+
+
+
+
+ {/* 漏斗 */}
+
+ {/* 四道工序环 */}
+
+ {stages.map((s, i) => {
+ const on = flow > (i + 0.5) / 5;
+ return (
+
+ {s}
+
+ );
+ })}
+
+ {/* 滴出的金色经验 */}
+
0.8 ? 1 : 0.1,
+ transform: `translateY(${(1 - flow) * -60}px)`,
+ }}
+ />
+
0.8 ? 1 : 0,
+ }}
+ >
+ 可用经验 z
+
+
+
+
+ z_i = H(τ_i)
+
+
+ );
+};
+
+/** 1-F:快慢双去路 */
+const TwoPaths: React.FC = () => {
+ const frame = useCurrentFrame();
+ const fast = interpolate(frame, [10, 45], [0, 1], {extrapolateRight: 'clamp', extrapolateLeft: 'clamp'});
+ const slow = interpolate(frame, [30, 100], [0, 1], {extrapolateRight: 'clamp', extrapolateLeft: 'clamp'});
+ return (
+
+
+
+ {/* 快路:上弧线到工位;慢路:下弧线到大脑。
+ 渐进绘制用 mask(pathLength=1 归一化坐标),虚线样式保留在原 path 上——
+ 两者须分离,否则 dash 量纲互相抵消(评审 #3) */}
+
+
0.9 ? 1 : 0,
+ }}
+ >
+
改工位 · 快
+
今天就能用上
+
+
0.9 ? 1 : 0,
+ }}
+ >
+
写大脑 · 慢
+
变成一辈子的本能
+
+
+
+ );
+};
+
+/** 1-G:三代演进时间轴 */
+const ThreeGens: React.FC = () => {
+ const frame = useCurrentFrame();
+ const {fps} = useVideoConfig();
+ const gens = [
+ {name: 'Gen 1 · 任务循环', icon: '🔁', desc: '会用工具 · 干完就忘', year: '2021', color: theme.dim},
+ {name: 'Gen 2 · 跨任务复用', icon: '📚', desc: '有记忆技能库 · 靠人配置', year: '2023', color: theme.harness},
+ {name: 'Gen 3 · 运行时系统', icon: '🏢', desc: '工位本身自动升级', year: '2025', color: theme.exp},
+ ];
+ return (
+
+
+
+
+ {gens.map((g, i) => {
+ const enter = spring({frame: frame - i * 16, fps, config: {damping: 200}});
+ return (
+
+
+
{g.icon}
+
+ {g.name}
+
+
{g.desc}
+
+ );
+ })}
+
+
+
+
+ ReAct → Voyager → Claude Code / Codex / Cursor · 四年半走完三代
+
+
+
+ );
+};
+
+export const P1Anatomy: React.FC<{scene: SceneRange}> = ({scene}) => {
+ const w = (fromId: string, toId?: string) => beatWindow(scene.sentences, scene.from, fromId, toId);
+ return (
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ );
+};
+
+/** 1-H / 2-A 共用:四管道总图 */
+export const FourDestinationsPreview: React.FC = () => {
+ const frame = useCurrentFrame();
+ const {fps} = useVideoConfig();
+ const dests = [
+ {icon: '🗂️', label: '技能库', color: theme.harness},
+ {icon: '📓', label: '记忆', color: theme.harness},
+ {icon: '🏭', label: '环境', color: theme.harness},
+ {icon: '🧠', label: '大脑', color: theme.params},
+ ];
+ return (
+
+
+
+ {dests.map((d, i) => {
+ const enter = spring({frame: frame - 6 - i * 5, fps, config: {damping: 200}});
+ return (
+
+
{d.icon}
+
+ {d.label}
+
+
+ );
+ })}
+
+
+ );
+};
diff --git a/media/experience-era-agents-video/video/src/scenes/P2FourDestinations.tsx b/media/experience-era-agents-video/video/src/scenes/P2FourDestinations.tsx
new file mode 100644
index 00000000..21b99abd
--- /dev/null
+++ b/media/experience-era-agents-video/video/src/scenes/P2FourDestinations.tsx
@@ -0,0 +1,611 @@
+import React from 'react';
+import {
+ AbsoluteFill,
+ Sequence,
+ interpolate,
+ spring,
+ useCurrentFrame,
+ useVideoConfig,
+} from 'remotion';
+import {FadeUp, Pill} from '../components/cards';
+import {theme} from '../design/theme';
+import {beatWindow} from '../timing';
+import type {SceneRange} from '../types';
+import {FourDestinationsPreview} from './P1Anatomy';
+
+/** 2-B:技能抽屉——SKILL.md 文件夹 σ=⟨M,I,R,A⟩ */
+const SkillDrawer: React.FC = () => {
+ const frame = useCurrentFrame();
+ const {fps} = useVideoConfig();
+ const open = interpolate(frame, [6, 26], [0, 1], {extrapolateRight: 'clamp', extrapolateLeft: 'clamp'});
+ const sections = [
+ {k: 'M', label: '封面 · 元数据', desc: '这技能干嘛的', color: theme.harness},
+ {k: 'I', label: '正文 · 指令', desc: '怎么干', color: theme.exp},
+ {k: 'R', label: '参考 · 资料', desc: '文档与样例', color: theme.harness},
+ {k: 'A', label: '附件 · 脚本', desc: '支撑落地', color: theme.params},
+ ];
+ return (
+
+
+
+
+ {sections.map((s, i) => {
+ const enter = spring({frame: frame - 24 - i * 5, fps, config: {damping: 200}});
+ return (
+
+ {s.k}
+ {s.label}
+ {s.desc}
+
+ );
+ })}
+
+
+
+ σ = ⟨ M, I, R, A ⟩ · SKILL.md 规范
+
+
+ );
+};
+
+/** 2-C:生命周期环(创建→使用→进化) */
+const LifecycleRing: React.FC = () => {
+ const frame = useCurrentFrame();
+ const {fps} = useVideoConfig();
+ const stages = [
+ {name: '创建', icon: '🛠️', desc: '专家手写 · 挖仓库 · 蒸馏文档'},
+ {name: '使用', icon: '🔍', desc: '找得到 · 搭得起来 · 跑得动'},
+ {name: '进化', icon: '🧬', desc: '部署证据 → 增删改库'},
+ ];
+ return (
+
+
+ {stages.map((s, i) => {
+ const enter = spring({frame: frame - i * 10, fps, config: {damping: 200}});
+ return (
+
+ {i > 0 ? (
+ →
+ ) : null}
+
+
{s.icon}
+
+ {s.name}
+
+
+ {s.desc}
+
+
+
+ );
+ })}
+
+
+
+ 库一大,找不到就是大问题 —— 好技能藏在角落,等于没有
+
+
+ SkillsWild / SkillRouter:大规模检索缺口
+
+
+
+ );
+};
+
+/** 2-D+2-E:验证门 + 负迁移数字面板 */
+const ValidationGate: React.FC = () => {
+ const frame = useCurrentFrame();
+ const {fps} = useVideoConfig();
+ const enterPanel = spring({frame: frame - 20, fps, config: {damping: 200}});
+ const passGate = interpolate(frame, [8, 20], [0, 1], {extrapolateRight: 'clamp', extrapolateLeft: 'clamp'});
+ return (
+
+
+ {/* 闸机 */}
+
+ {[0, 1, 2].map((i) => (
+
+ {i === 2 ? '✗' : '✓'}
+
+ ))}
+ {/* 闸门本体 */}
+
+
+ 及格才准入库
+
+
+ {/* 数字面板 */}
+
+
+
+16.2 分
+
86 任务 · 11 领域平均提升
+
+
+
16 / 84
+
任务反而变差 —— 负迁移
+
+
SkillsBench
+
+
+
+ );
+};
+
+/** 2-F:记忆五动作 */
+const MemoryOps: React.FC = () => {
+ const frame = useCurrentFrame();
+ const {fps} = useVideoConfig();
+ const ops = [
+ {name: '记', icon: '✍️', en: 'Write'},
+ {name: '压', icon: '🗜️', en: 'Compress'},
+ {name: '并', icon: '🔗', en: 'Consolidate'},
+ {name: '取', icon: '🔎', en: 'Retrieve'},
+ {name: '改', icon: '🧽', en: 'Update'},
+ ];
+ return (
+
+
+ {ops.map((o, i) => {
+ const enter = spring({frame: frame - i * 9, fps, config: {damping: 200}});
+ const stampDown = interpolate(frame - i * 9, [0, 6], [1.6, 1], {
+ extrapolateLeft: 'clamp',
+ extrapolateRight: 'clamp',
+ });
+ return (
+
0.5 ? stampDown : 0.5})`,
+ }}
+ >
+
+ {o.icon}
+
+
{o.name}
+
{o.en}
+
+ );
+ })}
+
+
+ );
+};
+
+/** 2-G:记忆三层自进化 + 两大坑 */
+const MemoryLayers: React.FC = () => {
+ const frame = useCurrentFrame();
+ const layers = ['内容', '机制', '策略'];
+ return (
+
+
+
+ {layers.map((l, i) => {
+ const size = 420 - i * 120;
+ const enter = interpolate(frame, [i * 14, i * 14 + 16], [0, 1], {
+ extrapolateRight: 'clamp',
+ extrapolateLeft: 'clamp',
+ });
+ return (
+
+
+ {l}变好
+
+
+ );
+ })}
+
+
+
+ 记太多 → 翻不动
+
+
+ 记太少 → 没料用
+
+
+ 陈旧记忆 → 悄悄带偏判断
+
+
+
+
+ );
+};
+
+/** 2-H:环境三层楼 + 天花板 */
+const EnvFloors: React.FC = () => {
+ const frame = useCurrentFrame();
+ const floors = [
+ {name: '可执行', icon: '⌨️', desc: '软件让 AI 真能操作'},
+ {name: '协议化', icon: '🔌', desc: '接口统一 · 经验能搬家'},
+ {name: '可学习', icon: '📡', desc: '反馈能当训练信号'},
+ ];
+ const ceil = interpolate(frame, [50, 70], [0, 1], {extrapolateRight: 'clamp', extrapolateLeft: 'clamp'});
+ return (
+
+
+
+ {floors.map((f, i) => {
+ const enter = interpolate(frame, [i * 16, i * 16 + 16], [0, 1], {
+ extrapolateRight: 'clamp',
+ extrapolateLeft: 'clamp',
+ });
+ return (
+
+
{f.icon}
+
+
+ {i + 1} 楼 · {f.name}
+
+
{f.desc}
+
+
+ );
+ })}
+ {/* 天花板虚线 */}
+
+
+ J*(E) 适应上限
+
+
+
+
+
+ 大部分环境卡在一楼半:能跑,但反馈太稀、没法学
+
+
+
+ );
+};
+
+/** 2-I:参数巩固——验证过的套路蒸馏进大脑 */
+const Consolidate: React.FC = () => {
+ const frame = useCurrentFrame();
+ const settle = interpolate(frame, [30, 90], [0, 1], {extrapolateRight: 'clamp', extrapolateLeft: 'clamp'});
+ return (
+
+
+
+ {[0, 1, 2, 3].map((i) => {
+ const enter = interpolate(frame, [i * 6, i * 6 + 10], [0, 1], {
+ extrapolateRight: 'clamp',
+ extrapolateLeft: 'clamp',
+ });
+ return (
+
+ ✓
+ 验证过的套路 #{i + 1}
+
+ );
+ })}
+
+ {/* 蒸馏漏斗 */}
+
+ {/* 大脑 */}
+
0.3 ? 1 : 0.3}}>
+
0.6 ? `0 0 90px ${theme.params}66` : 'none',
+ }}
+ >
+ 🧠
+
+
+ 变成肌肉记忆 · 跨任务跨用户
+
+
θ⁺ = Φ_M(θ, Z)
+
+
+
+ );
+};
+
+/** 2-J:工业现实——一边是真实循环,一边泼冷水 */
+const IndustryReality: React.FC = () => {
+ const frame = useCurrentFrame();
+ const {fps} = useVideoConfig();
+ const enter = spring({frame, fps, config: {damping: 200}});
+ return (
+
+
+
+
+ 💻
+ 编程工具厂商
+
+
+ {[0, 1, 2, 4, 6, 8].map((d) => (
+
+ ))}
+
用户反馈流
+
+
+ 生产环境反馈 → 聚合成奖励信号
+
→ 频繁更新模型权重
+
+
+ Cursor 实时 RL(Jackson et al., 2026)
+
+
+
+
🧊
+
+ 但论文泼了盆冷水:
+
+ 部署后从 trace 训练模型,
+
+ 公开证据还非常稀少。
+
+
+ 大部分自进化停在前三个去处
+
+
+
+
+ );
+};
+
+export const P2FourDestinations: React.FC<{scene: SceneRange}> = ({scene}) => {
+ const w = (fromId: string, toId?: string) => beatWindow(scene.sentences, scene.from, fromId, toId);
+ return (
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ );
+};
diff --git a/media/experience-era-agents-video/video/src/scenes/P3Meta.tsx b/media/experience-era-agents-video/video/src/scenes/P3Meta.tsx
new file mode 100644
index 00000000..a755f961
--- /dev/null
+++ b/media/experience-era-agents-video/video/src/scenes/P3Meta.tsx
@@ -0,0 +1,359 @@
+import React from 'react';
+import {
+ AbsoluteFill,
+ Sequence,
+ interpolate,
+ spring,
+ useCurrentFrame,
+ useVideoConfig,
+} from 'remotion';
+import {FadeUp, Pill} from '../components/cards';
+import {theme} from '../design/theme';
+import {beatWindow} from '../timing';
+import type {SceneRange} from '../types';
+
+/** 3-A:三级阶梯 + 谁控制进化 */
+const Ladder: React.FC = () => {
+ const frame = useCurrentFrame();
+ const {fps} = useVideoConfig();
+ const levels = [
+ {name: '自己攒资产', icon: '🎒', desc: '边干活边攒技能与记忆'},
+ {name: '学会怎么改进', icon: '🧭', desc: '失败 → 原则 → 下次引用'},
+ {name: '专职进化部门', icon: '🏛️', desc: '独立的 meta 层管进化'},
+ ];
+ return (
+
+
+ 谁来决定,经验往哪送?
+
+
+ {levels.map((l, i) => {
+ const h = 200 + i * 110;
+ const enter = spring({frame: frame - i * 12, fps, config: {damping: 200}});
+ return (
+
+
+
{l.icon}
+
+ 第 {i + 1} 级 · {l.name}
+
+
{l.desc}
+
+
+ );
+ })}
+
+
+ );
+};
+
+/** 3-B:第一级——卡片入背包 */
+const LevelOne: React.FC = () => {
+ const frame = useCurrentFrame();
+ const {fps} = useVideoConfig();
+ const cards = ['技能卡', '记忆卡', '经验卡'];
+ return (
+
+
+
🤖
+
+ {cards.map((c, i) => {
+ const enter = interpolate(frame, [10 + i * 14, 20 + i * 14], [0, 1], {
+ extrapolateRight: 'clamp',
+ extrapolateLeft: 'clamp',
+ });
+ return (
+
+ 🎫 {c}
+
+ );
+ })}
+
+
+ 🎒
+
+
+
+ 进化是干活的副产品
+
+
+ );
+};
+
+/** 3-C:第二级——失败提炼成原则 */
+const LevelTwo: React.FC = () => {
+ const frame = useCurrentFrame();
+ const distill = interpolate(frame, [20, 50], [0, 1], {extrapolateRight: 'clamp', extrapolateLeft: 'clamp'});
+ return (
+
+
+
+
💥
+
+ 我上次为什么搞砸
+
+
+ 失败轨迹复盘
+
+
+
→
+
+
📜
+
+ 原则 #1
+
+
+ 存入库 · 下次直接引用
+
+
+
+
+ MetaEvo:原则化自我修正
+
+
+ );
+};
+
+/** 3-D:第三级——员工冻结 + 图书管理员 */
+const LevelThree: React.FC = () => {
+ const frame = useCurrentFrame();
+ const {fps} = useVideoConfig();
+ const score = spring({frame: frame - 30, fps, config: {damping: 200}});
+ return (
+
+
+
+
🤖
+
+ 干活的员工
+
+
+ 🔒 冻结 · 一个字不许改
+
+
+
⇄
+
+
🧑📚
+
+ 图书管理员
+
+
+ {['➕ 增', '✏️ 改', '➖ 删'].map((op) => (
+
+ {op}
+
+ ))}
+
+
+
+ {/* 成绩单 */}
+
+ 📊
+
+ 每次改库,拿后面任务的成绩算绩效
+
+
+
+
+ SkillOS:冻结 Executor + 独立 Curator
+
+
+
+ );
+};
+
+/** 3-E:自指套娃——改进流程改进自己 */
+const SelfReference: React.FC = () => {
+ const frame = useCurrentFrame();
+ const depth = Math.min(4, 1 + Math.floor(frame / 16));
+ return (
+
+
+ {Array.from({length: depth}).map((_, i) => {
+ const scale = 1 - i * 0.18;
+ return (
+
+ 🛠️
+
+ 改进流程{depth > 1 && i < depth - 1 ? ' →' : ''}
+
+
+ );
+ })}
+
+
+
+ Hyperagents:连「怎么改进自己」的代码,也可以被改进
+
+
+
+ );
+};
+
+/** 3-F:裁判与运动员一起变形 */
+const Paradox: React.FC = () => {
+ const frame = useCurrentFrame();
+ const warp = Math.sin(frame * 0.05) * 14;
+ const darken = interpolate(frame, [40, 80], [0, 0.35], {extrapolateRight: 'clamp', extrapolateLeft: 'clamp'});
+ return (
+
+
+
+ 两者一起变形 · 失去稳定参照系
+
+
+
+ 论文的措辞很诚实:当前最大的开放问题之一
+
+
+
+ );
+};
+
+export const P3Meta: React.FC<{scene: SceneRange}> = ({scene}) => {
+ const w = (fromId: string, toId?: string) => beatWindow(scene.sentences, scene.from, fromId, toId);
+ return (
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ );
+};
diff --git a/media/experience-era-agents-video/video/src/scenes/P4Eval.tsx b/media/experience-era-agents-video/video/src/scenes/P4Eval.tsx
new file mode 100644
index 00000000..4a21cbd8
--- /dev/null
+++ b/media/experience-era-agents-video/video/src/scenes/P4Eval.tsx
@@ -0,0 +1,321 @@
+import React from 'react';
+import {
+ AbsoluteFill,
+ Sequence,
+ interpolate,
+ spring,
+ useCurrentFrame,
+ useVideoConfig,
+} from 'remotion';
+import {FadeUp, Pill} from '../components/cards';
+import {theme} from '../design/theme';
+import {beatWindow} from '../timing';
+import type {SceneRange} from '../types';
+
+/** 4-A:体检中心 + 刷分作弊 */
+const Checkup: React.FC = () => {
+ const frame = useCurrentFrame();
+ const {fps} = useVideoConfig();
+ const inflate = interpolate(frame, [30, 60], [30, 96], {extrapolateRight: 'clamp', extrapolateLeft: 'clamp'});
+ return (
+
+
+
+
+
🏥
+
+ 进化体检中心
+
+
「系统说自己进化了」
+
+
+
+
练过的题册上的分数
+
+
+ {Math.round(inflate)} 分
+
+
80 ? 1 : 0.1})`,
+ border: `4px solid ${theme.danger}`,
+ borderRadius: 10,
+ color: theme.danger,
+ fontFamily: theme.sans,
+ fontWeight: 900,
+ fontSize: 30,
+ padding: '4px 14px',
+ }}
+ >
+ 刷分 ≠ 变强
+
+
+
+
+ );
+};
+
+/** 4-B:六条体检指标 */
+const SixTargets: React.FC = () => {
+ const frame = useCurrentFrame();
+ const rows = [
+ {icon: '🆕', zh: '新任务涨分', en: 'Held-out gain'},
+ {icon: '🧠', zh: '老任务不忘', en: 'Backward retention'},
+ {icon: '⏳', zh: '持续稳定', en: 'Longitudinal stability'},
+ {icon: '💰', zh: '性价比', en: 'Improvement efficiency'},
+ {icon: '🧩', zh: '路径归因', en: 'Path attribution'},
+ {icon: '🛡️', zh: '安全不退化', en: 'Safety non-regression'},
+ ];
+ return (
+
+
+
+ 自我进化 · 六条硬指标
+
+
+
+ {rows.map((r, i) => {
+ const enter = interpolate(frame, [i * 8, i * 8 + 12], [0, 1], {
+ extrapolateRight: 'clamp',
+ extrapolateLeft: 'clamp',
+ });
+ const stamp = interpolate(frame - i * 8, [6, 12], [1.5, 1], {
+ extrapolateLeft: 'clamp',
+ extrapolateRight: 'clamp',
+ });
+ return (
+
0.9 ? theme.harness : theme.panelBorder}`,
+ opacity: enter,
+ transform: `scale(${enter > 0.9 ? stamp : 0.8})`,
+ minWidth: 560,
+ }}
+ >
+ {r.icon}
+ {r.zh}
+ {r.en}
+
+ );
+ })}
+
+
+ );
+};
+
+/** 4-C:同一 AI 跑两遍成绩差异 */
+const FlakyRuns: React.FC = () => {
+ const frame = useCurrentFrame();
+ const runA = interpolate(frame, [10, 34], [0, 0.92], {extrapolateRight: 'clamp', extrapolateLeft: 'clamp'});
+ const runB = interpolate(frame, [30, 54], [0, 0.31], {extrapolateRight: 'clamp', extrapolateLeft: 'clamp'});
+ return (
+
+
+ {[
+ {label: '第一次跑', v: runA, color: theme.ok},
+ {label: '第二次跑', v: runB, color: theme.danger},
+ ].map((r) => (
+
+
+
{r.label}
+
+ {Math.round(r.v * 100)} 分
+
+
+ ))}
+
+
+
+ 一次考得好,可能只是运气好
+
+
+ tau-bench:repeated-run reliability ≪ single-run success
+
+
+
+ );
+};
+
+/** 4-D:T0→T1→T2 纵向体检 */
+const Longitudinal: React.FC = () => {
+ const frame = useCurrentFrame();
+ const points = [
+ {t: 'T0', label: '改进前', color: theme.dim},
+ {t: 'T1', label: '改进后', color: theme.harness},
+ {t: 'T2', label: '过段时间', color: theme.exp},
+ ];
+ const lineGrow = interpolate(frame, [10, 70], [0, 1], {extrapolateRight: 'clamp', extrapolateLeft: 'clamp'});
+ return (
+
+
+
+ {points.map((p, i) => {
+ const enter = spring({frame: frame - i * 18, fps: 30, config: {damping: 200}});
+ return (
+
+
+ {p.t}
+
+
{p.label}
+
🩺
+
+ );
+ })}
+ {/* 旧题重考循环 */}
+
+ 留着旧题 · 反复重考
+
+
+
+
+ SIP-Bench:追着同一个进化的 AI 反复体检
+
+
+
+ );
+};
+
+/** 4-E:题库腐烂 */
+const RotBench: React.FC = () => {
+ const frame = useCurrentFrame();
+ const {fps} = useVideoConfig();
+ const decay = interpolate(frame, [20, 70], [1, 0.3], {extrapolateRight: 'clamp', extrapolateLeft: 'clamp'});
+ return (
+
+
+
+
📄
+
2024 年的考题
+
+ 模型都见过了 · 分数虚高
+
+
+
+ 🚚
+
+
+
🆕
+
持续换新的考题
+
+ SWE-bench-Live / SWE-rebench
+
+
+
+
+
+ 连考题本身,都得持续换新 —— 不然分数会腐烂
+
+
+
+ );
+};
+
+export const P4Eval: React.FC<{scene: SceneRange}> = ({scene}) => {
+ const w = (fromId: string, toId?: string) => beatWindow(scene.sentences, scene.from, fromId, toId);
+ return (
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ );
+};
diff --git a/media/experience-era-agents-video/video/src/scenes/P5Safety.tsx b/media/experience-era-agents-video/video/src/scenes/P5Safety.tsx
new file mode 100644
index 00000000..49ffec19
--- /dev/null
+++ b/media/experience-era-agents-video/video/src/scenes/P5Safety.tsx
@@ -0,0 +1,442 @@
+import React from 'react';
+import {
+ AbsoluteFill,
+ Sequence,
+ interpolate,
+ spring,
+ useCurrentFrame,
+ useVideoConfig,
+} from 'remotion';
+import {FadeUp, Pill} from '../components/cards';
+import {theme} from '../design/theme';
+import {beatWindow} from '../timing';
+import type {SceneRange} from '../types';
+
+/** 5-A:移动靶 + 旧审计标签错位 */
+const MovingTarget: React.FC = () => {
+ const frame = useCurrentFrame();
+ const drift = Math.sin(frame * 0.03) * 220;
+ const stampOld = interpolate(frame, [10, 22], [1.8, 1], {extrapolateRight: 'clamp', extrapolateLeft: 'clamp'});
+ return (
+
+
+ {/* 靶子 */}
+
+ {/* 旧审计标签——留在原地,靶子已漂走 */}
+
+ 出厂审计 ✅
+
+
+ 你审计的是昨天的它
+
+
+ 今天它已经改过自己
+
+
+
+
+ 安全:从「对齐快照」变成「治理过程」
+
+
+
+ );
+};
+
+/** 5-B:技能商店投毒(ClawHavoc) */
+const SkillStore: React.FC = () => {
+ const frame = useCurrentFrame();
+ const scan = interpolate(frame, [16, 44], [0, 1], {extrapolateRight: 'clamp', extrapolateLeft: 'clamp'});
+ const items = Array.from({length: 24});
+ return (
+
+
+ AI 技能市场
+
+
+ {items.map((_, i) => {
+ const isBad = i % 6 === 2;
+ const revealed = scan > (i % 12) / 14;
+ return (
+
+ {isBad && revealed ? '☠️' : '📦'}
+
+ );
+ })}
+
+
+
+ ~1,200 个恶意技能
+
+
+ 窃取 API 密钥 · 加密钱包 · 浏览器凭证
+
+
+ ClawHavoc:攻击者根本不用攻破模型 —— 装个「技能」,AI 自己交出钥匙
+
+
+
+ );
+};
+
+/** 5-C:记忆投毒——潜伏注入 */
+const MemoryPoison: React.FC = () => {
+ const frame = useCurrentFrame();
+ const inject = interpolate(frame, [8, 26], [-500, 0], {extrapolateRight: 'clamp', extrapolateLeft: 'clamp'});
+ const resurface = [36, 60, 84].map((t) =>
+ interpolate(frame, [t, t + 10], [0, 1], {extrapolateRight: 'clamp', extrapolateLeft: 'clamp'}),
+ );
+ return (
+
+
+
+
6 ? 1 : 0,
+ }}
+ >
+ ⚠️ 一句被埋下的话
+
+
+
+ 一次对话 · 一次接触
+
+
+
+ {/* 记忆库书架 */}
+
+
记忆库
+
+ {['经验', '偏好', '事实', '计划', '摘要', '教训'].map((t) => (
+
+ {t}
+
+ ))}
+ {/* 被投毒的条目 */}
+ 24 ? 1 : 0,
+ boxShadow: `0 0 26px ${theme.danger}55`,
+ }}
+ >
+ ☠️ 那句话
+
+
+
+ {/* 每次干活被翻出 */}
+
+ {resurface.map((op, i) => (
+
+ 🔧
+ ←
+ 第 {i + 1} 次干活又被翻出
+
+ ))}
+
+
+
+
+ 严格安全约束下,仍超 90% 场景可被操纵
+
+
+ From Storage to Steering:一次注入 · 永久潜伏
+
+
+
+ );
+};
+
+/** 5-D:反馈操纵——拧动评分仪表盘 */
+const FeedbackHack: React.FC = () => {
+ const frame = useCurrentFrame();
+ const needle = interpolate(frame, [16, 40], [-80, 70], {extrapolateRight: 'clamp', extrapolateLeft: 'clamp'});
+ const swap = interpolate(frame, [42, 54], [0, 1], {extrapolateRight: 'clamp', extrapolateLeft: 'clamp'});
+ return (
+
+
+
+
30 ? theme.danger : theme.dim,
+ transformOrigin: 'bottom center',
+ transform: `translateY(-65px) rotate(${needle}deg)`,
+ }}
+ />
+
+ 「什么算进步」评分
+
+
+ {/* 坏改动贴绿标 */}
+
+
🧾
+
+ 坏改动
+
+
0.5 ? theme.ok : theme.panelBorder}`,
+ color: swap > 0.5 ? theme.ok : theme.dim,
+ fontFamily: theme.sans,
+ fontSize: 24,
+ fontWeight: 700,
+ transform: `rotate(${swap * -6}deg)`,
+ }}
+ >
+ {swap > 0.5 ? '✓ 改进 · 已保留' : '? 待评分'}
+
+
+
+
+
+ 不攻击 AI 本身 —— 污染「什么算进步」的评分
+
+
+
+ );
+};
+
+/** 5-E:四味药 */
+const FourRemedies: React.FC = () => {
+ const frame = useCurrentFrame();
+ const rows = [
+ {icon: '🛂', zh: '准入测试', en: 'Admission tests', desc: '新技能新记忆 · 先考试再上岗'},
+ {icon: '🔒', zh: '最小权限', en: 'Least privilege', desc: '默认什么都不能碰 · 用啥申请啥'},
+ {icon: '⏪', zh: '版本回滚', en: 'Versioning & rollback', desc: '改坏了 · 一键恢复上个认证版本'},
+ {icon: '🔄', zh: '持续再认证', en: 'Continuous re-certification', desc: '安全检查不是一次性 · 是常态体检'},
+ ];
+ return (
+
+
+ 药方 · 四味药
+
+
+ {rows.map((r, i) => {
+ const enter = interpolate(frame, [i * 10, i * 10 + 14], [0, 1], {
+ extrapolateRight: 'clamp',
+ extrapolateLeft: 'clamp',
+ });
+ return (
+
0.9 ? theme.ok : theme.panelBorder}`,
+ opacity: enter,
+ transform: `translateX(${(1 - enter) * -60}px)`,
+ minWidth: 900,
+ }}
+ >
+ {r.icon}
+ {r.zh}
+ {r.desc}
+ {r.en}
+
+ );
+ })}
+
+
+ );
+};
+
+/** 5-F:AI-45° 双线爬坡 */
+const FortyFive: React.FC = () => {
+ const frame = useCurrentFrame();
+ const climb = interpolate(frame, [10, 70], [0, 1], {extrapolateRight: 'clamp', extrapolateLeft: 'clamp'});
+ const diverge = interpolate(frame, [76, 100], [0, 40], {extrapolateRight: 'clamp', extrapolateLeft: 'clamp'});
+ return (
+
+
+
+ AI-45° Law:能力涨多快,安全就得涨多快
+
+
+ );
+};
+
+export const P5Safety: React.FC<{scene: SceneRange}> = ({scene}) => {
+ const w = (fromId: string, toId?: string) => beatWindow(scene.sentences, scene.from, fromId, toId);
+ return (
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ );
+};
diff --git a/media/experience-era-agents-video/video/src/scenes/P6Ending.tsx b/media/experience-era-agents-video/video/src/scenes/P6Ending.tsx
new file mode 100644
index 00000000..3bd0dbce
--- /dev/null
+++ b/media/experience-era-agents-video/video/src/scenes/P6Ending.tsx
@@ -0,0 +1,272 @@
+import React from 'react';
+import {
+ AbsoluteFill,
+ Sequence,
+ interpolate,
+ spring,
+ useCurrentFrame,
+ useVideoConfig,
+} from 'remotion';
+import {FadeUp, QuoteCard} from '../components/cards';
+import {theme} from '../design/theme';
+import {beatWindow} from '../timing';
+import type {SceneRange} from '../types';
+
+/** 6-A:星空开放问题 */
+const OpenQuestions: React.FC = () => {
+ const frame = useCurrentFrame();
+ const qs = [
+ {q: '新本事,还是本来就有的潜能?', icon: '🌱'},
+ {q: '一直吃自己产的经验,会越吃越窄吗?', icon: '🌀'},
+ {q: '图片视频经验,怎么压缩归档?', icon: '🖼️'},
+ ];
+ return (
+
+ {/* 星星 */}
+ {Array.from({length: 26}).map((_, i) => {
+ const tw = 0.4 + 0.6 * Math.abs(Math.sin(frame * 0.04 + i * 2.1));
+ return (
+
+ );
+ })}
+
+ {qs.map((q, i) => {
+ const enter = spring({frame: frame - i * 14, fps: 30, config: {damping: 200}});
+ return (
+
+
+ {q.icon}
+ {q.q}
+ ?
+
+
+ );
+ })}
+
+
+ );
+};
+
+/** 6-C:三块拼图 */
+const ThreePuzzles: React.FC = () => {
+ const frame = useCurrentFrame();
+ const pieces = [
+ {zh: '可靠的反馈', icon: '📡'},
+ {zh: '安全的自我修改架构', icon: '🛡️'},
+ {zh: '评测 = 持续体检', icon: '🩺'},
+ ];
+ return (
+
+
+
+ 这条路还缺的三块拼图
+
+
+
+ {pieces.map((p, i) => {
+ const drop = spring({frame: frame - i * 12, fps: 30, config: {damping: 14}});
+ return (
+
0.95 ? theme.ok : theme.panelBorder}`,
+ display: 'flex',
+ flexDirection: 'column',
+ justifyContent: 'center',
+ alignItems: 'center',
+ gap: 16,
+ transform: `translateY(${(1 - drop) * -160}px) rotate(${(1 - drop) * 12}deg)`,
+ opacity: drop,
+ }}
+ >
+ {p.icon}
+
+ {p.zh}
+
+
+ );
+ })}
+
+
+ );
+};
+
+/** 6-D:系列呼应——上一集与本集并排 */
+const SeriesEcho: React.FC = () => {
+ const frame = useCurrentFrame();
+ const {fps} = useVideoConfig();
+ const prev = spring({frame: frame - 6, fps, config: {damping: 200}});
+ const curr = spring({frame: frame - 20, fps, config: {damping: 200}});
+ return (
+
+
+
+
+
+
+
+
+ 上期:AI 如何自己变强?
+
+
+ 改大脑,还是改装备?
+
+ ——「改什么」
+
+
+
+
+
+
+
+
+
+
+
+ 本期:上线之后,AI 才开始上学
+
+
+ 上了班之后,经验怎么攒?
+
+ ——「怎么攒」
+
+
+
+
+ );
+};
+
+/** 6-E:论文引用卡(fade-out 窗口收在本 beat 末帧内,避免渐黑被 Sequence 截断硬切) */
+const FinalCard: React.FC<{endFrame: number}> = ({endFrame}) => {
+ const frame = useCurrentFrame();
+ const {fps} = useVideoConfig();
+ const enter = spring({frame, fps, config: {damping: 200}});
+ const fadeStart = Math.max(0, endFrame - 50);
+ const fade = interpolate(frame, [fadeStart, endFrame], [1, 0], {extrapolateRight: 'clamp', extrapolateLeft: 'clamp'});
+ return (
+
+
+
88 页综述 · 2026-06
+
+ Self-Improving Agents in the Era of Experience:
+
+ A Survey of Self- to Meta-Evolution
+
+
+ C. Jiang, J. Zhong, Y. Fu, et al. · 清华大学 × Horizon Research (Frontis.AI)
+
+
+ 📖 推荐读原文
+
+
+
+ );
+};
+
+export const P6Ending: React.FC<{scene: SceneRange}> = ({scene}) => {
+ const w = (fromId: string, toId?: string) => beatWindow(scene.sentences, scene.from, fromId, toId);
+ /** 某句在本 Sequence 内的结束帧(局部坐标),供结尾渐黑对齐 beat 实际时长 */
+ const endFrame = (id: string) => {
+ const s = scene.sentences.find((x) => x.id === id);
+ if (!s) {
+ throw new Error(`endFrame: 未找到句 id ${id}`);
+ }
+ return s.from + s.durationInFrames - scene.from;
+ };
+ return (
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ );
+};
diff --git a/media/experience-era-agents-video/video/src/timing.ts b/media/experience-era-agents-video/video/src/timing.ts
new file mode 100644
index 00000000..8bf6f2a5
--- /dev/null
+++ b/media/experience-era-agents-video/video/src/timing.ts
@@ -0,0 +1,59 @@
+import type {ManifestItem, SceneRange, TimedSentence} from './types';
+
+export const FPS = 30;
+/** 句间停顿 */
+const SENTENCE_GAP_SEC = 0.32;
+/** 幕间额外停顿(转场呼吸) */
+const SCENE_GAP_SEC = 0.9;
+/** 片头静默引导 */
+const LEAD_IN_SEC = 0.6;
+/** 片尾静默淡出 */
+const TAIL_SEC = 2.0;
+
+export function computeTimeline(manifest: ManifestItem[]): {
+ timed: TimedSentence[];
+ scenes: SceneRange[];
+ totalDurationInFrames: number;
+} {
+ const timed: TimedSentence[] = [];
+ let cursor = Math.round(LEAD_IN_SEC * FPS);
+ for (let i = 0; i < manifest.length; i++) {
+ const item = manifest[i];
+ const next = manifest[i + 1];
+ const gap = next && next.scene !== item.scene ? SENTENCE_GAP_SEC + SCENE_GAP_SEC : SENTENCE_GAP_SEC;
+ const durationInFrames = Math.max(1, Math.round((item.durationSec + gap) * FPS));
+ timed.push({...item, from: cursor, durationInFrames});
+ cursor += durationInFrames;
+ }
+
+ const scenes: SceneRange[] = [];
+ for (const s of timed) {
+ const last = scenes[scenes.length - 1];
+ if (!last || last.scene !== s.scene) {
+ scenes.push({scene: s.scene, from: s.from, durationInFrames: s.durationInFrames, sentences: [s]});
+ } else {
+ last.durationInFrames = s.from + s.durationInFrames - last.from;
+ last.sentences.push(s);
+ }
+ }
+
+ return {timed, scenes, totalDurationInFrames: cursor + Math.round(TAIL_SEC * FPS)};
+}
+
+/** 场景内使用:取一段句 id 区间(含端点)的本地 Sequence 窗口 */
+export function beatWindow(
+ sceneSentences: TimedSentence[],
+ sceneFrom: number,
+ fromId: string,
+ toId?: string,
+): {from: number; durationInFrames: number} {
+ const start = sceneSentences.find((s) => s.id === fromId);
+ const end = sceneSentences.find((s) => s.id === (toId ?? fromId));
+ if (!start || !end) {
+ throw new Error(`beatWindow: 未找到句 id ${fromId}..${toId}`);
+ }
+ return {
+ from: start.from - sceneFrom,
+ durationInFrames: end.from + end.durationInFrames - start.from,
+ };
+}
diff --git a/media/experience-era-agents-video/video/src/types.ts b/media/experience-era-agents-video/video/src/types.ts
new file mode 100644
index 00000000..0207f4d1
--- /dev/null
+++ b/media/experience-era-agents-video/video/src/types.ts
@@ -0,0 +1,24 @@
+export type ManifestItem = {
+ /** 句 id,如 p2-15,对应 public/audio/{id}.mp3 */
+ id: string;
+ /** 所属幕,如 P2 */
+ scene: string;
+ /** 口播文本(同字幕) */
+ text: string;
+ /** 该句音频实测时长(秒) */
+ durationSec: number;
+};
+
+export type TimedSentence = ManifestItem & {
+ /** 全片时间轴上的起始帧 */
+ from: number;
+ /** 含句间停顿的占用帧数 */
+ durationInFrames: number;
+};
+
+export type SceneRange = {
+ scene: string;
+ from: number;
+ durationInFrames: number;
+ sentences: TimedSentence[];
+};
diff --git a/media/experience-era-agents-video/video/tsconfig.json b/media/experience-era-agents-video/video/tsconfig.json
new file mode 100644
index 00000000..97f1b5e1
--- /dev/null
+++ b/media/experience-era-agents-video/video/tsconfig.json
@@ -0,0 +1,15 @@
+{
+ "compilerOptions": {
+ "target": "ES2022",
+ "module": "ESNext",
+ "moduleResolution": "Bundler",
+ "jsx": "react-jsx",
+ "strict": true,
+ "skipLibCheck": true,
+ "esModuleInterop": true,
+ "forceConsistentCasingInFileNames": true,
+ "noEmit": true,
+ "resolveJsonModule": true
+ },
+ "include": ["src", "remotion.config.ts"]
+}
diff --git a/media/pipeline/README.md b/media/pipeline/README.md
new file mode 100644
index 00000000..17cbdb15
--- /dev/null
+++ b/media/pipeline/README.md
@@ -0,0 +1,101 @@
+# 科普视频制作 Pipeline(公共基建)
+
+> 从「论文精读 → 逐字稿 → 配音 → 代码动画 → 终渲」全链路中沉淀的**仓库级可复用流水线**。
+> 首个完整范例:[《AI 如何自己变强?》](../self-improving-agents-video/README.md)(Remotion 工程模式);轻量替代:[video-package 制作包模式](../../video-package/README.md)。
+
+## 一、Pipeline 总览(9 Stages)
+
+```mermaid
+flowchart LR
+ subgraph S["内容层(文档驱动)"]
+ A[① 论文精读提取
并行子代理] --> B[② 策划案
受众/结构/视觉契约]
+ B --> C[③ 逐字稿 narration.md
★单一事实源]
+ C --> D[④ 双重校验
真实性回溯+易懂性]
+ D --> E[⑤ 分镜表 storyboard.md]
+ end
+ subgraph P["生产层(工具驱动)"]
+ C --> F[⑥ TTS 合成
逐句 mp3+manifest]
+ E --> G[⑦ Remotion 场景实现]
+ F --> G
+ G --> H[⑧ 草渲+抽帧 QA
迭代修正]
+ H --> I[⑨ 终渲 1080p30]
+ end
+ style C fill:#1a3a5c,stroke:#4A9EFF,color:#fff
+ style F fill:#5c3a1a,stroke:#FF9F45,color:#fff
+ style I fill:#2d5c1a,stroke:#7ED321,color:#fff
+```
+
+每个 Stage 的代理提示词规格见 [skills/](./skills/)(01–05 覆盖内容层),可直接作为子代理 prompt 或未来挂载为 `.claude/skills/` 的底稿。
+
+## 二、工程目录约定
+
+每集视频一个 `media/
-video/` 工程:
+
+```
+media/-video/
+├── README.md # 本集说明(目录表/复现流水线/视觉契约/许可)
+├── research/paper-notes.md # 事实源:全部口播断言须可回溯至此
+├── script/
+│ ├── planning.md # 策划案
+│ ├── narration.md # 逐字稿(唯一维护处,勿改 narration.json)
+│ ├── narration.json # 派生物(build_narration.py 生成)
+│ └── storyboard.md # 分镜表(镜号↔句 id 区间↔画面↔动效)
+├── scripts/*.py # 薄包装 → ../../pipeline/scripts/(保 CLI 契约)
+├── video/ # Remotion 独立 pnpm 工程(--ignore-workspace 隔离)
+└── out/ # 渲染产物(gitignored)
+```
+
+**格式契约**(`build_narration.py` 的解析规则):
+- narration.md:`## P0 标题` 分幕 + `- [p0-01] 文本` 一句一行;句 id 必须以幕名小写为前缀、全片唯一。
+- `>` 引用块为画面备注,不进配音;英文方法名做角标不口播。
+
+## 三、公共脚本(单一事实源)
+
+| 脚本 | 用途 | 工程内等价调用 |
+|---|---|---|
+| [scripts/build_narration.py](./scripts/build_narration.py) | narration.md → narration.json + 时长估算 | `uv run --no-project scripts/build_narration.py` |
+| [scripts/tts.py](./scripts/tts.py) | 逐句 edge-tts 合成 + 时长 manifest(幂等) | `uv run --no-project --with edge-tts --with mutagen scripts/tts.py` |
+| [scripts/qa_frames.py](./scripts/qa_frames.py) | 按句 id 抽帧视觉 QA | `uv run --no-project scripts/qa_frames.py out/draft.mp4 --scene P2` |
+
+中心脚本以 `--project <工程根>` 参数化;工程内 `scripts/*.py` 为薄包装(透传参数、保持原 CLI)。改造/迭代只改 `media/pipeline/scripts/`,验证门 = 受影响工程的 `narration.json` / `manifest.json` 字节级不变。
+
+## 四、复用边界(显式权衡)
+
+- **Python 脚本:集中共享(SSOT)**——三个纯文本变换工具,跨集零差异,中心化防 split-brain。
+- **Remotion 工程原语:复制适配,不做共享包**——`timing.ts` / `Subtitle` / `cards.tsx` / `theme.ts` 等每集复制后按本集视觉契约修改。理由:每集工程须保持 pnpm `--ignore-workspace` 独立可渲染(嵌套 workspace 隔离 + Remotion 版本自由),共享 TS 包会把「一集的视觉改动」泄漏进其他集。复用时以首集工程为模板复制 `video/` 骨架。
+- **每集视觉契约独立设计**(色彩语义映射到本集核心概念),但底层规范复用:深色底 `#0E1116` 系、警示红 `#FF5C5C`、确认绿 `#7ED321`、金句卡衬线体、公式只作角标彩蛋。
+
+## 五、音画同步机制(零手工对轨)
+
+每句一段 MP3;`tts.py` 产出 `video/public/audio/manifest.json`(含每句实测时长);Remotion `calculateMetadata` 读取 manifest 计算全片时间轴(默认句间 0.32s、幕间 +0.9s、片头 0.6s、片尾 2s)。**改稿后只需重跑:build → tts → render**。
+
+⚠️ 若工程自定义了 `timing.ts` 常量,须同步 `qa_frames.py` 顶部的镜像常量,否则抽帧时间错位。
+
+## 六、新集脚手架清单
+
+1. `cp -r` 上一集工程目录骨架(README/research/script/scripts/video),改 slug 与内容。
+2. `video/package.json` 改 `name`;清空 scenes 重建;`theme.ts` 换本集色板。
+3. 根 `.gitignore` 追加本集产物规则(**不能放工程内**——根级裸 `.gitignore` 规则会挡住嵌套 ignore 文件):
+ ```
+ media/-video/video/public/audio/
+ media/-video/out/
+ media/-video/**/*.mp4
+ media/-video/**/*.mp3
+ media/-video/**/*.wav
+ ```
+4. `cd video && pnpm install --ignore-workspace`(必须显式忽略根 workspace;`onlyBuiltDependencies: [esbuild]` 已在 package.json);装完检查根 lockfile 零变更。
+5. 按 [skills/](./skills/) 01→05 顺序走内容层,再进生产层。
+
+## 七、两种工程模式
+
+| | Remotion 工程模式 | 轻量制作包模式 |
+|---|---|---|
+| 载体 | `media/-video/video/`(Remotion + React) | `video-package/`(单文件 Canvas HTML) |
+| 动画 | 全代码动画,可编程复渲 | 浏览器手动录屏 |
+| 配音 | edge-tts + manifest 自动对轨 | 人工录音/剪辑对齐 |
+| 适用 | 中长视频、多轮迭代、可复现 | 快速产出、低工程成本 |
+| 范例 | [《AI 如何自己变强?》](../self-improving-agents-video/README.md) | [《当 AI 开始给自己当老师》](../../video-package/README.md) |
+
+## 八、许可注意
+
+Remotion 对超过 3 人的公司需商业授权(个人/小团队免费);edge-tts 为微软在线语音,发布前确认平台对合成语音的标注要求;不使用任何未经授权的第三方图片/音频素材。
diff --git a/media/pipeline/scripts/build_narration.py b/media/pipeline/scripts/build_narration.py
new file mode 100644
index 00000000..36845d2c
--- /dev/null
+++ b/media/pipeline/scripts/build_narration.py
@@ -0,0 +1,60 @@
+#!/usr/bin/env python3
+"""从 narration.md 解析生成 narration.json(逐句:id/scene/text)——公共管线版本。
+
+narration.md 是单一事实源;本脚本是纯派生转换,不做任何内容改写。
+适用于任何 `media/*-video/` 科普视频工程(目录约定见 media/pipeline/README.md)。
+
+用法:uv run --no-project media/pipeline/scripts/build_narration.py --project media/<工程>
+ 工程内薄包装等价于:uv run --no-project scripts/build_narration.py
+"""
+
+from __future__ import annotations
+
+import argparse
+import json
+import re
+from pathlib import Path
+
+LINE_RE = re.compile(r"^- \[(?P[a-z0-9-]+)\]\s+(?P.+)$")
+SCENE_RE = re.compile(r"^## (?PP\d+)\b")
+
+
+def main() -> None:
+ parser = argparse.ArgumentParser(description="narration.md → narration.json")
+ parser.add_argument("--project", default=".", help="视频工程根目录(含 script/)")
+ args = parser.parse_args()
+
+ root = Path(args.project).resolve()
+ src = root / "script" / "narration.md"
+ dst = root / "script" / "narration.json"
+
+ scene = ""
+ items: list[dict[str, str]] = []
+ seen: set[str] = set()
+ for raw in src.read_text(encoding="utf-8").splitlines():
+ if m := SCENE_RE.match(raw):
+ scene = m.group("scene")
+ continue
+ if m := LINE_RE.match(raw):
+ sid, text = m.group("id"), m.group("text").strip()
+ if sid in seen:
+ raise SystemExit(f"重复句 id: {sid}")
+ if not sid.startswith(scene.lower() + "-"):
+ raise SystemExit(f"句 id {sid} 与所在幕 {scene} 不一致")
+ seen.add(sid)
+ items.append({"id": sid, "scene": scene, "text": text})
+
+ dst.write_text(
+ json.dumps(items, ensure_ascii=False, indent=2) + "\n", encoding="utf-8"
+ )
+ total_chars = sum(len(i["text"]) for i in items)
+ per_scene: dict[str, int] = {}
+ for i in items:
+ per_scene[i["scene"]] = per_scene.get(i["scene"], 0) + 1
+ print(f"句数: {len(items)} 总字数: {total_chars}")
+ print(f"各幕句数: {per_scene}")
+ print(f"估算时长(280字/分): {total_chars / 280:.1f} 分钟")
+
+
+if __name__ == "__main__":
+ main()
diff --git a/media/pipeline/scripts/qa_frames.py b/media/pipeline/scripts/qa_frames.py
new file mode 100644
index 00000000..80cc0d94
--- /dev/null
+++ b/media/pipeline/scripts/qa_frames.py
@@ -0,0 +1,102 @@
+#!/usr/bin/env python3
+"""按句 id 从渲染产物中抽帧,用于视觉 QA——公共管线版本。
+
+时序常量须与工程的 video/src/timing.ts 保持一致(FPS/句间停顿/幕间停顿/片头引导)。
+若工程自定义了 timing 常量,须同步本文件顶部的镜像常量。
+
+用法:uv run --no-project media/pipeline/scripts/qa_frames.py --project media/<工程> \
+ <句id> [句id ...]
+ uv run --no-project media/pipeline/scripts/qa_frames.py --project media/<工程> \
+ --scene P1 # 该幕抽样至多 ~8 帧(与 ids 二选一)
+输出:<工程>/out/frames/{句id}.png
+"""
+
+from __future__ import annotations
+
+import argparse
+import json
+import subprocess
+from pathlib import Path
+
+# 与各工程 video/src/timing.ts 对齐(管线默认值;改过 timing 的工程须同步)
+FPS = 30
+SENTENCE_GAP = 0.32
+SCENE_GAP = 0.9
+LEAD_IN = 0.6
+
+
+def timeline(manifest: Path) -> dict[str, tuple[float, float]]:
+ items = json.loads(manifest.read_text(encoding="utf-8"))
+ result: dict[str, tuple[float, float]] = {}
+ cursor_frames = round(LEAD_IN * FPS)
+ for i, item in enumerate(items):
+ nxt = items[i + 1] if i + 1 < len(items) else None
+ gap = SENTENCE_GAP + (SCENE_GAP if nxt and nxt["scene"] != item["scene"] else 0)
+ dur_frames = max(1, round((item["durationSec"] + gap) * FPS))
+ result[item["id"]] = (cursor_frames / FPS, dur_frames / FPS)
+ cursor_frames += dur_frames
+ return result
+
+
+def main() -> None:
+ parser = argparse.ArgumentParser(description="按句 id 抽帧视觉 QA")
+ parser.add_argument("--project", default=".", help="视频工程根目录(含 video/ 与 out/)")
+ parser.add_argument("--scene", help="按幕抽样(如 P1),与位置参数 ids 二选一")
+ parser.add_argument("--offset", type=float, default=0.0, help="时间轴整体偏移(草渲与终渲时间基准不一致时用)")
+ parser.add_argument("video", help="渲染产物 mp4 路径")
+ parser.add_argument("ids", nargs="*", help="句 id 列表(与 --scene 二选一)")
+ args = parser.parse_args()
+ if bool(args.scene) == bool(args.ids):
+ parser.error("ids 与 --scene 必须二选一")
+
+ root = Path(args.project).resolve()
+ video = Path(args.video).resolve()
+ manifest = root / "video" / "public" / "audio" / "manifest.json"
+ out = root / "out" / "frames"
+
+ tl = timeline(manifest)
+ offset = args.offset
+ if args.scene:
+ prefix = args.scene.lower() + "-"
+ ids = [k for k in tl if k.startswith(prefix)]
+ ids = ids[:: max(1, len(ids) // 8)] # 每幕最多抽 ~8 帧
+ else:
+ ids = args.ids
+
+ out.mkdir(parents=True, exist_ok=True)
+ ffmpeg = ["pnpm", "exec", "remotion", "ffmpeg"]
+ for sid in ids:
+ if sid not in tl:
+ print(f"跳过未知句 id: {sid}")
+ continue
+ start, dur = tl[sid]
+ ts = start + dur / 2 - offset
+ dst = out / f"{sid}.png"
+ try:
+ subprocess.run(
+ [
+ *ffmpeg,
+ "-y",
+ "-ss",
+ f"{ts:.3f}",
+ "-i",
+ str(video),
+ "-frames:v",
+ "1",
+ "-update",
+ "1",
+ str(dst),
+ ],
+ cwd=root / "video",
+ check=True,
+ capture_output=True,
+ text=True,
+ )
+ except subprocess.CalledProcessError as e:
+ print(f"ffmpeg 失败({sid}): {(e.stderr or '')[-500:]}")
+ raise
+ print(f"{sid} @ {ts:.2f}s -> {dst.relative_to(root)}")
+
+
+if __name__ == "__main__":
+ main()
diff --git a/media/pipeline/scripts/tts.py b/media/pipeline/scripts/tts.py
new file mode 100644
index 00000000..d3208361
--- /dev/null
+++ b/media/pipeline/scripts/tts.py
@@ -0,0 +1,100 @@
+#!/usr/bin/env python3
+"""逐句合成配音并产出时长 manifest——公共管线版本。
+
+- 输入:<工程>/script/narration.json(单一事实源派生)
+- 输出:<工程>/video/public/audio/{id}.mp3 + <工程>/video/public/audio/manifest.json
+- 引擎:edge-tts(免密钥);每句一个文件,幂等(文本未变则跳过)。
+
+用法:uv run --no-project --with edge-tts --with mutagen media/pipeline/scripts/tts.py \
+ --project media/<工程> [--voice zh-CN-YunxiNeural] [--rate +4%] [--force]
+ 工程内薄包装等价于:uv run --no-project --with edge-tts --with mutagen scripts/tts.py
+"""
+
+from __future__ import annotations
+
+import argparse
+import asyncio
+import hashlib
+import json
+from pathlib import Path
+
+import edge_tts
+from mutagen.mp3 import MP3
+
+DEFAULT_VOICE = "zh-CN-YunxiNeural"
+DEFAULT_RATE = "+4%"
+CONCURRENCY = 6
+RETRIES = 4
+
+
+def tts_text(text: str) -> str:
+ """口播文本微调:破折号换为逗号停顿,避免 TTS 念成怪音。"""
+ return text.replace("——", ",").replace("……", "。")
+
+
+async def synth_one(
+ sem: asyncio.Semaphore,
+ item: dict,
+ force: bool,
+ voice: str,
+ rate: str,
+ out_dir: Path,
+) -> dict:
+ sid, text = item["id"], item["text"]
+ mp3 = out_dir / f"{sid}.mp3"
+ meta = out_dir / f"{sid}.sha"
+ digest = hashlib.sha1(f"{voice}|{rate}|{text}".encode()).hexdigest()
+
+ if not force and mp3.exists() and mp3.stat().st_size > 0 and meta.exists() and meta.read_text() == digest:
+ pass
+ else:
+ async with sem:
+ last_err: Exception | None = None
+ for attempt in range(RETRIES):
+ try:
+ communicate = edge_tts.Communicate(tts_text(text), voice, rate=rate)
+ await communicate.save(str(mp3))
+ if mp3.stat().st_size == 0:
+ raise RuntimeError("空音频文件")
+ meta.write_text(digest)
+ break
+ except Exception as e: # noqa: BLE001 - 网络服务需要整体重试
+ last_err = e
+ await asyncio.sleep(1.5 * (attempt + 1))
+ else:
+ raise RuntimeError(f"{sid} 合成失败: {last_err}")
+
+ duration = MP3(str(mp3)).info.length
+ return {**item, "durationSec": round(duration, 3)}
+
+
+async def main() -> None:
+ parser = argparse.ArgumentParser(description="逐句 edge-tts 合成 + 时长 manifest")
+ parser.add_argument("--project", default=".", help="视频工程根目录(含 script/ 与 video/)")
+ parser.add_argument("--voice", default=DEFAULT_VOICE, help="edge-tts 语音(默认 zh-CN-YunxiNeural)")
+ parser.add_argument("--rate", default=DEFAULT_RATE, help="语速(默认 +4%%)")
+ parser.add_argument("--force", action="store_true", help="忽略缓存强制重合成")
+ args = parser.parse_args()
+
+ root = Path(args.project).resolve()
+ src = root / "script" / "narration.json"
+ out_dir = root / "video" / "public" / "audio"
+
+ items = json.loads(src.read_text(encoding="utf-8"))
+ out_dir.mkdir(parents=True, exist_ok=True)
+ sem = asyncio.Semaphore(CONCURRENCY)
+ results = await asyncio.gather(
+ *(synth_one(sem, i, args.force, args.voice, args.rate, out_dir) for i in items)
+ )
+
+ manifest_path = out_dir / "manifest.json"
+ manifest_path.write_text(
+ json.dumps(results, ensure_ascii=False, indent=1) + "\n", encoding="utf-8"
+ )
+ total = sum(r["durationSec"] for r in results)
+ print(f"合成 {len(results)} 句,纯语音总时长 {total / 60:.2f} 分钟")
+ print(f"manifest: {manifest_path}")
+
+
+if __name__ == "__main__":
+ asyncio.run(main())
diff --git a/media/pipeline/skills/01-paper-extraction.md b/media/pipeline/skills/01-paper-extraction.md
new file mode 100644
index 00000000..3b295397
--- /dev/null
+++ b/media/pipeline/skills/01-paper-extraction.md
@@ -0,0 +1,43 @@
+# Skill 01 · 论文精读提取(并行子代理)
+
+> Stage ①:把论文全文转化为科普视频的**单一事实源** `research/paper-notes.md`。
+> 本文件是提取代理的提示词规格,可直接作为 Workflow 子代理 prompt 底稿。
+
+## 输入
+
+- 论文 PDF 绝对路径(或 HTML 版 URL)。
+- 分章清单(每章一个代理,并行执行;章的粒度以「一个代理能在一次上下文内精读完」为准,长综述通常 8–10 章)。
+
+## 每章代理任务
+
+通读所辖章节全文(不是摘要、不是跳读),按以下**四段式结构**输出:
+
+### 1. 章节主旨综述段(中文,300–600 字)
+- 本章解决什么问题、在全文中的位置、组织逻辑(作者按什么维度切分小节)。
+- 形式化定义原样摘录(符号 + 一句话白话解释)。
+
+### 2. 代表方法列表
+每个方法一条:`- **名称(作者,年份)**:机制一句话(通俗但准确)。(例:论文原文表述 "...")`
+- 覆盖正文与表格点名的方法;表格-only 方法单独分组标注。
+- 机制描述必须落在论文原文的动词与对象上,不自行引申。
+
+### 3. 风险 / 挑战 / 防护
+- 论文明确列出的失败模式、trade-off、防护措施;保留关键英文术语。
+
+### 4. 科普叙事素材(金句 / 比喻 / 例子)
+- 每条:【标签】英文原句(如有)→ 中文白话 → 画面感建议。
+- 优先收录:反直觉断言、具体数字、有名字的系统、作者自评的边界句。
+- 这一段是后续逐字稿「记忆点」的原料库,宁多勿漏。
+
+## 纪律(硬约束)
+
+1. **零编造**:每条内容须能回溯到论文原文;不确定的写「论文未展开」。
+2. 引用编号/图表号原样保留(如 Figure 4、Table 3),便于校验。
+3. 不做价值判断、不添加论文之外的观点。
+4. 英文原句须逐字精确(供字幕角标与事实核查复用)。
+
+## 汇编与验收
+
+- 主代理合并各章产出 → 头部补:来源(IEEE 引用)、作者/机构、提取方式与日期、与仓库既有调研报告的交叉引用。
+- 一致性复核:跨章术语统一(同一概念不出现两种译名)、编号连续、重复方法去重。
+- 验收:抽 10 条断言 grep 原文验证命中;无命中项打回重查。
diff --git a/media/pipeline/skills/02-planning.md b/media/pipeline/skills/02-planning.md
new file mode 100644
index 00000000..4c489b4e
--- /dev/null
+++ b/media/pipeline/skills/02-planning.md
@@ -0,0 +1,25 @@
+# Skill 02 · 策划案生成
+
+> Stage ②:基于 `research/paper-notes.md` 产出 `script/planning.md`——全片的叙事与视觉蓝图。
+
+## 产出结构(六节)
+
+1. **定位表**:平台(B 站/YouTube 中长视频)、时长目标(硬约束区间)、形态(AI 配音 + 代码动画,无真人)、受众(不预设 ML 背景的普通人)、核心内容范围(论文章节取舍)。
+2. **叙事策略**:
+ - 一个贯穿全片的拟人化/比喻体系(论文的形式化概念 → 生活意象,全片一致);
+ - 一条主线问题(钩子 → 悬念 → 回答);
+ - 记忆点节奏(约每 60–90 秒一个,全部取自 paper-notes 第 4 段素材);
+ - 理性收尾原则(不贩卖焦虑,把悬念留在开放问题上)。
+3. **视觉语言**:
+ - **色彩语义契约**:为本集核心概念分配专属色(如上集 蓝=改大脑/橙=改装备);任何示意图严格用色;
+ - 深色底 `#0E1116` 系、警示红 `#FF5C5C`、确认绿 `#7ED321`、金句卡衬线体;
+ - 公式只作画面角标彩蛋,不进口播主线。
+4. **分幕结构表**:幕号 | 目标时间 | 主题 | 叙事要点(回溯 paper-notes)| 视觉锚点。通常 6–7 幕。
+5. **生产管线图**(Mermaid):文档层→配音层→视觉层,标注单一事实源节点。
+6. **边界与不做的事**:BGM 留空轨、论文外观点不进口播、许可注意。
+
+## 纪律
+
+- 每个叙事要点须指向 paper-notes 的具体小节;
+- 取舍原则:深度 > 广度,砍掉的章节在收尾「一句话带过」而不是硬塞;
+- 时长预算:中文口播约 280 字/分钟;各幕目标时间是规划值,最终以配音实测为准。
diff --git a/media/pipeline/skills/03-narration.md b/media/pipeline/skills/03-narration.md
new file mode 100644
index 00000000..0e94bc7e
--- /dev/null
+++ b/media/pipeline/skills/03-narration.md
@@ -0,0 +1,42 @@
+# Skill 03 · 逐字稿写作
+
+> Stage ③:撰写 `script/narration.md`——全片口播的**单一事实源**。定稿后一切下游(TTS/字幕/分镜/动画)均由它派生。
+
+## 格式契约(build_narration.py 解析规则)
+
+```markdown
+# 逐字稿:<片名>(vN,已过真实性+易懂性双重校验)
+
+> **格式约定**:`- [句id] 口播文本`——每行一句,一句 = 一条字幕 = 一段配音。
+> `>` 引用块为画面备注,不进入配音。英文方法名原则上不口播,做成画面角标。
+> 事实源:[../research/paper-notes.md](../research/paper-notes.md)
+
+## P0 幕标题
+
+> 画面:……(本段画面的导演备注)
+
+- [p0-01] 第一句。
+- [p0-02] 第二句。
+
+> 角标:MethodName
+```
+
+- 幕标题 `## P`;句 id 必须 `p-` 前缀、全片唯一(含字母后缀如 `p2-37b` 允许,用于事后插句)。
+- 每句一个完整语义单元(一条字幕),长度 8–35 字为宜;TTS 微调规则:`——`→逗号停顿、`……`→句号。
+
+## 写作纪律
+
+1. **事实回溯**:每个论文断言必须能在 paper-notes 找到对应条目;论文外内容须口播标明「论文之外多说一句」。
+2. **口语化**:短句、主谓宾、单句单义;禁用「综上所述」「值得注意的是」等书面腔。
+3. **术语降落**:新概念第一次出现必须配比喻(paper-notes 第 4 段的素材库);英文专名进角标不进口播(个别已成为中文口语的除外,如 ChatGPT)。
+4. **节奏**:每 60–90 秒一个记忆点(金句/反转/数字);每幕结尾留半句悬念钩到下一幕。
+5. **数字精确**:论文数字原样(如 +16.2 个百分点、~1200 个),不四舍五入成「很多」。
+6. 收尾理性:明确当前技术的边界,不渲染末日/奇点焦虑。
+
+## 自检清单(定稿前)
+
+- [ ] 通读一遍模拟口播,无拗口句、无超 40 字长句;
+- [ ] 每幕句数与其目标时长匹配(句均 ~1.9s);
+- [ ] 每个断言 grep paper-notes 可命中;
+- [ ] 角标(英文方法名)已随句标注;
+- [ ] 估算时长(280 字/分)落在硬约束区间内。
diff --git a/media/pipeline/skills/04-verification.md b/media/pipeline/skills/04-verification.md
new file mode 100644
index 00000000..61fa3050
--- /dev/null
+++ b/media/pipeline/skills/04-verification.md
@@ -0,0 +1,30 @@
+# Skill 04 · 双重校验(真实性 + 易懂性)
+
+> Stage ④:逐字稿定稿前的质量门。可由两个独立子代理并行执行(互不污染视角)。
+
+## A. 真实性校验(Veracity)
+
+逐句扫描 narration.md,产出核查表:
+
+| 句 id | 断言摘要 | paper-notes 锚点 | 判定 |
+|---|---|---|---|
+| p2-14 | 一致≠正确,自信地错会放大 | §5.2 风险第 3 条 | ✅ |
+
+- **判定级别**:✅ VERIFIED(原文可回溯)/ ⚠️ ANALOGY(比喻性引申,比喻与事实边界清晰)/ ❌ RISKY(无锚点或与原文有出入)/ ✏️ REWRITE(表述需修正)。
+- **定稿门槛:RISKY 与未处理 REWRITE 必须为零。**
+- 比喻句必须显式归类 ANALOGY 且核对本体不与论文矛盾(例如「在梦里练车」是比喻,但「梦有偏差、上路前需验证」这一约束句必须是 VERIFIED)。
+- 数字、系统名、人名、年份逐一核对。
+
+## B. 易懂性评审(Accessibility)
+
+以「不预设 ML 背景、智商在线但外行」的观众视角通读:
+
+- 每个新术语出现处,前一句或后一句内是否有比喻/白话解释?
+- 代词指代是否清晰(「它」「这条路」——离先行词太远则重写)?
+- 是否存在连续 3 句以上无画面感抽象论述(标记加比喻或例子)?
+- 金句卡候选(英文原文金句 ≤3 处/幕,过密则贬值);
+- 听觉友好:避免连读歧义、避免中英夹杂同一句。
+
+## 输出
+
+两份报告合并为逐字稿头部版本号升级依据(如 v1 → v2,已过双重校验)。所有 REWRITE 修正直接落回 narration.md,再复跑 A 直到清零。
diff --git a/media/pipeline/skills/05-storyboard.md b/media/pipeline/skills/05-storyboard.md
new file mode 100644
index 00000000..4dd50bdd
--- /dev/null
+++ b/media/pipeline/skills/05-storyboard.md
@@ -0,0 +1,27 @@
+# Skill 05 · 分镜表生成
+
+> Stage ⑤:把逐字稿切「镜」,产出 `script/storyboard.md`——Remotion 场景组件的实现规格。
+
+## 产出结构
+
+1. 头部:与 narration.md 句 id 对齐说明;时长以音频 manifest 实测为准的声明;**本集视觉契约**(色板 hex + 语义映射,与 planning.md 一致)。
+2. 每幕一节(对应一个场景组件 `video/src/scenes/P.tsx`),内含分镜表:
+
+| 镜 | 句区间 | 画面 | 动效 |
+|---|---|---|---|
+| 2-A 章头 | p2-01..04 | 蓝色章节卡…… | 章节转场 |
+
+3. 字幕规范(底部单行、一句一条、字号、与配音同步)。
+4. 实现映射:幕 ↔ 组件名对照、公共组件清单(金句卡/章节卡/字幕条/图标集)。
+
+## 切镜规则
+
+- 一「镜」(beat)= 一段连续句 id(2–8 句)共享同一主画面;镜内动效随句推进。
+- 句 id 区间必须**覆盖该幕全部句子、无交叠无遗漏**(组件内以 `beatWindow(sentences, sceneFrom, from, to)` 取窗口)。
+- 每镜「画面」写清:主体元素、布局、色彩(用契约色名)、出现的角标;「动效」写清:入场方式、随句节奏的推进(生长/高亮/计数)。
+- 风险/反转段显式标注色调切换(如「画面转红调」)。
+
+## 验收
+
+- 逐幕核对句 id 连续性(首个 beat 起于本幕第一句,末个 beat 止于本幕最后一句,相邻 beat 区间无缝衔接);
+- 每镜画面均可在 Remotion 用现有公共组件 + 少量定制实现(不出现无法代码化的素材需求)。
diff --git a/media/self-improving-agents-video/scripts/build_narration.py b/media/self-improving-agents-video/scripts/build_narration.py
index 849a5164..8f853c6b 100644
--- a/media/self-improving-agents-video/scripts/build_narration.py
+++ b/media/self-improving-agents-video/scripts/build_narration.py
@@ -1,52 +1,22 @@
#!/usr/bin/env python3
-"""从 narration.md 解析生成 narration.json(逐句:id/scene/text)。
+"""薄包装:转发到公共管线 media/pipeline/scripts/build_narration.py。
-narration.md 是唯一事实源;本脚本是纯派生转换,不做任何内容改写。
-用法:uv run --no-project scripts/build_narration.py
+实现已收敛至仓库级单一事实源;本文件仅保留原 CLI 契约
+(uv run --no-project scripts/build_narration.py)。
"""
from __future__ import annotations
-import json
-import re
+import subprocess
+import sys
from pathlib import Path
-ROOT = Path(__file__).resolve().parent.parent
-SRC = ROOT / "script" / "narration.md"
-DST = ROOT / "script" / "narration.json"
-
-LINE_RE = re.compile(r"^- \[(?P[a-z0-9-]+)\]\s+(?P.+)$")
-SCENE_RE = re.compile(r"^## (?PP\d+)\b")
-
-
-def main() -> None:
- scene = ""
- items: list[dict[str, str]] = []
- seen: set[str] = set()
- for raw in SRC.read_text(encoding="utf-8").splitlines():
- if m := SCENE_RE.match(raw):
- scene = m.group("scene")
- continue
- if m := LINE_RE.match(raw):
- sid, text = m.group("id"), m.group("text").strip()
- if sid in seen:
- raise SystemExit(f"重复句 id: {sid}")
- if not sid.startswith(scene.lower() + "-"):
- raise SystemExit(f"句 id {sid} 与所在幕 {scene} 不一致")
- seen.add(sid)
- items.append({"id": sid, "scene": scene, "text": text})
-
- DST.write_text(
- json.dumps(items, ensure_ascii=False, indent=2) + "\n", encoding="utf-8"
- )
- total_chars = sum(len(i["text"]) for i in items)
- per_scene: dict[str, int] = {}
- for i in items:
- per_scene[i["scene"]] = per_scene.get(i["scene"], 0) + 1
- print(f"句数: {len(items)} 总字数: {total_chars}")
- print(f"各幕句数: {per_scene}")
- print(f"估算时长(280字/分): {total_chars / 280:.1f} 分钟")
-
+PIPELINE_SCRIPT = Path(__file__).resolve().parents[2] / "pipeline" / "scripts" / "build_narration.py"
if __name__ == "__main__":
- main()
+ sys.exit(
+ subprocess.run(
+ [sys.executable, str(PIPELINE_SCRIPT), "--project", str(Path(__file__).resolve().parent.parent), *sys.argv[1:]],
+ check=False,
+ ).returncode
+ )
diff --git a/media/self-improving-agents-video/scripts/qa_frames.py b/media/self-improving-agents-video/scripts/qa_frames.py
index 119b21a7..0e8a4957 100644
--- a/media/self-improving-agents-video/scripts/qa_frames.py
+++ b/media/self-improving-agents-video/scripts/qa_frames.py
@@ -1,94 +1,22 @@
#!/usr/bin/env python3
-"""按句 id 从渲染产物中抽帧,用于视觉 QA。
+"""薄包装:转发到公共管线 media/pipeline/scripts/qa_frames.py。
-时序常量须与 video/src/timing.ts 保持一致(FPS/句间停顿/幕间停顿/片头引导)。
-
-用法:uv run --no-project scripts/qa_frames.py <句id> [句id ...]
- uv run --no-project scripts/qa_frames.py --scene P1 # 该幕每镜首句
-输出:out/frames/{句id}.png
+实现已收敛至仓库级单一事实源;本文件仅保留原 CLI 契约
+(uv run --no-project scripts/qa_frames.py [--offset N] <句id|--scene P1>)。
"""
from __future__ import annotations
-import json
import subprocess
import sys
from pathlib import Path
-ROOT = Path(__file__).resolve().parent.parent
-MANIFEST = ROOT / "video" / "public" / "audio" / "manifest.json"
-OUT = ROOT / "out" / "frames"
-
-# 与 video/src/timing.ts 对齐
-FPS = 30
-SENTENCE_GAP = 0.32
-SCENE_GAP = 0.9
-LEAD_IN = 0.6
-
-
-def timeline() -> dict[str, tuple[float, float]]:
- items = json.loads(MANIFEST.read_text(encoding="utf-8"))
- result: dict[str, tuple[float, float]] = {}
- cursor_frames = round(LEAD_IN * FPS)
- for i, item in enumerate(items):
- nxt = items[i + 1] if i + 1 < len(items) else None
- gap = SENTENCE_GAP + (SCENE_GAP if nxt and nxt["scene"] != item["scene"] else 0)
- dur_frames = max(1, round((item["durationSec"] + gap) * FPS))
- result[item["id"]] = (cursor_frames / FPS, dur_frames / FPS)
- cursor_frames += dur_frames
- return result
-
-
-def main() -> None:
- video = Path(sys.argv[1]).resolve()
- tl = timeline()
- argv = sys.argv[2:]
- offset = 0.0
- if argv and argv[0] == "--offset":
- offset = float(argv[1])
- argv = argv[2:]
- ids: list[str]
- if argv and argv[0] == "--scene":
- prefix = argv[1].lower() + "-"
- ids = [k for k in tl if k.startswith(prefix)]
- ids = ids[:: max(1, len(ids) // 8)] # 每幕最多抽 ~8 帧
- else:
- ids = argv
-
- OUT.mkdir(parents=True, exist_ok=True)
- ffmpeg = ["pnpm", "exec", "remotion", "ffmpeg"]
- for sid in ids:
- if sid not in tl:
- print(f"跳过未知句 id: {sid}")
- continue
- start, dur = tl[sid]
- ts = start + dur / 2 - offset
- dst = OUT / f"{sid}.png"
- try:
- subprocess.run(
- [
- *ffmpeg,
- "-y",
- "-ss",
- f"{ts:.3f}",
- "-i",
- str(video),
- "-frames:v",
- "1",
- "-update",
- "1",
- str(dst),
- ],
- cwd=ROOT / "video",
- check=True,
- capture_output=True,
- text=True,
- )
- except subprocess.CalledProcessError as e:
- print(f"ffmpeg 失败({sid}): {(e.stderr or '')[-500:]}")
- raise
- print(f"{sid} @ {ts:.2f}s -> {dst.relative_to(ROOT)}")
-
+PIPELINE_SCRIPT = Path(__file__).resolve().parents[2] / "pipeline" / "scripts" / "qa_frames.py"
if __name__ == "__main__":
- main()
+ sys.exit(
+ subprocess.run(
+ [sys.executable, str(PIPELINE_SCRIPT), "--project", str(Path(__file__).resolve().parent.parent), *sys.argv[1:]],
+ check=False,
+ ).returncode
+ )
diff --git a/media/self-improving-agents-video/scripts/tts.py b/media/self-improving-agents-video/scripts/tts.py
index b44d3e07..2bf5b66a 100644
--- a/media/self-improving-agents-video/scripts/tts.py
+++ b/media/self-improving-agents-video/scripts/tts.py
@@ -1,83 +1,22 @@
#!/usr/bin/env python3
-"""逐句合成配音并产出时长 manifest。
+"""薄包装:转发到公共管线 media/pipeline/scripts/tts.py。
-- 输入:script/narration.json(单一事实源派生)
-- 输出:video/public/audio/{id}.mp3 + video/public/audio/manifest.json
-- 引擎:edge-tts(免密钥);每句一个文件,幂等(文本未变则跳过)。
-
-用法:uv run --no-project --with edge-tts --with mutagen scripts/tts.py [--force]
+实现已收敛至仓库级单一事实源;本文件仅保留原 CLI 契约
+(uv run --no-project --with edge-tts --with mutagen scripts/tts.py [--force])。
"""
from __future__ import annotations
-import asyncio
-import hashlib
-import json
+import subprocess
import sys
from pathlib import Path
-import edge_tts
-from mutagen.mp3 import MP3
-
-ROOT = Path(__file__).resolve().parent.parent
-SRC = ROOT / "script" / "narration.json"
-OUT_DIR = ROOT / "video" / "public" / "audio"
-
-VOICE = "zh-CN-YunxiNeural"
-RATE = "+4%"
-CONCURRENCY = 6
-RETRIES = 4
-
-
-def tts_text(text: str) -> str:
- """口播文本微调:破折号换为逗号停顿,避免 TTS 念成怪音。"""
- return text.replace("——", ",").replace("……", "。")
-
-
-async def synth_one(sem: asyncio.Semaphore, item: dict, force: bool) -> dict:
- sid, text = item["id"], item["text"]
- mp3 = OUT_DIR / f"{sid}.mp3"
- meta = OUT_DIR / f"{sid}.sha"
- digest = hashlib.sha1(f"{VOICE}|{RATE}|{text}".encode()).hexdigest()
-
- if not force and mp3.exists() and mp3.stat().st_size > 0 and meta.exists() and meta.read_text() == digest:
- pass
- else:
- async with sem:
- last_err: Exception | None = None
- for attempt in range(RETRIES):
- try:
- communicate = edge_tts.Communicate(tts_text(text), VOICE, rate=RATE)
- await communicate.save(str(mp3))
- if mp3.stat().st_size == 0:
- raise RuntimeError("空音频文件")
- meta.write_text(digest)
- break
- except Exception as e: # noqa: BLE001 - 网络服务需要整体重试
- last_err = e
- await asyncio.sleep(1.5 * (attempt + 1))
- else:
- raise RuntimeError(f"{sid} 合成失败: {last_err}")
-
- duration = MP3(str(mp3)).info.length
- return {**item, "durationSec": round(duration, 3)}
-
-
-async def main() -> None:
- force = "--force" in sys.argv
- items = json.loads(SRC.read_text(encoding="utf-8"))
- OUT_DIR.mkdir(parents=True, exist_ok=True)
- sem = asyncio.Semaphore(CONCURRENCY)
- results = await asyncio.gather(*(synth_one(sem, i, force) for i in items))
-
- manifest_path = OUT_DIR / "manifest.json"
- manifest_path.write_text(
- json.dumps(results, ensure_ascii=False, indent=1) + "\n", encoding="utf-8"
- )
- total = sum(r["durationSec"] for r in results)
- print(f"合成 {len(results)} 句,纯语音总时长 {total / 60:.2f} 分钟")
- print(f"manifest: {manifest_path}")
-
+PIPELINE_SCRIPT = Path(__file__).resolve().parents[2] / "pipeline" / "scripts" / "tts.py"
if __name__ == "__main__":
- asyncio.run(main())
+ sys.exit(
+ subprocess.run(
+ [sys.executable, str(PIPELINE_SCRIPT), "--project", str(Path(__file__).resolve().parent.parent), *sys.argv[1:]],
+ check=False,
+ ).returncode
+ )