用代码生成宣传片(四):ffmpeg 合成、端到端工作流与踩坑大全
概览
这是系列的完结篇。前面三篇分别讲了用 Remotion 生成画面、用 edge-tts 批量配音、用 numpy 离线合成 BGM。本篇把三路产物合一:用 ffmpeg filter_complex 把无声视频 + 7 段配音 + 一段 BGM 混成最终成片,同时覆盖端到端工作流、踩坑大全和底层原理。
读完后你将理解:
- 为什么
-c:v copy比重新编码快得多且无损 - 如何排布多段配音的时间轴来避免重叠
filter_complex每段滤镜的数学含义- 一条命令批量产出 6 种语言版本
ffmpeg 合成
整体思路
合成阶段的任务很单纯:把三样东西装进一个 MP4。
- 视频轨:用
-c:v copy直接复用 Remotion 渲染好的无声画面,不解码、不重编码,快且不损画质。 - 音频轨:用
-filter_complex把 7 段人声 + 1 段 BGM 实时混成一条立体声。
前提是画面在 Remotion 阶段已经定稿。只要不改画面,每次跑合成就是秒出——这也是分层管线最大的红利。
时间轴:顺序排布,避免人声重叠
这是合成阶段最容易被忽视的坑,值得优先讲。
❌ 错误做法:把 7 段配音的起点硬对齐到 Remotion 场景起点 [0.5, 5, 11, 19, 26, 33, 40] 秒。因为每段配音实际有 5–8.7 秒长,而场景间隔只有 4.5–8 秒,必然前后重叠、互相打架。英文版因为文本更长,总时长甚至会超过视频的 45 秒。
上图直观展示了两种排布方式的差异。上方的错误做法把每段人声硬对齐到场景起始帧,各段 5–8.7 秒的片段在 4.5–8 秒的间隔里互相重叠、打架;下方的正确做法按顺序时间轴排布,每段结束留 0.35 秒 GAP,从根本上杜绝重叠。紫色虚线播放头从左到右扫描 45 秒时间轴,模拟播放进度。
✅ 正确做法:用 ffprobe 读取每段配音的真实时长,按顺序时间轴排布——每段结束留 0.35 秒 GAP 再开始下一段。如果某版本(通常英文女声)排布后超出视频长度,再对它施加极轻微的 atempo 压缩(<1.05 倍几乎不可察觉)。
| |
GAP 的合理性:0.35 秒在听觉上约等于一个自然停顿,观众不会察觉;但它足以保证一个 5–8 秒的人声片段与下一段之间没有任何样本重叠。amix 的 normalize=0(见下节)依赖各段不重叠来保留原始音量,因此 GAP 不仅是为了听感,也关乎混音正确性。
filter_complex 逐段解析
以下就是整个音频合成的核心——一段 filter_complex 字符串(伪代码形式,实际拼接见下文 mixer 脚本):
| |
逐段解释:
aformat=channel_layouts=stereo:sample_rates=44100:人声可能是 mono MP3,BGM 是 stereo WAV——amix要求各路输入声道数和采样率一致,所以必须用aformat统一。否则 ffmpeg 直接报错退出。adelay=500|500:把整段音轨往后推 500ms(|分隔左右声道,单位毫秒)。这就是「时间轴」的物理实现——每段人声延迟到各自的起点,在时间轴上顺序排开。volume=1.35:略微提升人声,让它在最终混音中更突出。配合侧链压缩的 ducking 效果,人声清晰又不突兀。amix=inputs=7:normalize=0:把多路音轨逐样本相加(sum)。normalize=0时不做归一化,保留原始幅值。因为各段不重叠,同一时刻只有一路有信号,相加结果就是单轨的原音量。如果各段有重叠,叠加部分会超过 1.0 导致削顶。asplit=2:把人声复制成两路。一路送去触发侧链压缩(voice_sc),另一路送去参加最终混音(voice_mix)。sidechaincompress=threshold=0.03:ratio=8:attack=5:release=350:makeup=1:侧链压缩(闪避 / ducking)——压缩器从另一条音轨(人声)读取触发信号。当人声响起超过threshold,压缩器立刻把 BGM 音量压下去(ratio=8= 压到 1/8),人声一停再按release=350ms慢慢恢复。这是广播和播客处理「人声 vs 背景音乐」的标准手法,比单纯压低 BGM 自然得多。alimiter=limit=0.95:最后兜底限幅器,任何超过 0.95 的样本被夹住,防止削顶破音(clipping——听起来「滋滋」失真)。
下面是用 Mermaid 表达的完整 filter 图:
flowchart TD
V["视频轨<br/>-c:v copy"] --> OUT["输出 MP4"]
BGM["BGM"] --> VOL["volume=0.06"]
VOICE["7 段人声<br/>→ aformat → adelay → volume=1.35<br/>→ amix(inputs=7,normalize=0)"] --> ASPLIT["asplit=2"]
ASPLIT --> SC["voice_sc<br/>(侧链信号)"]
ASPLIT --> VM["voice_mix<br/>(混音信号)"]
VOL --> SCS["sidechaincompress<br/>threshold=0.03 ratio=8<br/>attack=5 release=350"]
SC --> SCS
SCS --> AMIX2["amix(inputs=2,normalize=0)"]
VM --> AMIX2
AMIX2 --> LIMIT["alimiter limit=0.95"]
LIMIT --> OUT
classDef default fill:#1a1a2e,stroke:#e0e0e0,stroke-width:1.5,color:#e0e0e0;
classDef process fill:#2196F3,stroke:#1976D2,color:#fff;
classDef audio fill:#9C27B0,stroke:#7B1FA2,color:#fff;
classDef output fill:#4CAF50,stroke:#388E3C,color:#fff;
classDef trigger fill:#FF9800,stroke:#F57C00,color:#fff;
class V,OUT output;
class BGM,VOL,AMIX2,LIMIT process;
class VOICE,ASPLIT,SC,VM audio;
class SCS trigger;图 1:ffmpeg filter_complex 音频处理图。 左侧三个输入源分三路进入:视频轨(绿)直接 -c:v copy 输出;BGM(蓝)先压音量再进侧链压缩;7 段人声(紫)统一格式、延迟到各自起点、提升音量后合并为一路,再复制成侧链信号和混音信号。侧链压缩器(橙)以人声为触发器动态压低 BGM,最后两路合并进限幅器兜底,与视频一同输出为最终 MP4。
完整 Python mixer 脚本
把以上 filter 逻辑封装进 mix_all.py,批量产出所有语言版本:
| |
输入顺序:-i VIDEO -i BGM -i voice_1 ... -i voice_7。filter 里 [0:v] 是视频,[1:a] 是 BGM,[2:a]~[8:a] 是 7 段人声。每段人声的 filter 串动态生成:读取真实时长 → build_timeline 算出起点 → adelay 写到该起点。
端到端工作流
完整的流水线命令
| |
三条命令,一条流水线。每次改文案或调画面,只需重复步骤 ①(画面重渲染)→ ③(重新合成);如果只改 BGM 风格,跳过 ① 和 ②,只跑 ③。
出一版「新语言/新音色」的 checklist
gen_all_voices.py的VERSIONS加一行(子目录, 文本列表, 语音ID, 语速)。mix_all.py的VERSIONS加一行(子目录, 输出文件名)。- 跑
gen_all_voices.py(或者单独跑该版本以节省时间)。 - 跑
mix_all.py(自动用最新渲染的MiBeeNVR_Promo_Full.mp4)。
⚠️ 改了画面必须重渲染再重混。脚本只改源码不自动出片——本系列踩过这坑,表现为热情地改了半天的代码,一看输出「怎么没变」。记住:改源码 → 重渲染 → 重混,三步缺一不可。
踩坑大全
| 现象 | 根因 | 解法 |
|---|---|---|
| 画面里 emoji 变空白方框 | headless Chrome 无彩色 emoji 字体 | 全部换成内联 SVG 图标 |
| 图被裁切 / 画到画布外 | SVG 坐标超出 viewBox 或出现负坐标 | 重算坐标,确保元素落在画布内 |
| 黑色太多、显空洞 | 背景只有纯色 | 加 AmbientBackground + FloatingHexes + 粒子 |
| 手机上小字糊成蚂蚁 | 1080p 缩放到 ~0.21 倍 | 正文字号 ≥ 40px,用脚本做全局映射 |
| BGM 盖住人声 | BGM 音量太高 | volume=0.06 + sidechaincompress 闪避 |
| BGM 听久了头痛 | 失谐拍频 / 梳状混响 / 高频堆积 | 纯正弦零失谐 + 平滑低通 + 卷积混响(见第 3 篇) |
| 人声前后重叠打架 | 配音硬对齐场景起点,段长 > 间隔 | 顺序时间轴 + 0.35s GAP(见 5.2) |
edge-tts 偶发 No audio received | 微软服务端瞬时抽风 | 加重试(5 次 + 指数退避) |
| 改了源码却没变化 | 只改码没重新渲染 / 合成 | 重渲染 → 重混 |
延伸学习
- Remotion 官方文档:remotion.dev/docs ——
Sequence、interpolate、spring、AbsoluteFill - edge-tts:
edge-tts --list-voices看全部音色;GitHub rany2/edge-tts - ffmpeg filter 文档:重点看
amix、adelay、sidechaincompress、alimiter、atempo、aformat - 声学基础:拍频(beating)、梳状滤波(comb filtering)、卷积混响(convolution reverb)——理解了这三者,BGM 就不会再「听了头痛」
底层原理详解
ffmpeg 原理:滤镜图如何合流多路音视频
MP4 是容器格式,里面装的是独立的编码流。视频流用 H.264(帧间压缩,靠前后帧差异),音频流用 AAC(心理声学模型压缩)。Remotion 渲染出的无声 MP4 已经包含了 H.264 视频流——我们的任务只是给它配上音频,不碰视频编码。
-c:v copy 为什么又快又不损画质?
-c:v copy(copy video codec)指示 ffmpeg 不解码、不重编码,把视频流原封不动地从输入文件搬到输出文件。这只有在「画面已经定稿、不需要任何修改」时才成立。本项目在 Remotion 阶段画面就定型了,合成阶段只动音频,所以视频直接 copy。
与重新编码相比:
- 速度:copy 是 O(n) 的容器级搬运(~5 秒完成),重新编码是 O(n) 的逐帧解码再编码(45 秒视频可能需要 2–5 分钟)
- 画质:copy 完全无损;重编码即使码率设高也有微损
-c:v copy 为什么又快又不损画质?
-c:v copy(copy video codec)指示 ffmpeg 不解码、不重编码,把视频流原封不动地从输入文件搬到输出文件。这只有在「画面已经定稿、不需要任何修改」时才成立。本项目在 Remotion 阶段画面就定型了,合成阶段只动音频,所以视频直接 copy。与重新编码相比:
- 速度:copy 是 O(n) 的容器级搬运(~5 秒完成),重新编码是 O(n) 的逐帧解码再编码(45 秒视频可能需要 2–5 分钟)
- 画质:copy 完全无损;重编码即使码率设高也有微损
-filter_complex 是一张有向图。
它比简单的 -af(音频滤镜)强大得多——-filter_complex 允许你把多个输入、多个输出、多条路径组织成一张有向无环图(DAG):
- 每个输入有固定标号:
[0:v](第 1 个输入的视频)、[1:a](第 2 个输入的音频)…… - 每个滤镜节点读入一个或多个标号,输出贴新标签
- 最终用
-map选取哪个流向写入输出
本项目的 filter 图有 10 个输入(1 视频 + 1 BGM + 7 人声),9 个滤镜节点(8 个 aformat+adelay+volume + 1 个 amix + 1 个 asplit + 1 个 sidechaincompress + 1 个 amix+alimiter)。看 5.3 节的 Mermaid 图一目了然。
它比简单的 -af(音频滤镜)强大得多——-filter_complex 允许你把多个输入、多个输出、多条路径组织成一张有向无环图(DAG):
- 每个输入有固定标号:
[0:v](第 1 个输入的视频)、[1:a](第 2 个输入的音频)…… - 每个滤镜节点读入一个或多个标号,输出贴新标签
- 最终用
-map选取哪个流向写入输出
本项目的 filter 图有 10 个输入(1 视频 + 1 BGM + 7 人声),9 个滤镜节点(8 个 aformat+adelay+volume + 1 个 amix + 1 个 asplit + 1 个 sidechaincompress + 1 个 amix+alimiter)。看 5.3 节的 Mermaid 图一目了然。
音频时间轴模型。
音频是连续时间轴,adelay 就是「在时间轴上平移一段波形」。adelay=500|500 把整段音轨往后推 500ms(| 前后是左右声道,单位毫秒)。7 段人声各自延迟到自己的起点——在时间轴上顺序排开,互不重叠。这就是 5.2 节「顺序时间轴避免重叠」的物理实现。
音频是连续时间轴,adelay 就是「在时间轴上平移一段波形」。adelay=500|500 把整段音轨往后推 500ms(| 前后是左右声道,单位毫秒)。7 段人声各自延迟到自己的起点——在时间轴上顺序排开,互不重叠。这就是 5.2 节「顺序时间轴避免重叠」的物理实现。
amix 的混音数学。
amix 的本质是逐样本相加(sum)。normalize=0 时不除路数,保留原始幅值:
- 当各路不重叠时(本项目场景),同一时刻最多只有一路有信号,相加结果 ≈ 单路原音量,人声保持自然响度
normalize=1(默认值)会把总和除以路数,适合多路同时发声的场景(如多路麦克风混音),但对不重叠的序列化配音会不必要地衰减
amix 的本质是逐样本相加(sum)。normalize=0 时不除路数,保留原始幅值:
- 当各路不重叠时(本项目场景),同一时刻最多只有一路有信号,相加结果 ≈ 单路原音量,人声保持自然响度
normalize=1(默认值)会把总和除以路数,适合多路同时发声的场景(如多路麦克风混音),但对不重叠的序列化配音会不必要地衰减
sidechaincompress(侧链压缩 / 闪避)。
传统压缩器对自己的输入做增益衰减。侧链压缩器则从另一条音轨读取触发信号(threshold),自己的增益按触发信号来调整。
本项目:[bgmlow][voice_sc]sidechaincompress —— BGM 是被处理的信号,人声是触发信号。人声一响超过 threshold=0.03,BGM 音量立刻被压到 1/8(ratio=8),人声一停按 release=350ms 斜率恢复。结果是每句话开头 BGM 自动「让开」,人声结束后 BGM 缓缓回来,自然得像专业电台混音。
传统压缩器对自己的输入做增益衰减。侧链压缩器则从另一条音轨读取触发信号(threshold),自己的增益按触发信号来调整。
本项目:[bgmlow][voice_sc]sidechaincompress —— BGM 是被处理的信号,人声是触发信号。人声一响超过 threshold=0.03,BGM 音量立刻被压到 1/8(ratio=8),人声一停按 release=350ms 斜率恢复。结果是每句话开头 BGM 自动「让开」,人声结束后 BGM 缓缓回来,自然得像专业电台混音。
alimiter(限幅器)。
mix 之后的数字信号可能超过 0dBFS(数字满量程),超出部分被削平就产生 clipping 失真——听起来像「滋滋」的刺耳杂音。alimiter=limit=0.95 把任何超过 0.95 的样本「夹住」,确保最终输出永远不会削顶。
mix 之后的数字信号可能超过 0dBFS(数字满量程),超出部分被削平就产生 clipping 失真——听起来像「滋滋」的刺耳杂音。alimiter=limit=0.95 把任何超过 0.95 的样本「夹住」,确保最终输出永远不会削顶。
atempo(变速不变调)。
atempo 改变播放速度但不改变音高。原理是 WSOLA(波形相似重叠相加) 或相位声码器——在时间轴上跳过或重复波形片段,但通过重叠和相位调整维持基频不变。本项目只对排布后超出视频总长的版本(通常英文女声文本最长)极轻微使用(<1.05 倍),听觉上几乎不可察觉,只为把总时长控制在 45 秒以内。
atempo 改变播放速度但不改变音高。原理是 WSOLA(波形相似重叠相加) 或相位声码器——在时间轴上跳过或重复波形片段,但通过重叠和相位调整维持基频不变。本项目只对排布后超出视频总长的版本(通常英文女声文本最长)极轻微使用(<1.05 倍),听觉上几乎不可察觉,只为把总时长控制在 45 秒以内。
aformat 为什么必加。
人声 MP3 可能是单声道(mono),BGM WAV 是立体声(stereo),采样率也可能是 22050/44100/48000 不等。amix 要求各路输入的声道数和采样率严格一致,否则直接报错退出。aformat=channel_layouts=stereo:sample_rates=44100 把所有音频统一到 44.1kHz 立体声,让后续所有滤镜安全运作。
人声 MP3 可能是单声道(mono),BGM WAV 是立体声(stereo),采样率也可能是 22050/44100/48000 不等。amix 要求各路输入的声道数和采样率严格一致,否则直接报错退出。aformat=channel_layouts=stereo:sample_rates=44100 把所有音频统一到 44.1kHz 立体声,让后续所有滤镜安全运作。
整体原理:把「创作」变成「参数 + 代码」
flowchart TD
COPY["文案<br/>字符串"] --> PIPELINE
VISUAL["画面<br/>React 组件"] --> PIPELINE
VOICE["配音<br/>voice ID"] --> PIPELINE
BGM["BGM<br/>合成参数"] --> PIPELINE
MIX["合成<br/>filter 图"] --> PIPELINE
PIPELINE["可版本化<br/>可复现<br/>边际成本低<br/>可调试"]
classDef default fill:#1a1a2e,stroke:#e0e0e0,stroke-width:1.5,color:#e0e0e0;
classDef str fill:#FF9800,stroke:#F57C00,color:#fff;
classDef react fill:#2196F3,stroke:#1976D2,color:#fff;
classDef tts fill:#9C27B0,stroke:#7B1FA2,color:#fff;
classDef bgm fill:#4CAF50,stroke:#388E3C,color:#fff;
classDef mix fill:#2196F3,stroke:#1976D2,color:#fff;
classDef result fill:#4CAF50,stroke:#388E3C,color:#fff;
class COPY str;
class VISUAL react;
class VOICE tts;
class BGM bgm;
class MIX mix;
class PIPELINE result;图 2:整体创作原则。 五个创作要素全部以代码/参数形式存在:文案是字符串(橙),画面是 React 组件(蓝),配音是一个 voice ID(紫),BGM 是 numpy 合成参数(绿),最终合成为一个 filter 图(蓝)。它们全部是可版本化的文本,进入 Git、可 diff、可回滚。结果是一套可复现、低边际成本的媒体生产流水线(绿)。
这套方法论的核心价值在于把所有创作要素代码化:
- 可版本化:文案 = 字符串,画面 = React 组件,配音 = voice ID,BGM = 合成参数,合成 = filter 图——全是文本,能进 Git、能 diff、能回滚
- 可复现:同一组参数永远得到同一结果。Remotion 的纯函数帧模型 + 确定性合成脚本保证:第 N 次运行和第 1 次完全一致
- 边际成本极低:画面只渲染一次(无声),6 种语言只是 6 种不同的「音频叠加方案」,复用同一段视频。加一种新语言 = 加两行配置 + 跑一次混音,画面零成本
- 可调试:每个环节独立(画面 / 配音 / BGM / 合成),出问题能分层定位。画面怪查 Remotion,人声怪查 edge-tts,头痛查 BGM 合成,重叠查 mixer 时间轴
掌握了这层原理,你就不只是在「用工具」,而是在用代码指挥一套媒体生产流水线。改一句话、换一种音色、调一段混响,都是改参数而非重新创作。
本系列:① 总览与 Remotion ② edge-tts 配音 ③ numpy BGM ④ ffmpeg 合成