用代码生产视频
将视频视为 一个纯函数 render(t) 加上一个可分阶段、可断言的管线。此类任务中几乎所有的失败模式都是无声地传播的单位/符号/通道/度量错误——因此下面的工作流旨在让每一种错误都变得可观测。
0. 在绘制任何内容之前冻结契约
- 单一时间源。 一切都在
render(t)内部;帧与时间的映射(t = frame/fps)是整个公共 API。没有 CSS 动画、没有定时器、没有帧到帧的状态。结果是:帧可并行化、可恢复、可比对,并且错误可定位到一个时间范围。 - 确定性。 使用种子 PRNG(
hash(i)/mulberry32),绝不使用Math.random;绝不使用Date.now;将字体和纹理作为数据 URI 嵌入,这样没有网络/字体回退能在运行中改变像素。 - 将规范编写为断言,而不是散文。 时长、帧率、分辨率、编解码器、像素格式、响度、真峰值、安全区域/边距、禁用技术、文件路径、CLI 标志、退出码、JSON 输出结构。如果简报中有一个数字,它必须最终出现在一项检查中。
- 提前决定接缝策略——一次性 / 循环 / 拼接片段。对于循环:每个振荡器是
sin(2π·k·t/D),其中k为整数;画面收敛回自己的第一个状态(而不是“看起来相似”);事件表以及屏幕上的计数器/标签在最终过渡中重置;验证render(0)与render(D)的渲染结果。 - 每帧预算。 在构建内容之前测量
render(t)(一个 1080p 画布绘制需要 2–30 毫秒;3 个子帧 × 5400 帧会成倍增加)。记录预算并保持它。 - 冻结接口,然后哈希化它。 画布大小、页面/API 表面、工作线程 CLI、输出路径。所有质量控制都绑定到该修订版本的哈希——针对过期哈希进行的质量控制渲染是无用的。
- 记录有意偏差。 现实与简报不符的每一处(干净线条上的网格抖动、小于声明最小值的微字体、超出声明范围的弹簧率)都进入一个带有理由的简短列表,以便审阅者可以判断而非发现。
1. 第一天的三个探测
| 探测 | 断言 | 原因 |
|---|---|---|
| 捕获完整性 | 精确像素,alpha == 255,颜色范围,首/末帧存在 | 半透明累积、范围/色彩空间错误会无声地毁掉整个视频 |
| 成本 | render(t) 每场景毫秒分布,内存 | 架构变更现在便宜,以后昂贵 |
| 运动 | 整个时长内每帧差异序列 + 最长冻结段 | 能立即显示死时间段 |
来自真实失败的经验法则:
- 白色合成陷阱。 通过将 alpha 合成到白色上的查看器来审阅帧会隐藏半透明度;编码器则合成到黑色上。让审阅路径的合成方式与交付路径完全相同,并比较两个路径间的像素差异。
- 了解度量能看见什么。 经过 12 倍下采样后,细线的亚像素运动会改变平均绝对差异约 0.03/255,而整帧亮度变化会使其移动约 1.5/255。如果接受度量是下采样后的均值,那么最便宜且真正有效的杠杆是全局值通道(例如,在节拍率上叠加一个 2 Hz ±3% 的亮度脉冲),而不是更大的抖动。
- 在验证资产之前先验证度量。 当检查失败时,首先转储原始信号(1 毫秒的包络、像素行轮廓、帧序列)。一半的“资产错误”情况实际上是检测器的问题。
- 让检测器自我描述。 建模并补偿系统性偏差(滤波器群延迟、因果分析窗口延迟);优先选择最接近事件的候选者而不是最响亮的一个;永远不要调整阈值直到它通过。
- 精确声明与近似声明。 “render(0) ≡ render(D),PSNR ∞”是一个证明;“第一个与最后一个编码帧相差 2/255(一帧的运动)”是一个观察结果。绝不要在报告中模糊两者。
捆绑:scripts/av_audit.mjs 实现了这部分中容器/运动/循环/响度/接触页的一半功能(仅依赖 ffmpeg,无 npm 依赖)。
2. 审阅循环是一项特性,而不是开销
- 网格化:数百张静止帧→十几张接触页,一次审查通过。
- 嵌入引导:安全区域框、中心十字准线、时间码、事件 ID。审阅者不应在脑中记住布局。
- 按事件采样,而非固定间隔:每个预期事件上取一帧,每个移动过程中间取一帧。
- 最仔细地查看接缝:场景过渡、循环包裹、片段边界、首帧和末帧。
- 隔离并放大可疑元素到空白画布上,放大 4–6 倍;大多数错误在全帧中不可见。
- 工件是可丢弃的:一条命令即可重新生成;永远不要手动修补审阅帧。
- 保留问题列表(杂乱/不协调/重叠/超出安全区域/不可读/冻结),并在每次修复循环后重新审查。
3. 运动与循环工艺
- 环境 ≠ 动作。 每个场景都需要一个与动作频率不同的连续通道(漂移、波浪、空闲旋转、值脉冲),其零交叉点与节拍对齐,以便动作仍然干净地落地。这满足了“从未冻结”的要求。
- 一种缓动系列。 临界阻尼弹簧
1-(1+x)e^-x(永不超调)用于移动;指数缓出/入用于擦除、按压、退出。确保着陆与事件同时发生:开始 = 事件 − 持续时间,并归一化使得在事件发生时进度恰好为 1。 - 两处运动模糊:在帧函数内部(2–4 个快门样本,使用运行平均——
alpha = 1/(k+1)——最终完全不透明度),以及编码器侧的时间混合。用一个索引编码的定标(输入帧的像素编码帧号并回读哪些索引被平均)证明滤镜窗口语义,绝不依赖文档。 - 通过构造保持连续性。 跨场景携带一个几何/状态模型并进行变换(变形、材质替换、硬边擦除冲刷、扫描线),而不是剪切或溶解。
- 保持长度的变形:通过角度/弧长参数化形状,使混合时生长而非缩放。
- 默认禁用列表(除非要求,否则删除):粒子、发光、镜头眩光、摄像机抖动、RGB 分裂、交叉淡化、通用渐变、弹跳超调。
4. 渲染与编码
- 格式 vs 目标:首先测量吞吐量。无损 PNG 帧为多 MB,会压垮管道;高质量 JPEG 速度大约快 1.5 倍。一次决定,然后保持。
- 使用管道,而不是假脱机:将帧输入 ffmpeg 的标准输入(
-f image2pipe -framerate <fps*sub> -vcodec png -i -)。使用tmix混合,选择组的最后一帧,重新计时,使用-frames:v <n>进行裁剪。 - 按成本并行化,而非帧数。测量每场景毫秒数,然后划分为大致相等的时间块;统一的帧计数会使最慢的工作线程决定挂钟时间。
- 片段必须可拼接安全:每端重复 2 个边界帧,相同的编码器参数和时间基,使用 ffprobe 验证帧数/时长,然后使用
-c copy拼接。 - 将管线分阶段,使得一次失败仅损失一个阶段:渲染 → 混合 → 编码 → 音频 → 复用 → 测量;每个阶段写入一个文件。
- 在阶段之间保留无损视频母版(所有地方使用
-c copy),并且最后进行响度处理。 - 在依赖未知工具链之前先探测它:滤镜/编码器列表、浏览器可执行文件、版本;允许
$FFMPEG/$FFPROBE覆盖;优先使用固定的本地二进制文件而非 PATH 猜测。浏览器附带的精简版“媒体” ffmpeg 通常缺少x264/tmix/loudnorm。 - 通过文件描述符捕获子进程输出,而不是管道。 在受限沙箱内,
spawn/execSync配合stdio: 'pipe'可能会因EPERM失败,而相同的二进制文件从 shell 运行则正常。改为传递打开的文件句柄:spawnSync(cmd, a, { stdio: ['ignore', fdOut, fdErr] }),然后读取文件——对于纯存在性/版本探测,使用stdio: 'ignore'。将标准输出和标准错误路由到单独的文件,以便二进制帧数据不会与日志文本交织。 - 完成后从源代码进行端到端重建,以确保交付的文件不是幸运缓存。
5. 音频与复用
- 与画面相同的时间基和相同的确定性(离线渲染、种子、无实时依赖)。
- 一个提示表(
{t, name})作为每个可听/可见事件的唯一真相来源;音效位于自己的总线上,声音比音乐轻。 - 使用两遍 loudnorm 进行归一化(
print_format=json→ measured_* → 第二遍使用linear=true),然后使用独立的第三测量验证;断言综合响度在 ±0.5 LU 以内,真峰值 ≤ 目标。 - 循环:将尾部(约 40 毫秒)淡出到精确零值,第一个样本不发出咔嗒声;测量打击乐命中对齐到网格的程度并报告。
- 使用
-c:v copy -c:a aac -b:a <spec>复用,然后使用 ffprobe 检查容器作为断言,而非查看。 -b:a Nk对于 ffmpeg 的原生 AAC 编码器来说是目标,而非下限(在安静素材上,针对 320k 请求测得约 180–250 kbps;-aac_coder twoloop有帮助)。读取声明的bit_rate并报告实测平均值,而不是重复请求的数字,并在规范要求比特率时明确说明。
捆绑:scripts/mux_normalize.mjs。
6. 保持绘制层廉价且确定
- 缓存所有时间不变的内容:预烘的背景、文本遮罩、图案、Path2D。缓存键包含每一个可能改变像素的参数。
- 画布池必须根据源画布尺寸进行大小设置,而非逻辑内容尺寸——否则
drawImage会无声地拉伸或裁剪(这导致了裁剪/损坏的文本)。 - 避免在帧循环中使用
shadowBlur/filter;优先使用几何。 - 内存:一个 1080p 画布约 8 MB;限制池的大小。
- 记住子帧计数会线性增加帧预算。
7. 协作
- 分开作者与验证者。 验证者拥有质量控制脚本和判定交付物不合格的权力,并独立检查作者未检查的维度(alpha、与页面的保真度、响度、循环闭合)。这能捕获作者视而不见的错误类型。
- 不重叠的写入范围 + 冻结的接口,以便并行工作不会冲突;哈希化修订版本并在任何更改后重新运行质量控制。
- 失败要响亮:非零退出、每个声明一行
PASS/FAIL、机器读取的部分使用 JSON、无静默回退。
8. 反模式
- 通过白色合成的审阅图像判断颜色。
- 编辑资产以满足一个从未表征过的度量。
- 调整检测器的阈值而不是修复其模型。
- 为了回答一个 20 秒的页面侧探测就能回答的问题而重新运行完整渲染。
- 在成本差异很大的场景上使用均匀的帧计数分块。
- 审阅路径与交付路径不同(比例、颜色范围、合成)。
- “看起来还行”而不是一个断言;失败但退出码为 0。
- 使用会损坏非 ASCII 的工具进行文本/资产手术(编辑后验证编码)。
- 手动修复审阅帧而不是修复源。
- 用交叉淡化掩盖状态不连续性。
9. 检查清单
预飞行——规范 → 断言;render(t) 签名冻结;接缝策略 + 循环代数;资产嵌入;三个探测存在;每帧预算已设定;FFMPEG/FFPROBE 已解析并探测能力。
飞行中——每个场景后:成本探测、运动序列、事件采样审阅网格;问题列表已关闭;语法/类型检查以及对编辑过的源进行非 ASCII 检查;哈希化修订版本。
交付前——从源代码重建;完整渲染(按成本加权、边界帧、拼接已验证);响度两遍 + 第三遍验证;独立测量脚本 → PASS/FAIL;明确报告精确与近似声明;删除临时脚本/截图,并记录每个内容的一条命令重新生成方式。
完成定义——容器元数据精确;接缝行为已验证;冻结段限制已满足;响度/真峰值已满足;屏幕上的文本与简报逐字匹配;一条命令从源代码重建;针对冻结哈希的所有质量控制通过。
10. 捆绑脚本
两者都是单文件、零依赖(仅需要 ffmpeg/ffprobe),从 $FFMPEG/$FFPROBE → PATH → 公共位置解析工具,每个声明打印一行 PASS/FAIL,使用 --json 输出 JSON,并在失败时以非零退出码退出。它们从该技能自身的路径解析其目录。
# QC 一个完成的文件:容器与期望对比、冻结段限制、循环接缝、
# 响度/真峰值、可选的用于人工审查的接触页
node av_audit.mjs film.mp4 --expect-duration 30 --expect-fps 60 --expect-size 1920x1080 \
--static-limit 0.5 --sheets ./sheets # exit 0 = 所有检查通过
# 将 WAV 归一化到目标值并复用到一个已编码的视频上(视频流复制),
# 然后通过第三独立的测量验证
node mux_normalize.mjs silent.mp4 score.wav film.mp4 --lufs -14 --tp -1 --abr 320k
av_audit.mjs 有意使用低分辨率(160×90)的 MAD 序列:它能检测整帧值变化和块移动,而不是细线的亚像素运动——将其与 §1 中的度量敏感性规则配对使用。对于循环,将 first_vs_last_psnr_db 视为关于一帧运动的观察结果,并通过比较渲染器自身的两次渲染来单独证明精确性。
一行总结——成功取决于三件事,没有一件是绘图技巧:时间函数是纯且可验证的,度量在被优化之前就被理解,以及审阅路径与交付路径是相同的路径。