时间线到 filter_complex:把编辑文档编译成 FFmpeg 滤镜图
用户在时间线上拖了几十段素材、加了配音轨、排了字幕,点"导出"的那一刻,所有这些结构化数据必须被翻译成一条 命令行------几十个
-i输入、一张带标号的滤镜有向图、一对最终 map。这篇文章不谈进程管理与进度回传(那是运行时的事),只谈最难的一环:如何把一份编辑文档确定性地编译成正确的 FFmpeg filter_complex,包括多输入裁剪、concat 接缝、画布设一、音轨覆盖语义、以及 Windows 盘符冒号如何让一行字幕命令静默崩溃。
一、核心思想:命令构建器是纯函数
第一原则:构建命令与执行命令严格分离。构建器是一个纯函数:
ts
function buildRenderCommand(input: {
document: ProjectDocument
outputPath: string
}): RenderCommand {
return { args: string[], totalSeconds: number, summary: string }
}
相同的 document 永远产出相同的 args------不读时钟、不读环境、不 spawn、不写文件。这个约束带来三个直接收益:
- 可单测:构造一份 document,断言 args 数组,不需要任何 FFmpeg 二进制。
- 可复现:用户报 bug 时把 document + args 贴回来,本地 100% 复现。
- 副作用上移:临时 SRT 文件的写入、cwd 设置、进程 spawn 全部留给 job manager,构建器只负责"翻译"。
这与时间线引擎里"op 是数据、apply 才是副作用"的分层一脉相承:先把意图算成值,再由唯一的边界去执行。
二、输入段:每个 clip 是一条独立输入
时间线上 30 个 clip 不是"一个文件裁 30 次",而是 30 条独立的 FFmpeg 输入:
ts
videoClips.forEach((clip) => {
const asset = findAsset(document, clip.assetId)
if (!asset) return // ① 外键悬空:跳过而非崩溃
if (!(clip.sourceOut > clip.sourceIn)) return // ② 非法区间:跳过
inputArgs.push(
'-ss', String(clip.sourceIn), // 输入级 seek:快速定位到最近关键帧
'-t', String(clip.sourceOut - clip.sourceIn),
'-i', asset.path,
)
})
两个关键决策:
为什么用输入级 -ss(放在 -i 前面)而不是输出级? 输入级 seek 在 demuxer/解码器层跳过数据,长视频几乎零成本;输出级(-i 之后)要解码丢弃到目标点。导出场景素材长、切点多,差距可达数十倍。代价是只能定位到关键帧,精度约 ±1 帧------剪辑工具按帧剪,这个误差在 concat 重编码时表现为切点轻微偏移。要帧级精确可改用 -ss + -accurate_seek(现代 FFmpeg 默认对输入级 seek 也做精确解码补偿),但要评估速度损失。
为什么非法 clip 是"跳过"而不是"报错中断"? document 已经过三层 Zod 校验,理论上不会有悬空 assetId。但构建器是最后一道防线------用户手里的工程文件可能来自损坏的磁盘、旧版本、手工编辑。渲染管线的哲学是尽力成片:跳过坏 clip,其余正常导出,在 summary 里报告跳过数量。让用户拿到一条缺了 2 秒的视频,远好过什么都拿不到。
输入计数器 inputIndex 是后面滤镜图引用流的句柄([0:v:0]、[1:a:0]),必须在追加输入时同步递增------视频输入与音频输入共用一个编号空间。
三、视频链路:四段式滤镜流水线
每个视频输入先过一条逐流归一化链,再进 concat:
text
[i:v:0]
→ settb=AVTB 统一时基(不同素材时基可能不同:1/25、1/30000......)
→ setpts=PTS-STARTPTS 时间戳归零(-ss 之后 PTS 是绝对时间,concat 要求从 0 开始)
→ scale=W:H:force_original_aspect_ratio=decrease 等比缩放到画布内
→ pad=W:H:(ow-iw)/2:(oh-ih)/2 不足部分补黑边居中
→ setsar=1 采样宽高比归 1(防止变形)
→ fps=30 统一帧率
[v_clip_i]
ini
[v_clip_0][v_clip_1]...[v_clip_n] concat=n=N:v=1:a=? [cat_v]
逐段解释其中的坑:
1. settb + setpts:concat 的隐形前置条件
concat filter 要求所有输入流时间基一致、PTS 都从 0 开始。漏掉 settb=AVTB,两段 25fps 和 30fps 的素材拼在一起,后一段时长漂移;漏掉 setpts=PTS-STARTPTS,每段都从自己的绝对 PTS 开始,concat 后出现大段黑屏空隙。这是 concat 类问题最高频的两个根因。
2. scale + pad:保比例而不是拉伸
直接 scale=1920:1080 会把 9:16 的竖屏素材拉成扁脸。正确做法分两步:scale 用 force_original_aspect_ratio=decrease 等比缩到"能放进画布的最大尺寸",再 pad 用 (ow-iw)/2:(oh-ih)/2 把差额对称补黑边------画面永远居中、不变形。这是画布(canvas)存在的工程意义:画布是契约,素材是被安置的内容。
3. fps 统一:不做的话 concat 直接报错
帧率不同的流 concat 在某些 muxer/编码器组合下会产生输出帧率抖动。归一化链尾部显式 fps=${canvas.fps},把帧率转换放在拼接之前一次性完成。
4. concat 的 a 流参数:v=1:a=0 还是 a=1
这是整条命令里最容易写错的布尔位,取决于音轨策略(见第五节):
ts
const useVideoAudio = validVoiceInputs.length === 0
`concat=n=${n}:v=1:a=${useVideoAudio ? 1 : 0}`
有独立配音轨时,视频 concat 声明不输出音频 (a=0),且 concat 的输入标签里音频流要写成可选 [i:a:0?]------问号表示"该输入可能没有音频流",否则纯视频素材会让 concat 因缺流直接失败。
四、音频链路:重采样与配音覆盖
text
[i:a:0]
→ aformat=sample_fmts=fltp:channel_layouts=stereo:sample_rates=48000
→ asetpts=PTS-STARTPTS
[a_clip_i]
[a_clip_0]... concat=n=M:v=0:a=1 [voi_a]
aformat 一个滤镜同时锁定三件事:采样格式(fltp,AAC 编码要求)、声道布局(stereo)、采样率(48000)。不同 TTS 引擎输出 22050/24000/44100Hz 都有,不统一采样率,concat 出来的音频会变速变调或直接报错。48kHz 是视频发行的事实标准,与工程 exportSettings 的字面量约束一致。
音轨覆盖语义需要在产品上明确:时间线上存在 voiceover/audio 轨时,导出音频 = 配音轨拼接,原视频自带声音被完全替换;不存在时才回落到视频原声。这不是混音(mix),是二选一------因为该实现是 Slice A 的简化策略。完整混音(人声 + 背景音乐按音量混合,amix/sidechaincompress)是滤镜图的下一步演进,但"覆盖"作为第一版语义是正确的:口播类内容最怕双层人声打架,替换比混合安全。
五、字幕烧录:一行 filter 引发的跨平台战争
字幕链路分三段:cue 数组 → SRT 文本 → 临时文件 → filter 引用。
SRT 生成的时间码
ts
// 毫秒必须是逗号,结束时间至少比起始大 1ms
const startMs = Math.max(0, cue.start * 1000)
const endMs = Math.max(startMs + 1, (cue.start + cue.duration) * 1000)
// 00:00:01,500 --> 00:00:04,200
Windows 盘符冒号陷阱(本模块最有价值的实战修复)
直觉写法是把 SRT 的绝对路径塞进 filter:
text
[cat_v]subtitles='C:/Users/me/AppData/Local/Temp/xxx/subtitles.srt'[sub_v]
在 Windows 上静默崩溃 :FFmpeg 滤镜图解析器把 : 当作 filter 选项分隔符,C:/... 被解析成一个名为 C 的选项,参数解析阶段直接失败(退出码 -22),且 stderr 可能为 0 行------没有任何有效报错,排查全靠经验。
路径转义的几次演进都失败过:
- 转义盘符冒号
C\:→ 与file:///协议叠加后变成file\:///C\:/...,协议识别失败,同样 exit -22。 - 换
file:///URL → 滤镜对 URL 的支持在各平台不一致。
最终方案是彻底回避冒号出现在 filter 字符串里:
ts
// 1. filter 图里只写裸文件名(无盘符、无目录)
filterComplex += `;[${videoStreamLabel}]subtitles='subtitles.srt'[sub_v]`
// 2. job manager 把 SRT 写到临时目录,spawn 时 cwd 指向该目录
const child = spawn(bin.ffmpeg, args, { cwd: tempDir })
FFmpeg 在 cwd 下按相对路径找到 subtitles.srt,filter 字符串里永远没有冒号。这个修复体现了一个通用工程模式:当转义规则在多层解析器(滤镜语法 → 路径语法 → 平台协议)之间组合失控时,不要继续打补丁,改变输入形态让冲突字符物理消失。
配套的路径转义函数只处理滤镜语法定界符(方括号、逗号、单引号),且临时目录用 ASCII 的 os.tmpdir() + randomUUID(),从源头规避中文/空格路径的转义组合爆炸。
标签接力
注意滤镜图末尾的标签替换:
ts
filterComplex += `;[cat_v]subtitles=...[sub_v]`
videoStreamLabel = 'sub_v' // 后续 -map 的目标改成新标签
滤镜图是流式管线:每加一段,"当前视频流"的标签就向后接力一次。map 永远引用最终标签,不需要知道中间串了多少段------这让"加字幕"成为可插拔的一段,未来加调色、加水印只需继续接力。
六、空轨兜底:黑底与静音也是合法输入
时间线可能只有配音没有画面(纯口播草稿),或只有画面没有声音。FFmpeg 不会替你发明流,map 一个不存在的流就是硬错误。兜底用 lavfi 虚拟源:
ts
// 无视频:生成画布尺寸、工程帧率的黑底
'-f', 'lavfi', '-t', totalSeconds, '-i',
`color=c=black:s=${canvas.width}x${canvas.height}:r=${canvas.fps}`
// 无音频:生成 48kHz 立体声静音
'-f', 'lavfi', '-t', totalSeconds, '-i', 'anullsrc=r=48000:cl=stereo'
要点是输出流的结构永远完整:无论 document 里有什么,最终产物一定是"一路视频 + 一路音频、时长确定"的 mp4。播放器、后续上传平台、前端预览对"缺流"的容忍度都很差,对"黑屏/静音段"的容忍度很好。在编译期消除"缺流"这种非法输出形态,比在每个消费端打补丁便宜得多。
七、输出参数:编码意图的显式声明
ts
['-y',
'-c:v', exportSettings.codec, // libx264
'-pix_fmt', 'yuv420p', // 4:2:0,兼容性最广(奇数尺寸会编码失败)
'-r', String(exportSettings.fps),
'-c:a', exportSettings.audioCodec,
'-ar', '48000', '-b:a', '192k',
'-movflags', '+faststart', // moov 前置,网页/移动端秒开
'-shortest'] // 以最短流为准结束
三个容易被忽略的点:
yuv420p不只是像素格式,还是兼容性声明:QuickTime、很多移动端硬解、网页播放器对 yuv444p 支持很差。+faststart让导出在结尾多一次 moov 重写(约 5% 时间成本),是发行向文件的必选项。-shortest在有黑底/静音兜底流时长等于工程总时长的前提下是安全的;没有兜底流时它可能导致音画其中一路被意外截断------两者要配套设计。
八、可调试性:把命令行变成一等产物
构建器返回的不只是 args,还有 summary 和给 manager 用的 fullCommand(每个参数双引号包裹,可直接在 PowerShell 粘贴复现):
text
输出: D:/out.mp4 | 尺寸: 1920x1080@30fps | 视频clip: 8 / 配音clip: 3 |
字幕: 12 条 | 总时长约: 00:01:32
这条设计贯穿整个故障排查链:job manager 保留最近 20 行 stderr,加上可复制的完整命令和临时目录路径,任何一次导出失败,用户都能把"命令 + stderr + cwd"打包给工程师。滤镜图类问题的现场几乎都在参数组合里,没有可复现的命令行,这类 bug 只能靠猜。
九、小结
| 编译环节 | 关键决策 |
|---|---|
| 架构 | 构建器是纯函数;副作用(写 SRT/spawn/cwd)留给 manager |
| 输入 | clip 级独立输入 + 输入级 -ss;坏 clip 跳过不中断 |
| 视频链 | settb→setpts→scale(decrease)→pad 居中→setsar→fps→concat |
| 音频链 | aformat 锁 fltp/stereo/48k;asetpts 归零 |
| 音轨语义 | 有配音则 concat a=0 可选流 [a?],配音覆盖;无配音回落原声 |
| 字幕 | SRT 裸文件名 + spawn cwd 回避 Windows 盘符冒号;标签接力 |
| 空轨 | lavfi color/anullsrc 兜底,输出流结构永远完整 |
| 输出 | yuv420p 兼容 + faststart + shortest 配套兜底 |
| 调试 | 可粘贴命令 + summary + stderr 环形缓冲 |
把时间线编译成 FFmpeg 命令,本质上是写一个针对特定后端的代码生成器:document 是 IR(中间表示),filter_complex 是目标汇编,构建器是编译器。编译器工程的经典原则在这里全部适用------纯函数转换、非法输入降级而非崩溃、目标平台的怪癖(盘符冒号)用输入整形绕开、产物自带可调试信息。用户看到的是"导出"两个字,工程师看到的是一次完整的 IR → 有向图 → 命令行的确定性翻译。