让 Agent 把视频做出来:DeepSeek Harness × DeepSeek-V4.1-Flash 视频生产流水线
引言
用大模型做视频,大多数人想到的是"生成":文生视频、图生视频、数字人口播。这条路的问题在于,产出的每一帧都由模型"想象"出来,因此不可控、不可复现,也承载不了真实信息——产品界面上的按钮、报表里的数字、游戏里的操作,它都没法准确画出来。
另一条路几乎没人讨论,但工程上更实在:让 Agent 写一套程序,把"已经存在的事实"搬进一条时间线。画面来自真实运行的软件(浏览器的渲染帧、录屏文件),或者由代码逐帧绘制;时间轴由数据描述;最后由 ffmpeg 合成。模型在这里不像素,它做的是编排:写采集脚本、写时间轴、写合成命令、看渲染结果、改参数重跑。
本文讨论的就是这条路的完整技术栈:一个只有单一工具入口的 coding agent(DeepSeek Harness 驱动 DeepSeek-V4.1-Flash,1M 上下文、可开子代理、可起后台任务),如何从一句话需求走到一支可交付的成片;它在哪些环节必然翻车;什么样的题材适合交给它;以及一次成片的真实成本。
目录
- 引言
- 1. 为什么这条路现在成立:四个基础设施条件
- 2. 三条技术路线:画面从哪来,时间轴就长什么样
- 3. 一条通用流水线:六个阶段
- 4. 视听层:旁白、字幕、配乐
- 5. 并行:什么时候值得开子代理
- 6. 成本:钱花在哪,时间花在哪
- 7. 适合做什么视频,不适合做什么
- 8. 给想复刻这套流水线的工程师:10 条经验
- 9. 局限与风险
- 10. 结语
- 附录 A:口径与数据来源 | 附录 B:关键参数速查
1. 为什么这条路现在成立:四个基础设施条件
1.1 统一工具面:一个 run_code,把整个操作系统收进沙箱
视频制作是一条由几十个异构小动作拼成的流水线:找到浏览器、下载 ffmpeg、起静态服务、驱动鼠标键盘、抽帧、编码、量响度、烧字幕。如果每个动作都要声明成一个工具,工具面会迅速失控;如果只给一个 shell,模型又失去了用代码组织控制流和错误处理的能力。
run_code 的折中很关键:控制流用真正的编程语言写,副作用通过受控工具发生。它接收一段 TypeScript 程序,程序里可以调 pwsh、读写文件、发 HTTP、起后台任务、拉子代理。于是"录制 20 个场景"不是 20 次工具调用,而是一个 for 循环 + 一个场景注册表。
const ff = "D:\\tools\\@ffmpeg-installer\\win32-x64\\ffmpeg.exe";
await tools.pwsh({
description: "Extract QC frames from a clip",
command: "& $ff -y -ss 5.2 -i out/clips/scene-03.mp4 -frames:v 1 -q:v 2 out/qc/03-5.2.jpg",
});
await tools.read_image({ file_path: "out/qc/03-5.2.jpg" });
最后一行是整件事的分水岭:编码结果不再只是一个成功/失败的返回值,而是可以被模型自己审视的画面。
1.2 长上下文 + 高缓存命中:让"记住整条流水线"变便宜
一支十几分钟的长片,制作过程中模型要同时持有:分镜表、几十段场景脚本、TTS 时间轴、字幕轨、编码参数,以及此前每一次报错的结论。实测中单条请求的输入规模可以爬到十几万 token(合成阶段上下文堆满时的典型值)。
这件事之所以不烧钱,是因为 prompt caching:Agent 负载的输出/输入比大约 1:115——读得多、写得少,天然是缓存最擅长的形状。实际生产里缓存命中可以稳定在 90% 以上,这是"让模型反复看同一份 20 万字上下文"在经济上可行的前提。
1.3 后台任务 + 子代理:把墙钟时间叠起来
视频生产里大量时间是在"等":等编码、等录屏、等渲染。所以运行时必须能起后台任务(job/registered → job/settled),并允许把可独立验收的工作拆给子代理并行做——配乐、卡片设计、事实核查、独立验收,都是典型的可并行块。
并行不是白来的:它把"一次长上下文读入"换成"N 路并行读入相加",省的是墙钟,花的是 token(后文有量化)。
1.4 确定性:同一份脚本必须能跑出同一支片子
最容易低估的一条。视频工程里,ffmpeg 参数、字体、响度、帧率、字幕时间轴必须可复现,否则"改一个地方"就变成"重做一支片子"。所以你会看到 Agent 为确定性付出各种代价:固定随机种子(Particles(78, seed=11))、要求音乐合成脚本"两次运行 sha256 一致"、把每段视频独立编码以便"失败可单场景重录"、把中间产物(timeline.json、marks.json)落盘而不是留在内存里。
小结:Agent 做视频的可行性,不取决于某次生成的质量,而取决于能不能把视频变成一段可反复执行、可局部重跑、结果能被自己看见的程序。
1.5 运行时解剖:一次 step 到底发生了什么
把事件流摊开,一次 step 的生命周期非常清楚,而这些机制恰好都是做视频所依赖的:
| 事件 | 作用 |
|---|---|
assistant/message |
一次模型响应结束,带 usage(输入/缓存/输出)与 tool-call 列表 |
tool/ptc-dispatch |
代码沙箱内部实际派发的子工具调用(run_code 里调 pwsh、读图、子代理…) |
job/registered → job/settled |
后台任务(起服务、录制、编码),可被杀、可超时 |
llm/retry |
传输层/超时重试,策略显式写在 policyKey 里 |
image/offload |
把历史里的图片从上下文中卸载 |
三个细节值得单独说:
(1)重试策略是显式的。 运行时下发的策略长这样:
["normal",5,["EMPTY_RESPONSE","RATE_LIMIT","SERVER","TIMEOUT","TRANSPORT"],500,10000,0.1]
含义是:普通模式、最多 5 次、只针对这几类失败、退避 500→10000 ms、抖动 0.1。长任务最常撞的其实不是模型能力,而是传输层——一场两小时的作业里出现几十次 TRANSPORT 重试并不罕见。
(2)图片是"有限资源",运行时会给它设闸门。 做视频必然要反复看图,而图片占用的上下文远高于文本。超限时请求会被拒绝:
pi-ai request images exceed the configured image count or base64 payload bound;
3 more oldest occurrence(s) must be offloaded.
code: IMAGE_OFFLOAD_REQUIRED
然后运行时把最旧的图片卸载掉(image/offload 记录"卸载了哪几个事件的第几张图"),模型需要时再用 read_image 重读。这个机制解释了为什么"看图 → 改代码 → 再看图"能在长上下文里跑上百轮:图片被有策略地丢弃,而不是把上下文撑爆。它不是故障,是正常流控——别和重试混着看。
(3)图片的多寡直接决定"看得见多少"。 一次请求能带多少图是有上限的,所以抽帧自检必须"挑帧",不能"全看":一支 16 分钟的片子,全部帧数是万级,能回看的是几十张。选择看哪几帧,本身就是一个工程决策。
2. 三条技术路线:画面从哪来,时间轴就长什么样
决定流水线形态的不是模型,而是画面来源。三种来源对应三套完全不同的时间轴机制。
| 路线 | 画面来源 | 时间轴来源 | 典型用途 | 主要代价 |
|---|---|---|---|---|
| A. CDP screencast | 浏览器 Page.startScreencast 推来的 JPEG 帧 |
每帧自带的 CDP 时间戳 | 真实软件界面走查 | 帧率由画面活跃度决定(十几到三十几 fps),须重建恒定帧率 |
| B. Playwright recordVideo | 浏览器写出的 WebM | 录制期打的 mark() 墙钟时间,事后反锚 |
网页/小游戏试玩、交互演示 | 要自造"墙钟 → 视频时间"映射,依赖容器元数据 |
| C. 纯代码逐帧渲染 | Pillow / Canvas 现算的位图 | 秒数直接换算成帧号 | 无界面可录的信息型内容、数据页、片头片尾 | 排版全靠手写坐标,没有布局引擎 |
2.1 路线 A:screencast —— 用"推帧时间戳"换真实性
这条路走的是"真录":驱动一个无头浏览器,让页面自己渲染,把浏览器推出来的帧收下来。零依赖 CDP 客户端的启动参数里,塞满的都是"防抖"开关:
--headless=new --disable-gpu --no-sandbox --no-first-run
--disable-background-timer-throttling --disable-renderer-backgrounding
--disable-backgrounding-occluded-windows --hide-scrollbars --mute-audio
--force-color-profile=srgb --font-render-hinting=none
--window-size=1920,1080 --force-device-scale-factor=1
录制核心只有几十行:
await session.send('Page.startScreencast', {
format: 'jpeg', quality: 92, maxWidth: 1920, maxHeight: 1080, everyNthFrame: 1,
});
// 收到帧 → 落盘 jpg + 记下 metadata.timestamp → 回 screencastFrameAck
核心矛盾:screencast 是"内容变化才推帧",天然是可变帧率。实测一段纯数字比对的页面只有 15 fps,一段多图表的页面能到 36 fps。而交付物必须是恒定帧率。
解法是:每帧落盘时记下时间戳,把"帧 + 它该持续多久"写成 concat demuxer 清单,交给 ffmpeg 补齐成 CFR:
file 'frames/f000000.jpg'
duration 0.0666
file 'frames/f000001.jpg'
duration 0.0521
...
# 然后 -f concat -safe 0 -i list.txt -vsync cfr -r 30
首帧补上启动空档、末帧补上收尾空档——于是"页面静止时不出帧"不会让视频变短,只会让这一帧多停几秒。帧时长一般还会 clamp 到 [1/240, 20s] 之间,防止某个异常间隔把整条时间轴拉坏。
这条路还有一个容易被误读的细节:帧少不等于掉帧。画面越静,帧越少;所以"帧数/秒"是画面活跃度指标,不是性能指标。真正决定观感的是那根时间戳。
副作用也很实在:镜头没有呼吸感。页面不动就没帧,节奏只能靠"小幅放慢 + 尾帧补齐"微调;需要卡点剪辑的短片会很难看。
2.2 路线 B:recordVideo + mark 反锚 —— 把"录制期的时间"翻译成"剪辑期的帧号"
Playwright 的 recordVideo 更省事:给一个 size,浏览器把整段过程写成 WebM(实测 VP9、25 fps)。
麻烦在于:录出来是一条连续时间流,而分镜需要的是"第 12.4 秒切下一个镜头"。recordVideo 不会告诉你"用户按下按钮"那一刻在视频的第几秒。
于是有了这套机制——页面里打点,事后反锚:
- 录制脚本在每个语义节点调用
mark('brush-start'),把performance.now()与墙钟一起写进marks.json; - 录制结束(关闭 context、
await page.video().path()拿到文件)后,用已知的关闭时刻T_CLOSE与视频总时长D反算:videoTime = D - (T_CLOSE - t); - 所有剪辑点、字幕窗、转场全由
marks.json算出,不再靠肉眼估。
坑在这里:ffprobe 有时读不出这类 WebM 的 duration。于是基准 D 退化成 T_CLOSE - T0,一旦录制器有首帧延迟或尾帧丢弃,整条时间轴整体平移——这正是很多自动剪辑"字幕总差半秒"的根因。可行的修法是别用墙钟反锚:用 requestVideoFrameCallback 记录每帧的 mediaTime,把 mark 直接打在视频时间轴上;或者只录"动作片段",由合成器拼接。
这套机制之前,先有一轮"录制参数矩阵"实验。 一个常见的误解是"把 deviceScaleFactor 调高,录出来就更清晰"。实测把它拆穿了:
| 变体 | viewport | DPR | recordVideo.size |
实测输出 | 码率 |
|---|---|---|---|---|---|
| A | 360×640 | 3 | 1080×1920 | vp9 1080×1920 @25fps | 517 kb/s |
| B | 540×960 | 2 | 1080×1920 | vp9 1080×1920 @25fps | 789 kb/s |
| C | 1080×1920 | 1 | 1080×1920 | vp9 1080×1920 @25fps | 1280 kb/s |
| D | 360×640 | 3 | 360×640 | vp9 360×640 @25fps | 470 kb/s |
结论可以直接抄走:recordVideo 的输出像素只由 size 决定,与 deviceScaleFactor 无关——DPR 只影响页面内部位图(canvas)的绘制精度,不影响视频帧尺寸。四个变体除了码率(内容复杂度不同)以外规格完全一致。"调高 DPR 换清晰度"在视频录制里是无效操作。
同一轮对照还排除了两条路:
- headed vs headless:产物没有可观测差异,因此默认 headless(也免去窗口被抢占带来的抖动);
- CDP screencast:在一个移动端 canvas 页面上,1.5 秒只推出 6 帧,且帧 metadata 仍是小尺寸——淘汰。同一个模型在另一类页面上却首选 screencast:选型依据是"页面活跃度"和"目标帧率",不是"哪种技术更先进"。
还有一个更根本的限制:canvas 类应用的 backing store 常常固定在设计尺寸(例如 750×1334),1080 视口只是 CSS 拉伸,拿不到原生高分辨率的绘制。想要竖屏版式,只能用 CSS zoom 把版式撑起来,代价是媒体查询不跟着变——这个副作用如果页面里断点不多,可以侥幸不炸;断点一多,就得放弃这条路。
这不是"AI 试错",而是"AI 做受控实验":固定其余变量,只改一个自变量,产出一组可比较的产物,再用看图 / ffprobe 判定,最后把结论写回约束表。
2.3 路线 C:纯代码渲染 —— 时间轴是"拍号",不是秒表
没有界面可录的题材(项目集、数据总结、概念片),整个采集层会被删掉,画面改由程序生成:
W, H = 1920, 1080
SS = 2 # supersample factor used when baking sprites
def render_frame(rig, t):
ctx = {"fade": 0.0, "endfade": 0.0}
frame = rig.bg.copy()
ox, oy = -(t * 7.0) % 48, -(t * 4.0) % 48 # 缓慢漂移的经纬网
blit(frame, rig.grid, ox - 48, oy - 48, alpha=0.85)
rig.particles.draw_canvas(frame, t, alpha=0.85) # 固定 seed 的粒子
rig.scene_at(t).draw(frame, t, ctx) # 每个场景自己画自己
blit(frame, rig.chrome, 0, 0, alpha=0.92)
return frame
帧是 t 的纯函数(粒子种子固定),因此"任意时刻单独出图""只重渲某几秒""精确复现"全都成立。编码把原始帧直接管道喂给 ffmpeg,一次做完,不用 filter_complex:
cmd = [FFMPEG, "-y", "-f", "rawvideo", "-pix_fmt", "rgb24",
"-s", "%dx%d" % (W, H), "-r", str(FPS), "-i", "-",
"-c:v", "libx264", "-preset", "medium", "-crf", "18",
"-pix_fmt", "yuv420p", "-profile:v", "high", "-level", "4.2",
"-g", str(FPS * 2), "-movflags", "+faststart"]
这条路最漂亮的一处设计,是时间轴用拍号而不是秒:设 BPM=104,260 拍恰好 150.000 秒;画面切点写成 CUTS=[0,12,32,72,...](单位是拍),配乐也按同一张拍网格合成。于是"音画对齐"这个通常要逐帧对轴的活,变成了整数乘法——实测 9 个切点里 8 个零帧偏差。
代价同样明显:没有布局引擎。元素坐标是手写常量(592 + dy、H - 176、y0 = 392),文本一长就压盖,只能靠人眼盯帧发现。这是它最大的一次性成本来源。
2.4 选型的判据
三条路线不是"先进程度"的排序,而是三组权衡:
| 你的情况 | 选 |
|---|---|
| 要展示的是真实软件行为,且页面变化频繁 | A(screencast) |
| 要展示的是真实软件行为,但需要精确的"某时刻发生了什么" | B(recordVideo + mark) |
| 没有界面可录,或画面本就是图表/文字/数据 | C(代码渲染) |
| 需要原生高分辨率 + 精确时序 | A 或 C(B 受容器与反锚精度限制) |
一句话:画面与时间轴必须解耦。画面负责"长什么样",时间轴负责"什么时候出现";只要这两件事能分开描述,Agent 就能像改代码一样改片子。
3. 一条通用流水线:六个阶段
把这三种路线抽象一层,会发现它们共享同一套阶段划分。这套划分本身就是方法论。
[0] 环境侦察 ──→ [1] 约束化分镜 ──→ [2] 素材生产(卡片 / 片段)
│
▼
[6] 合成与自检 ←── [5] 时间轴重建 ←── [4] 交互自动化 ←── [3] 页内注入
│
└─ 录制(三条路线任选)
自检不通过 ──→ 只回退受影响的阶段(2 素材 / 4 交互 / 5 时间轴),不重做全片
阶段 0 · 环境侦察:先问"这台机器能干什么"
Agent 不假设环境。第一个动作永远是并发探针:node -v、ffmpeg -version、Test-Path node_modules、浏览器缓存目录、磁盘空间、甚至 npm registry 连通性。
这一步的产出经常直接改写后续所有方案:
- ffmpeg 可能是某个 IDE 或截图工具自带的旧构建,年份决定你能不能用
xfade、有没有libass(字幕烧录)、有没有libx264; - 系统 PATH 里可能根本没有 ffmpeg,但某个软件的安装目录里躺着一个;
- Playwright 自带的 ffmpeg 常常只带 VP9/WebM,没有 h264/aac——这就是为什么录制端只能拿到 WebM,编码必须交给另一个 ffmpeg;
- 有的构建压根没有
ffprobe,所有探测都得退化成ffmpeg -i+ 正则解析 stderr。
教训:Agent 的"能力"是环境的函数。同一个模型换一台机器,可能从"能做叠化转场"退化成"只能硬切"。环境侦察必须是流水线的第一等公民,而不是前置准备。
阶段 1 · 把创作目标翻译成可验收的约束
一句自然语言需求,必须先变成一张显式约束表,后面的每一段代码都要能对得上它。典型的一张:
| 约束 | 落地方式 |
|---|---|
| 形态 | 1920×1080 / 30fps / H.264 + AAC,中文配音 + 全程字幕,14—16 分钟 |
| 叙事结构 | 每一章先由静态卡点题,再由真实操作画面给证据 |
| 口径纪律 | 不出现第三方文档名称;数据一律称"示例数据" |
| 分镜粒度 | 30 段 = 10 张卡片 + 20 段录制,每段独立录制、独立配音 |
| 时长口径 | 画面 targetSec 为目标,配音时长以 TTS 实测为准(允许 ±10%) |
注意最后一条:它把"时长"这个最容易扯皮的量,定义成了一个有确定算法的量——先跑 TTS 拿到音频时长,再反过来决定画面多长。这是长片能自动化的关键:音频驱动时长,而不是时长驱动音频。
阶段 2 · 素材生产:卡片和片段是两种东西
素材永远是两类:
- 静态卡片(片头、章节页、片尾、数据页):PPT 光栅化、HTML + Playwright 截图、或者代码直接画。要高分辨率就用
deviceScaleFactor=2截两倍图(例如 1080×1920 的卡片截成 2160×3840),缩回来更锐利; - 动态片段(真实操作过程):screencast 或 recordVideo 产出的视频文件。
两类的验收标准完全不同:卡片看"文字有没有压边、二维码静区够不够",片段看"操作是否完整、状态是否到达、控制台有没有报错"。
卡片侧还可以做自动像素自检(例如"最小净空 ≥ 51px"这类断言),把"看起来挤"变成可测的数字。
阶段 3 · 页面内注入:让看不见的操作变成看得见的手
真实 UI 里的点击是无声的。要让视频"讲清楚发生了什么",就得在页面里注入一层视觉提示:光标、点击涟漪、聚光灯(用一个超大外阴影把周围压暗一格)、气泡标注。
#stage-root { position: fixed; inset: 0; z-index: 2147483000; pointer-events: none; }
#stage-cursor { position: absolute; width: 72px; height: 72px; border-radius: 50%;
background: radial-gradient(circle at 50% 50%, rgba(58,47,38,.34) 0%, rgba(58,47,38,0) 76%);
border: 3px solid rgba(253,243,220,.95); transition: transform .28s ease; }
关键细节是双轨同步:光标移动既驱动 CSS 动画,也用 CDP 分步派发真实鼠标事件——这样"看起来点了"和"真的点了"是同一件事,悬停态、聚焦态、按下态都会正确出现。
await session.send('Input.dispatchMouseEvent', {
type: 'mousePressed', x, y, button: 'left', clickCount: 1,
})
覆盖层用 Page.addScriptToEvaluateOnNewDocument 注入,因此 SPA 内部路由跳转后它仍然存在;hash 路由不重载页面时,脚本要主动重新注入一次。
还有一类场景需要合成事件:某些 canvas 游戏只监听 pointer 事件,或者要求 MouseEvent 与 PointerEvent 成对出现。只发其中一种,会静默失败——状态机停在原地,而控制台什么错误都没有。稳妥做法是两条都发,并同时派发 pointermove 做插值,让拖拽轨迹平滑。
阶段 4 · 交互自动化:从"点坐标"升级到"读状态机"
最容易做砸的一步。如果只会 click(960, 540),换一个分辨率就全废。
可行做法是尽量从应用自身读状态:
- 先读项目自带的 e2e 测试与采集脚本,学现成的选择器;
- 把"等待"定义成条件而非固定睡眠:等两帧 rAF + 沉降时间(
settle())、等某个 DOM 出现、等应用自己的 debug bridge 报告状态推进; - 遇到"点了没反应",去看页面实际状态(截图 + DOM 查询),而不是盲目加大等待。
极端一点的情况(完全没有 DOM、纯 canvas 绘制的小游戏),也能"看像素":截图 → 按网格采样 → 颜色量化 → 洪水填充,从画面里反推出可点击的目标。
阶段 5 · 时间轴重建:把"跑一遍"变成"可剪辑的素材"
技术含量最高的一环,也是三条路线分道扬镳的地方。共性抽象只有一条:
录制期只负责"忠实记录 + 打点",剪辑期只负责"按点重排"。
把这两件事混在一起的写法都会返工。一个典型事故:给静态 PNG 的叠加层加了 -loop 1,把一张图变成了无限帧流,overlay 链的 framesync 直接爆炸——4 次编码超时(累计十几分钟工具预算)外加一个 moov atom not found 的坏文件。修法三件事:去掉 -loop、用 enable='between(t,a,b)' 门控叠加时段、把叠加图预裁到最小包围盒。修完,同样一段 13.5 秒编完。
时间轴的落地形式,通常是这几个文件:
timeline.json # 场景 id / 起止 / 音频 / 片段 / cue 数
subtitles.ass # 由 timeline 生成的字幕轨
segments.txt # concat demuxer 清单(视频)
audios.txt # concat demuxer 清单(音频)
阶段 6 · 合成与自检:能不能交付由检查项决定,不由模型自信决定
合成几乎全是 ffmpeg 的确定性工作,但质检环节花的时间往往超过合成本身。检查项大致四层:
| 层 | 检查什么 | 手段 |
|---|---|---|
| 规格 | 分辨率/帧率/时长/编码/体积 | ffprobe 读 stream;-movflags +faststart |
| 完整性 | 黑场、静音、帧数 | blackdetect、silencedetect、帧数断言 |
| 观感 | 字幕压边、二维码可扫、淡出颜色 | 抽帧 → read_image → 模型用眼睛判断;二维码解码 |
| 内容 | 画面里的数字/状态是否真的对 | 逐帧比对、标准算例对拍、volumedetect 逐秒量化 |
第四层最值钱。有一类 bug 只有视频能暴露:抽帧核对时发现某页的"引擎值"整列恒红——为了把视频做对,先去把产品 bug 修了,然后重录那一段。这就是"视频会逼你先修产品"的来源。
第三层也不可省。合成后抽帧发现"淡出效果把画面淡到了黑色,而我的间隔帧是奶油色",两者不匹配——改淡出颜色再合成。没有"抽帧看图"这个动作,这个 bug 会一直留在成片里。
4. 视听层:旁白、字幕、配乐
视频"能看"是一半,"能听、能读"是另一半。这一层有几个反复出现的技术点。
4.1 无旁白时:配乐全本地合成
"不要旁白,可以有点背景音乐但不能抢戏"是最常见的需求。它其实简化了整条流水线:音画对齐的难点直接归零,剩下的只是"配乐够不够安静、够不够干净"。
而版权干净的最稳做法是不采样、纯合成——用代码算出一条 WAV,可复现、无素材检索、零版权风险。两条实现路径都很常见:
| 路径 | 实现 | 特点 |
|---|---|---|
| Node 零依赖 | 手写 PCM 采样 + RIFF/WAV 头(writeUInt32LE 逐字段) |
无第三方依赖,能控制到"两次运行 sha256 一致" |
| Python + numpy | 加法合成波表 + ADSR 包络 + 简易混响 + BS.1770 响度归一 | 表达力强,适合做和声/包络/侧链 |
具体的合成配方(可直接照抄的思路):
- 拨弦:多个谐波叠加 + 指数衰减包络 + 轻微揉弦;或者用 Karplus-Strong 波导,音色更像竹/琴;
- 铺底:五度/八度长音 pad,音量压到拨弦的 1/5 以下;
- 混响:Freeverb 式的 8 comb + 4 allpass,RT60 设到 2–3 秒;
- 响度:目标
-20 LUFS左右、真峰≤ -3 dBTP,避免削波与"突兀静音"; - 结构:按段落写和弦进行,用同一张拍表对齐画面切点。
有个细节很能说明"Agent 怎么听音乐":模型听不到声音,所以子代理会把频谱图放大来看,靠它揪出"振荡器相位错 → 泛音整体快了 64 倍"这种用耳朵都未必听得出的 bug。
4.2 有旁白时:TTS 驱动时长 + ASS 烧字幕
口播类视频的骨架是"音频先定、画面后跟":
python -m edge_tts --voice zh-CN-XiaoxiaoNeural --text "…" --write-media test.mp3
# 选定后:voice=zh-CN-XiaoxiaoNeural, rate=+4%
选型靠 A/B 试听:同一句话用不同 voice / rate 各生成一版,听感与时长都记下来,再定稿。
拿到音频后:
- 段长公式:
D = max(audioDur + 0.9, clipDur - 2.5)(长片),卡片段D = max(4, audioDur + 1.2); - 画面不够长就用
slow = min(1.12, D / clipDur)放慢 +tpad=stop_mode=clone补最后一帧; - 每段音频前置留白 + 补静音到 D:
adelay=450|450, apad,统一48kHz / stereo / pcm_s16le; - 所有段音频 concat 成一条完整音轨,最后与画面一次合流。
字幕没有词级时间戳,只能按字数比例切 cue:
const d = Math.max(900, Math.round((cue.length / totalChars) * durationMs));
// 末句对齐音频尾;过长则按标点拆两行(ASS 里用 \N)
样式参数直接写进 ASS 头部,烧录时不用再调:
Style: Sub,Microsoft YaHei,37,&H00FFFFFF,&H00FFFFFF,&H0012243C,&H9A000000,...
PlayResX: 1920, PlayResY: 1080, MarginV: 52
一条实战经验:字幕时刻别手工估。手工估的版本总会差 0.5–1 秒,稳妥做法是把 cue 锚定到"事件锚点"(某段画面真正开始的时刻)再按字数分配。前面说的 marks.json 就是为这个服务的。
4.3 混音与响度
成片音频基本就三条命令:
# 旁白 + 配乐:配乐压低 / 避让
[1:a]volume=-18dB,alimiter=0.85[bgm];[0:a][bgm]amix=inputs=2:duration=first[aout]
# 统一响度
[aout]loudnorm=I=-16:TP=-1.5:LRA=11[final]
# 收尾
-c:a aac -b:a 192k -ar 48000
验收靠 volumedetect / ebur128 逐段量化——"听起来差不多"不算数,得看 mean_volume 与 integrated LUFS。
5. 并行:什么时候值得开子代理
流水线里天然存在"可以并行做、且互不依赖"的块,最典型的是配乐和片头/片尾卡。
做法是主线程建共享任务卡,write scope 显式互斥,然后并行拉起 fresh 子代理:
| 任务 | write scope | 子代理 |
|---|---|---|
| 合成 120s 垫乐 | 只写 .scratch/video/audio/ |
music |
| 片头/片尾卡 + 字幕渲染器 | 只写 .scratch/video/cards/ |
cards |
互斥是写进提示词的,不是靠文件锁:
只写
.scratch/video/audio/下的文件,不要动仓库源码、不要做 git 操作、不要碰其他.scratch目录。
实测时间账:spawn 发生在流程第 5 分钟;卡片子代理第 15.5 分钟交活、配乐子代理第 28.5 分钟交活,而主线程在 82.7 分钟才收尾。两个子代理都远早于终稿,主线程中途只做两次"只读观察"(读队友产出的 PNG 审卡),全程没有阻塞等待。
再进一步,可以把验收本身也拆成独立子代理:让它跑亮度统计、切点对齐测量、二维码可扫性检查。让做片子的人不给自己打分,是这条流水线里最"工程化"的一笔。
反过来,有些工作拆不开:
- 录制与时间轴:所有镜头共享同一份时间戳/打点文件,拆开就要合并时间轴,合并成本高于收益;
- 字幕时序:它依赖"每一段画面最后长什么样",必须在录制与裁剪定型后才能定稿。
判据可以浓缩成两句:
能并行 = 产物文件互斥 + 验收标准能写成句子。
不能并行 = 共享同一个时间轴 / 后一步的输入是前一步的像素结果。
代价也要说清:并行不是免费的,它把"一次长上下文读入"换成"N 路并行读入相加",子代理合计可以吃掉整项任务 30%–50% 的 token。
6. 成本:钱花在哪,时间花在哪
6.1 一次成片的量级
先说结论再看分解。下面是几类典型产出实测到的量级(同一套流水线、同一模型、同一种"从一句话到成片"的交付方式):
| 产出类型 | 成片时长 | 总 token | 每秒成片消耗 token | 缓存命中率 |
|---|---|---|---|---|
| 长片(演示/培训,带旁白) | 约 16.5 分钟 | 约 1.10 亿 | 约 11.1 万 | 96.7% |
| 中片(竖屏试玩/宣传) | 103–150 秒 | 3100–3200 万 | 21–30 万 | 90%–96% |
| 短片(60–90 秒) | 约 84 秒 | 约 4500 万 | 约 54 万 | 91% |
按官方价目(deepseek-flash,即 DeepSeek-V4.1-Flash-1017;单位:元 / 百万 tokens)折算:
| 计费项 | 空闲时段 | 高峰时段 |
|---|---|---|
| 输入(缓存命中) | 0.02 | 0.04 |
| 输入(缓存未命中) | 1.00 | 2.00 |
| 输出 | 4.00 | 8.00 |
高峰时段 = 北京时间周一至周五 9:00–12:00、14:00–18:00(不含法定节假日);其余时间(含周末与节假日全天)为空闲时段,价格为高峰的一半。
因为每个请求都带时间戳,可以按"这条消息落在哪个时段"逐条计价,而不是取平均值。结果:
| 产出类型 | 成片时长 | 单支成本 | 折合每分钟成片 |
|---|---|---|---|
| 长片(16 分钟) | 约 16.5 分钟 | 约 ¥16.0 | ¥0.97 |
| 中片(150 秒) | 2.5 分钟 | 约 ¥4.6 | ¥1.84 |
| 中片(103 秒) | 1.7 分钟 | 约 ¥4.7 | ¥2.73 |
| 短片(84 秒) | 1.4 分钟 | 约 ¥6.2 | ¥4.43 |
同一批数据,如果全部落在空闲时段是 ¥22.0,全部落在高峰时段是 ¥43.9,按真实时间戳逐条计价是 ¥31.5——时间点本身就是 2 倍的成本变量。这也解释了一个反直觉现象:长片折合每分钟只要 ¥0.97,短片却要 ¥4.43,固定开销(环境侦察、选型实验、卡片设计)摊不薄。
6.2 三个反直觉的结构关系
第一,负载几乎全是"重读上下文"。 输出 token 只占总量的 0.86%,命中输入占 94.41%,输出/输入比约 1:115。也就是说,每次请求的规模由上下文体积决定,而不是由模型"写多少"决定。同一件事如果在无缓存状态下重读十遍历史,token 量会差一个数量级。
第二,账单大头是"未命中的输入",不是输出。 按官方单价拆开:未命中输入占 43.2%、输出占 36.3%、命中输入只占 20.5%。注意这里的关键比值——未命中输入是命中输入的 50 倍单价,输出是命中输入的 200 倍单价。所以真正该抠的是两件事:把钱优先花在提高缓存命中率上(让 1033 万未命中输入继续往 2.06 亿命中里挪),以及压缩输出(187.7 万 token 的输出已经贡献了三分之一账单)。
第三,子代理可能比主线程更贵。 16 分钟长片那一次,主回合折合约 ¥5.7,它拉起的 5 个子代理合计约 ¥10.3——并行省的是墙钟,不是钱。原因是每个子代理都带一份自己的完整上下文,且它们的请求有一部分落在高峰时段。这也是"什么时候该开子代理"必须用"文件互斥 + 可书面验收"来约束的原因:拆得不好,就是花双倍 token 买一份重复理解。
6.3 时间花在哪:返工才是大头
一次 100 分钟的交付里,真正"录制"往往只占很小一块,其余是裁剪、重录、重编码。几条典型的返工线:
(1)自动找切点,踩了四版。 场景脚本都以登录起步,于是每段开头都带 3–5 秒登录页。解决办法是写一个帧相关性检测器(64×36 灰度 + 皮尔逊相关)自动找切点,但踩了四版:快照取错帧 → 深色帧过滤误杀某段 → 命中过渡帧 → 阈值从 0.97 调到 0.995。结果:全片因裁切点变化重编码 3 次,累计约 694 秒编码时间。
(2)合成期事故,占了 80% 的回合。 一次 82.7 分钟的交付里,约 66.5 分钟花在合成/自检/返工上——字幕时序两跳重排、-loop 1 导致的 4 次超时、坏文件回退。真正"录制"的时间反而不多。
(3)排版返工,全片重渲 4 版。 走程序化渲染路线的交付,4 版里 3 版是排版/质检问题触发的(数字错位、留白不均、文字压码、二维码静区不足),单帧渲染 610–870 ms,全片从 4.3 分钟涨到 6.3 分钟。
一句话:成本不在"渲染",而在"判断哪里不对,然后重来一遍"。降低成本的杠杆是把判断自动化(相关性检测、亮度统计、二维码解码、响度测量),而不是把模型换得更快。
6.4 优化清单
按性价比排序:
- 先修确定性,再谈优化。 中间产物落盘、每段独立编码、随机种子固定——返工成本会掉一个数量级,而返工是最大的隐性开销。
- 把验收做成断言。 响度、帧数、静音、二维码解码、切点偏差,全部脚本化。断言过的部分,模型不用再"看",也就省掉了反复读图的上下文。
- 盯住缓存命中率。 未命中输入是命中输入的 50 倍单价,账单里它是最大一项。稳定的系统提示、稳定的中间产物路径、把长上下文放在会话前部而不是反复改写,都能提高命中率。
- 控制输出。 输出单价是命中输入的 200 倍。让模型写"参数化脚本"而不是"逐次描述",输出量能压下来一半,账单能省掉一截。
- 注意时段。 同一批工作量,落在高峰时段是空闲时段的 2 倍价格。批量渲染、长时间重编码这类重活,可以排到 12:00–14:00 或 18:00 之后。
- 并行只用于互斥块。 配乐、卡片、事实核查、验收可以并行;录制与时间轴不要拆。
- 抽帧策略要省。 一次请求能带回的图片有限,优先抽"转场帧 + 关键动作 + 片尾",别平均分配。
7. 适合做什么视频,不适合做什么
7.1 结构性适合的四类
| 题材 | 为什么成立 |
|---|---|
| 产品/系统演示片(UI 走查、功能验收、售前演示) | 证据都在屏幕上;路由与选择器可驱动;每个操作点都能用注入层点出来 |
| 网页/小游戏试玩片(竖屏为主) | 交互语义明确;可读数据文件与 debug bridge 推进状态;无旁白时音画对齐难点归零 |
| 数据/项目集/年度总结类信息片 | 画面 = 几何 + 文字 + 图表,天然可程序化;版式重复度高、可模板化 |
| 带口播讲解的教学/培训片 | TTS 时长驱动片段时长,字幕按字数比例切句、ASS 烧录,全链路确定性可重跑 |
7.2 结构性不适合的
| 题材 | 卡在哪 |
|---|---|
| 真人出镜 / 实拍素材 | 流水线里没有采集卡、没有相机、没有人脸处理;这条路上没有任何一次尝试 |
| 强叙事、需要表演的镜头语言 | 表现手段只有"卡片 + Ken Burns + 光标/涟漪/聚光灯/气泡";没有景别切换、没有多机位、没有运镜设计 |
| 需要精确口型的数字人 | TTS 只产出音轨,不做口型;这类需求应当换成数字人方案 |
| 复杂 3D / 物理仿真 / 特效合成 | 代码渲染路线画的是"装饰性的球体",不是 3D 场景;没有 GPU 渲染管线 |
| 帧级时序敏感的内容(卡点踩鼓、逐帧对白) | 路线 A 的帧率由内容驱动、路线 B 的基准会整体平移;现有容差是"秒级"(0.5–1 s) |
7.3 一条更锋利的判据
如果只能记一句话:
画面能不能被"几何 + 文字 + 一次真实操作过程"描述完?能,就适合;不能,就别指望。
再加两条工程判据:
- 证据在屏幕上吗? 在屏幕上(数字、状态、图表)→ 录制路线最优;只在心里(叙事、情绪)→ 这条路走不通。
- 排版精度要求多高? 元素少、版式重复 → 可用;元素多、文本长度动态、要求像素级对齐 → 返工成本会指数上升(根因是没有布局引擎)。
8. 给想复刻这套流水线的工程师:10 条经验
- 先探环境,再定方案。 ffmpeg 的构建年份决定你能不能用
xfade、能不能烧 ASS 字幕、有没有libx264。 - 把"画面"和"时间轴"在数据结构上分开。 前者是素材(图片/视频/渲染函数),后者是一张表(场景 id、起止、音频、cue)。
- 时长由音频驱动。 有旁白就"TTS 先跑、画面后定";无旁白就"拍号/网格先定、两边都从同一个整数算"。
- 每段独立编码,失败可单场景重录。 别做一条 16 分钟的大命令,那会让任何一次返工都变成全片重来。
- 静态图叠加永远不要
-loop 1。 用enable='between(t,a,b)'门控,并把叠加图预裁到最小包围盒。 - 抽帧自检要成为固定动作,不是应急手段。 每次合成后抽 5–6 帧(开头、转场、关键动作、片尾),读回来看。这一步抓到的 bug 密度最高。
- 把验收标准写成可执行断言。 "二维码能扫出目标 URL""响度在 −20…−23 LUFS""无 ≥1 s 静音""帧数对得上"——比"看起来不错"有用一百倍。
- 注入层用
pointer-events:none+ 最高 z-index,并与真实输入事件双轨同步;canvas 应用要合成 MouseEvent + PointerEvent 两条通道。 - 并行的判据是文件互斥 + 可书面验收。 满足就拆,不满足就别拆;子代理会花掉 30%–50% 的 token。
- 给剪辑点留复核位。 相关性检测器可以自动找切点,但阈值一定需要被数据校准。
9. 局限与风险
诚实地列出这条路线暴露出来的问题:
- 时序精度是"秒级",不是"帧级"。 墙钟反锚会整体平移;screencast 帧率浮动;TTS 时长与画面裁剪互相让步。想做逐帧卡点的片子,需要换更硬的时序机制(把 mark 打在视频时间轴上,或用固定帧号渲染)。
- 排版没有布局引擎。 程序化渲染路线的坐标是手写常量,文本一长就压盖,只能靠人眼盯帧发现——这是它最大的一次性成本来源。
- 对外部数据源依赖很脆。 抓取类任务会撞 403 限流、SSRF 防护(域名解析到非公网地址直接被拒)、以及"页面结构已变"导致的正则全返回 0;最后往往要靠页面内嵌 JSON 才能拿到真值。
- 工具链版本决定能力上限。 没有
xfade就只能硬切;没有sidechaincompress就做不了旁白避让;没有libass就烧不了字幕。这不是模型的问题,是机器的问题。 - 验收依赖"看图",而看图有盲区。 模型能从帧里看出"字压住了二维码",但听不出"这段配乐好不好听"——音频质量最终要靠频谱图、响度表、以及"放大频谱抓 bug"这种间接手段兜底。
- 失败会很好看地发生。
-loop 1那次事故的产物是一个"3.6 秒的坏文件",如果不做时长/moov检查,它会被当成成功交付。
10. 结语
如果只看结果,会以为这是"AI 生成视频"。拆开工程才知道,它真正做的是把视频工程变成可编程、可观测、可迭代的软件工程:
- 用一个统一工具面把几十个异构工具收进一段程序;
- 用 CDP screencast / Playwright recordVideo / 代码逐帧渲染三条路线解决"画面从哪来";
- 用时间戳重建、打点反锚、拍号表三种机制解决"时间轴从哪来";
- 用抽帧看图、二维码解码、响度测量、相关性检测解决"怎么知道做对了";
- 用文件互斥 + 可书面验收的任务卡决定"什么能并行"。
它的成本结构也很诚实:token 的大头是重读上下文,钱的大头花在返工;而返工的根源,一半是环境的不确定性,一半是"排版与审美的判断还没被自动化"。
所以更准确的说法是:这套组合现在能稳定交付的,是"信息型视频"——把屏幕上已经存在的事实,用可复现的工程手段搬进一条时间线,并自己检查它有没有搬对。 至于"讲故事",那还是人的活。
附录 A:口径与数据来源
Token 口径:逐条累加每次模型响应的 usage,分 inputTokens(未命中输入)、cacheReadTokens(命中输入)、outputTokens(输出)三项;三者之和应等于该次请求的 totalTokens(本文引用的数据全部通过这条一致性校验)。
耗时口径:从首次 turn/start 到末次 turn/end 的墙钟差,含空档;讨论"实际干了多久"时用 turn 并集(去掉被中止的 turn)。
成本口径:采用 DeepSeek 开放平台官方价目表(api-docs.deepseek.com/zh-cn/quick_start/pricing,查询于 2026-10-22),模型取表中 deepseek-flash(对应 DeepSeek-V4.1-Flash-1017)。单价为元 / 百万 tokens:
| 计费项 | 空闲时段 | 高峰时段 |
|---|---|---|
| 输入(缓存命中) | 0.02 | 0.04 |
| 输入(缓存未命中) | 1.00 | 2.00 |
| 输出 | 4.00 | 8.00 |
高峰时段按官方定义:北京时间周一至周五(不含中国法定节假日)9:00–12:00、14:00–18:00;其余时段为空闲,价格为高峰的一半。计费时逐条消息按其自身时间戳判定时段,因此得到的数字既不是"全按空闲",也不是"全按高峰"——同一批数据若全落空闲为 ¥22.0、全落高峰为 ¥43.9,逐条计价为 ¥31.5。
注:官方同时公布了并发限制(表中该档为 2,500)与网页搜索/抓取(Search / Fetch 各 5 元每 1,000 次成功调用)的价格;本文的成本只涉及 token,不含联网检索费用。
成片参数:由 ffprobe(或等价的 MP4 box 解析:mvhd/mdhd/stsd/stts)读取,并与交付说明交叉核对;体积/码率沿用实测值,MiB 与十进制 MB 之间可能相差几个百分点。
未计入项:标题生成、翻译插件等旁路模型调用不走主 usage 记账,未计入,对总量影响在千分之几量级。
关于"看图次数":不要用 image/offload 事件数去估算"模型看了多少图"——大量读图发生在 run_code 内部的 read_image 调用里,不产生独立事件;要看实际读了哪些文件,得去翻工具调用的参数。
附录 B:关键参数速查
| 项 | 路线 A(screencast) | 路线 B(recordVideo) | 路线 C(代码渲染) |
|---|---|---|---|
| 典型分辨率 | 1920×1080 | 1080×1920(竖屏,由 size 决定) |
1920×1080(设计空间) |
| 帧率 | 录制 15–36 fps → 重建为 CFR 30 | 录制 25 fps → 交付 30 fps(重采样) | 30 fps 直接出帧 |
| 编码 | libx264 crf17–18, veryfast→medium, yuv420p, +faststart |
libx264 crf17–18 + aac 192k |
rawvideo 管道 → libx264 -preset medium -crf 18 -profile high -level 4.2 |
| 音轨 | edge-tts 旁白 + ASS 字幕 | 本地合成 BGM | 本地合成 BGM(拍网格对齐) |
| 转场 | fade + concat |
fade + 硬切(旧版 ffmpeg 无 xfade) |
场景内 sweep / 遮罩过渡 |
| 时间轴 | 帧时间戳 → concat demuxer → -vsync cfr |
marks.json → D - (T_CLOSE - t) 反锚 |
拍号表 CUTS(BPM 驱动) |
| 自检 | 抽帧 + 像素自检 + 黑场/静音 + 响度 | 抽帧 + 二维码解码 + 帧数断言 | 亮度统计 + 切点偏差 + 二维码静区 |
文中的参数、命令与数值均为工程实测摘录;未验证的能力一律不做推断。价格取自 DeepSeek 官方价目页(2026-10-22),按逐条消息的时间戳区分高峰/空闲时段计价。

浙公网安备 33010602011771号