用代码生成视频,最容易被误解成"让模型生成视频"。这是两件不同的事。
扩散模型那一类,输入提示词、输出像素,每次结果不同,你对中间过程的控制力有限;程序化渲染这一条路,画面里每一个像素都由一段代码算出来,同一帧号永远得到同一张图。代价是你得自己写构图、渐变、排版和缓动,收益是------改了就一定变,没变就一定有原因。
这篇记的是我按这条路做一部 3 分 54 秒中文科普片时踩到的东西。回头看,翻车点其实固定在四处:
- 音画不同步:按字数估时长,讲到十几句就开始漂(第一节)
- 成片像草稿:文字发虚、响度偏低、字幕越出安全区(第二节、第四节、第五节)
- 声音不认识画面:该响的地方没响,只能事后手工对时间(第六节)
- 门禁看着干净:检查写得很像样,其实一条都没在查(第七节)
下面按这四件事展开,最后是这次真实踩到的坑和几条边界。
一、音画同步,本质是"时间从哪来"
最常见的做法是按字数估时长:一句话 30 个字,按每秒 5 字算 6 秒。小片段里看不出问题,但误差是累积的------真正读出来,标点、数字、英文缩写、语气词都会让语速上下浮,短句偏快、长句偏慢,到第十几二十句,字幕和声音就能差出好几秒。
我在成片里做了一屏对照:把真实的逐句时长和"按字数等比分配"画成两条时间轴,实测在第 15 句时两者已经差 4.9 秒。这不是修辞,是拿这次的实测时间轴算的。
正确的顺序只有一个:音频先做,画面后做。
- 逐句合成旁白,落成一个个音频文件;
- 用解码器量每一句的真实 PCM 长度(不读音频头里的时长字段,那个字段在裁剪、拼接后经常不准);
- 用实测时长反推每段起止:
dur = max(min_dur, lead + voice + tail + hold); - 字幕、画面、配乐、音效全部只读这份时间轴,没有任何一处再按字数估算。
两个容易忽略的细节:
旁白缓存要校验文本,不能只校验文件存在。 只判断"这个音频在不在",就会出现改完台词、配音还是旧的,而画面按新台词长度排了轴------片子照样能出,只是内容对不上,非常隐蔽。缓存 key 应该是 文本 + 音色 + 语速 + 音高 的哈希,全等才复用。
字幕窗口以语音边界为窗,别用固定秒数。 曾经把字幕结束时间硬钳在"开始后 5.2 秒",长句读到一半字幕就消失。正确做法是默认覆盖整句、需要短字幕时再显式给上限。
一句话总结:时间轴不是排出来的,是量出来的。
二、每帧必须是纯函数
场景层的契约长这样:
python
def draw_frame(scene, t_local, dur, t_global, script, frame) -> PIL.Image
约束是硬的:禁止 random、禁止 time() 。确实需要随机(粒子分布、抖动、纹理),用整数哈希生成的确定性函数:hash2(i, j)、hash3(i, j, k)、一维值噪声 vnoise(x, seed)。同一帧号不管谁在什么时候渲染,都必须画出同一张图。
为什么值得为这个约束付出写作成本?
- 断点续跑。分块按"绝对起始帧"命名而非"第几块",中断后只补缺块,拼接前显式检查缺口。帧是纯函数,重渲一块和原来必然一致。
- 可以逐帧 diff。改了场景代码,只渲染受影响区间,和上一版逐帧比像素,而不是靠人眼扫全片。
- 回归测试成立。自检里二十多条断言,每条对应一个"曾经真的坏过"的点,它们能成立的前提就是同样的输入得到同样的输出。
两个性能经验:这类画面最贵的往往不是画几何,而是中文排版------逐字测宽再折行,一帧吃掉 12 ms;把"文本 → 行数组 + 宽度"缓存起来,同帧降到 4.6 ms。静态背景(渐变 + 网点 + 暗角)预烘焙一次,每帧只 copy。
还有一个容易忽略的硬约束:正文与字幕必须有安全区。1080p 上我把正文限制在 y ≤ 856,字幕带固定在 y ≥ 872、永不超过 1030,这样字幕永远压不到正文,也不会贴边被裁。这些数字写进常量,由质检自动核对,而不是靠每次目测。
三、两条渲染路线,选哪个
画面可以有两条路:用 Pillow 逐帧绘制,或者交给无头浏览器画 Canvas。
PIL 路线依赖少(numpy + Pillow 就够)、启动快、天然确定(纯函数画图),适合图文、数据、图表、UI 风;单帧成本压到几毫秒后,1080p30 能跑到几十帧每秒。浏览器路线能拿到 Canvas2D/WebGL,粒子、形变、着色器质感都是它擅长,代价是每帧要截图,速度会掉一个数量级。
浏览器路线有三条必须遵守的规则,否则确定性立刻崩:
- 所有补间放在一条暂停的 主时间轴上,渲染第 f 帧 = seek 到
f / FPS秒,不许依赖播放; - 循环、粒子、波浪这类程序化内容用"按时间直算"的更新器,不许用定时器累加;
- 不读上一帧的状态,不读系统时间,字体与像素比固定。
这三条里最容易被破坏的是第三条------只要有人在绘制里读了 Date.now() 或者页面滚动位置,同一帧号就会画出两个结果。
而"确定性"本身也分等级:同一会话内一致是最低要求;跨会话默认还会有不超过 4 像素、±1 级的抗锯齿差异,要字节级复现得额外开确定性模式(关 GPU 光栅),代价大约 +22%/帧。这个词一定要带条件说,否则迟早被自己的截图打脸。
四、编码:不要在中间多加一层有损
帧画出来之后,很多人会习惯性写一个 .mp4 中间文件再合成。这一步有两个问题:多一层有损压缩(文字边缘先糊一遍),速度还白白慢一倍。
直接的做法是把帧写进 ffmpeg 的 stdin:
python
cmd = [ffmpeg, "-f", "rawvideo", "-pix_fmt", "rgb24",
"-s", "1920x1080", "-r", "30", "-i", "-",
"-c:v", "libx264", "-preset", "medium", "-crf", "18",
"-pix_fmt", "yuv420p"]
合成阶段视频流直接 -c:v copy,不重编码;音频另走两遍响度标准化(目标 -16 LUFS,真峰留 1.4 dB 余量再交给 AAC)。
"到底糊没糊"可以量化:编码锐度比,用场景函数重新渲染同一时刻的原图,和成片解码帧比拉普拉斯方差。
这里的经典陷阱是:如果参考帧从成片自己里抽,分子分母几乎相同,比值恒等于 1.0------门禁永远通过,也永远测不到东西。参考帧必须来自场景函数,不是成片。
五、混音:一条 ffmpeg 命令喂上百个输入的坑
早期我用过"一条 ffmpeg 命令,把旁白、配乐、音效当成上百个输入丢进去混"。在 Windows 上它有个很恶心的问题:同一命令跑三次,一次正常、一次从第 15 秒开始整段静音、一次又正常。不是参数错,是内部线程池竞争导致的非确定性空流。
换成 numpy 总线,按采样点叠加:
python
bus[i:i + n] += src * gain # 旁白居中、配乐立体声、音效按提示点落位
这里有个顺序陷阱:必须先判断 dtype,再决定是否转 float32。写反了,归一化分支永远不执行,叠加后直接 +92 dBFS 爆表。做法是先打印中间峰值,超过 0.97 就报警。
混音链本身不复杂:
text
旁白(单声道, 居中) ─┐
配乐(立体声) ───────┼─> 音效包络让位(-3dB) ─> 求和 ─> 峰值保护 ─> loudnorm 两遍 ─> -16 LUFS / TP<=-1
音效(立体声) ───────┘
几点取舍:不做母带压缩,能量差靠配乐分段与"吸气"(重击前把配乐压低、0.2 秒内恢复)制造,而不是压缩器;响度标准化交给 ffmpeg 两遍 loudnorm,别自己在 numpy 里手搓归一化------真峰才是门禁,而 loudnorm 比手写准得多。
六、声音要知道画面上发生了什么
这是我认为最值得抄走的一个设计。
早期做法是"手工在 12.5 秒放一声嗖":能听,但只要改了节奏、加一句台词、把某段停留拉长,所有音效位置全废,得回来一条条对时间。
改成提示点:画面代码在"该响一下"的地方登记一个点,音频阶段按登记表逐条合成。
json
{"at": "l03+0.05", "type": "impact", "size": 1.1} // 相对某段起点 0.05 秒
{"at": "abs:12.5", "type": "whoosh", "dur": 0.9} // 绝对秒
{"at": 12.5, "type": "ui_click"} // 数字同义
三种登记方式各有用途:写在剧本 JSON 里方便改;写在场景模块的钩子里能让"画面动到哪、声音就跟到哪";浏览器渲染时由页面自己导出。画面一改,提示点跟着改,音效自动贴住。
它还解决了一个质检难题:音画同步怎么验 。如果只有音频时刻、没有"画面在该时刻发生了什么"的锚点,同步检查就无从下手。给每条提示点加一个 visual_at(画面上那件事真正发生的绝对秒),质检就能直接算差值。
但要说诚实:这种写法里的"零超差"是构造性的------锚点和画面动效用同一个公式算出来,差值为 0 是必然的。它证明的是"检查确实执行了",而不是"独立测量出同步很好"。真正的独立测量得靠浏览器页面导出的视觉事件列表,按事件类型和音效配方名配对。这两者在报告里必须分开写。
七、质检:最危险的状态是"看起来通过"
这是整件事里最花时间的部分。质检分四层,一层比一层更接近"观众真的看到了什么":
| 层 | 查什么 | 抓得住的问题 |
|---|---|---|
| 容器层 | 分辨率、帧率、音轨、时长 | 参数写错、音轨丢了 |
| 信号层 | 响度、真峰、锐度比、黑帧、字幕条数 | 像草稿、文字发虚、字幕静默丢失 |
| 时间层 | 单帧闪烁、画面空窗、画面静止、音画同步 | 单看任何一帧都正常,只有看完全片才发现的问题 |
| 页面层 | 文字重叠、出画、一闪而过、从没露面 | 页面里被别的元素压住的字 |
时间层最值得说,因为它需要的判据比想象中精细。最早我用"相邻帧平均亮度差"找闪烁,结果把正常的加速运动全报成问题------逐字入场、缓动推近,都是二阶差分的大户。后来加了三道闸:
- 幅值 :二阶差分
|F[t] - (F[t-1]+F[t+1])/2|分块后超阈值; - 孤立性:当前帧异常度 ≥ 1.5 × 前后帧异常度(真正的单帧事件,前后帧只各看到一半);
- 回到原位 :
|F[t+1] - F[t-1]|要小于 1.6 × 中间帧偏离量------加速运动的前后两帧差得更多,会被这一条排除。
第三条是关键。没有它,你会把正常运动当 bug,改到死。但门禁可以保守,不能静默:把"高分但被判成运动"的候选转成 warning 让人复核,而不是直接放过。
这一节的真正教训是:门禁失效比没有门禁更危险 。我遇到过三种"看起来通过":锐度比的参考帧用了成片抽帧(恒 1.0)、真峰检查没开 peak=true(解析不到就永远不触发)、音画同步在没有视觉事件的渲染路线上恒为空数组(一条都没查,却显示"零超差")。
自查的方法很朴素:如果一条门禁从来没失败过,先确认它是不是根本测不到。 故意改错一个值,看它会不会红。
八、这次真实踩到的坑
上面是方法,下面五个是这部片子里的真实故障。每一条都靠改代码解决,没有一条是靠调阈值放过的:
1. 打字机效果被判成"单帧闪烁"(4 处)。 终端场景逐字打出命令,每出现一个新字符分块像素就跳一次,恰好满足"前后两帧相近、中间帧偏差大"的判据。修法不是调阈值,而是把字符改成按进度淡入:已打出的字符全亮,下一个字符按比例半透明。同理,数字滚动原来是一秒几十次的整数跳变,改成"旧数淡出 + 新数淡入"的进位交叉;方块光标原来每 10 帧硬开关一次,改成细竖线的亮度渐变。
2. 画面长时间静止告警(6 段)。 判据是"最活跃的 8×8 块在 0.5 秒内漂移 ≤ 1.5 级灰度,连续 6 秒"。逐条对下来告警是真的------卡片落位之后画面确实一动不动。加了一条全场柔光按固定速度扫过(峰值仅 7.5%),六段全部归零。这比塞无关动效便宜,也比改判据诚实。
3. 改了上屏文案,画面没变。 最隐蔽的一条。时间轴文件和旁白清单里存的是剧本快照 ,改 JSON 之后只重跑排版阶段不够------排版读的是旁白阶段快照,而快照来自更早那一步。正确顺序是 tts → layout → 渲染,看似"只改了备注",实际要重跑三步。做了分块渲染还要注意:断点续跑的指纹校验会因为剧本哈希变了而拒绝拼接旧分块。这是刻意设计,不是 bug。
4. 台词和图表自相矛盾。 旁白写的是"讲到第二十句就开始漂",但那一屏的图表是拿真实时间轴算的,标的是"第 15 句差 4.9 秒"。观众会先看到 15 再听到 20。最后改的是旁白------图表是数据,不能为了迁就文案去改。
5. 一个我选择不修的告警。 数字计数那屏在起始 0.5 秒内有几帧高分异常,被判成"运动"而非闪烁放行。核对下来它确实是运动:四个数字同时滚动,前后帧差得比中间帧更多,属于正确判定。这类候选会留在 warning 里提醒人工复核,而不是被静默吞掉。
九、流程长什么样
整个生产拆成多个阶段,每阶段只做一件事、产物落盘、可单独重跑:
text
tts → layout → cues → score → sfx → audio → frames → mux → qa
(+ stills 母版预览,需单独调用)
实际用法三步:
- 改剧本(每句拆"朗读文本"和"上屏字幕",这两个必须分开------朗读要口语自然,字幕要短);
- 改场景代码(每帧怎么画);
- 先跑母版静帧,看图定了再跑全片。
母版静帧值得单独强调。全片七千多帧渲完才发现字号丑、配色脏,是这类工作最典型的浪费。先出三四张关键帧,配一张联系表看一眼再定稿。
长片分块并行加断点续跑:按绝对帧号切块,中断只补缺块。要在 CI 里跑,质检失败必须返回非 0------否则"质检通过"只是个日志字符串。
十、几条诚实的边界
写到这儿,把不能吹的部分也列出来:
- 确定性有前提。纯函数绘制天然确定;浏览器路线默认只在同一会话内一致,跨会话有 ≤4 像素、±1 级差异,要字节级复现得开确定性模式(约 +22%/帧)。
- "零超差"可能只是"没在查"。默认配置下,音画同步检查可能因为缺少视觉事件而根本没执行,报告里只留一条 warning。要么给提示点写锚点,要么用环境变量把"没查"升级成失败。
- 文档写了不等于代码生效。核对时发现,时间轴构建函数接受"全局停留"和"小节对齐"参数,但主流程调用时没把这两个参数传下去,实际只有"逐行停留"生效。字段存在、文档教了,它却是个死字段------只能靠读代码发现。
- 所有速度数字都是特定机器的。PIL 路线 40--80 fps(1080p30)、浏览器路线 80--130 ms/帧,换机器、换负载都要重测。别把区间当保证。
- 音效别堆"叮咚"。有音高的小提示音一多,整片就变成八音盒。留几个关键的,其余换成不带音高的咔嗒。
结语
程序化视频这套做法的核心不是炫技,而是把视频放回软件工程的可控范围:时间由实测驱动、帧是纯函数、混音按采样点、质检分层且必须真的会失败。
代价是你得写代码画图;收益是每次修改都可复现、每次交付都可验收。这两件事,恰恰是"生成式"路线目前最缺的。
项目地址:https://github.com/znlgis/dsh-video-studio-skill
基于本DSH插件生成的视频地址:https://www.bilibili.com/video/BV1mL4s6pEDj/