从素材到成片:ffmpeg 拼接、转码与字幕,一条流水线交付
本文是《Mac mini 玩转 ComfyUI》专栏第 12 篇,视频篇收官。承接第 11 篇的 A→B→A 两段接力:素材不变,这一篇只做"交付"。

实测环境:Mac mini M5 Pro / 64GB / macOS / ffmpeg 9.0.2(Homebrew)。素材为第 11 篇的
F2_A2B.webm与F3_B2A.webm(H3 生成,VP9、544×960、24fps、各 73 帧/3.042s、无音轨)。所有数字为本轮实测,命令与脚本可复现。
第 11 篇结束在一条 6.04 秒的 A→B→A 接力片上。它在播放器里能看,但离"能发"还差一层:webm/VP9 容器、没有音轨、没有字幕、没有平台要求的编码规格。生成解决"素材",ffmpeg 解决"交付"。
这一篇不讲命令大全,只讲五个工序:体检、拼接、接缝、包装、验收。每个工序都对应一个可验证的输出,不靠"看起来差不多"。

一、生成完 ≠ 能发布
一条能上传的片子,至少过四道硬指标:
| 维度 | 第 11 篇素材 | 交付要求 |
|---|---|---|
| 容器/编码 | webm / VP9 | mp4 / H.264(CSDN 视频口径) |
| 画面 | 544×960 / 24fps | 同分辨率、同帧率、yuv420p |
| 声音 | 无音轨 | AAC 44.1kHz 立体声,响度统一 |
| 信息层 | 无字幕 | 烧录字幕 + 右上角"AI 生成"角标 |
这四件事没有一件是模型能替你做的。模型决定"画面里有什么",ffmpeg 决定"这个画面能不能被平台稳定播放"。
举个具体的失败链:直接把 webm 传上去,平台先卡容器;转成 mp4 但忘了音轨,片子静音;补了 BGM 但没做响度,手机外放听不见;字幕用软字幕挂 SRT,播放器不认;最后画面里还有一条 1/24 秒的重复帧,动作转向时"顿"一下。每一环都不致命,叠在一起就是"看起来像半成品"。这篇的流水线,就是把这五环逐个钉死。
二、先体检:六个字段决定后面所有命令
动手前先读素材。一条命令拿到编码、分辨率、帧率、实际帧数、时长和音轨:
bash
ffprobe -v error -count_frames -select_streams v:0 \
-show_entries stream=codec_name,width,height,r_frame_rate,nb_read_frames \
-show_entries format=duration,size -of default=nw=1 F2_A2B.webm
两段素材的体检结果:
| 文件 | 编码 | 分辨率 | 帧率 | 帧数 | 时长 | 体积 | 音轨 |
|---|---|---|---|---|---|---|---|
| F2_A2B.webm | vp9 | 544×960 | 24 | 73 | 3.042s | 495.8KB | 无 |
| F3_B2A.webm | vp9 | 544×960 | 24 | 73 | 3.042s | 473.7KB | 无 |
三个结论直接决定后面的命令:VP9 要转 H.264 (平台兼容性);73 帧是硬边界 (24fps 下 3.042s,不是"大约 3 秒");没有音轨 (BGM 必须从零混进去,不能"调低原声")。另外,两段的分辨率和帧率必须一致------不一致就先统一,别让 concat 去猜。
两个容易读错的字段:nb_read_frames 要加 -count_frames 才准,webm 的 nb_frames 经常是 N/A;r_frame_rate 是标称帧率,avg_frame_rate 才是实际平均帧率,遇到可变帧率素材两者会对不上。体检时顺手看 pix_fmt 和有没有 codec_type=audio 的流------前者决定要不要转 yuv420p,后者决定 BGM 是"混音"还是"从零加轨"。
三、拼接:丢一帧,6.083 变 6.042
第 11 篇的接力有一个天然重复:F2 的末帧和 F3 的首帧都锚在 B 上,拼之前要丢掉 F3 的第 0 帧。拼接因此不能只用 concat 分离器 + -c copy------它不给你丢帧的口子,也不会替你重置时间基,后面还要叠字幕,必须走 filter。
先做个对照:用 concat demuxer + -c copy 合这两段,得到 146 帧 / 6.083s / 964.6KB,文件能播,但重复帧原样保留,VP9 也没转。它适合"同规格、无剪辑点、纯合并"的批量素材,优点是零解码零编码;缺点是它只认容器层面的首尾相接,不认画面内容。只要你要丢帧、改时间戳、叠字幕,就得走 filter。两者的代价不同:demuxer 是复制,filter 是解码+编码------但这条片子本来就要转 H.264,所以 filter 并没有多一次损失,只是把转码提前到拼接这一步。
同一组素材,只改"丢帧后有没有重置 PTS",三个结果:
| 方案 | 处理 | 帧数 | 时长 | 体积 |
|---|---|---|---|---|
| S1 直接拼 | 不丢帧、不 reset | 146 | 6.083s | 1023.7KB |
| S2 只丢帧 | select 丢 F3 第 0 帧,不 reset |
145 | 6.083s | 1019.3KB |
| S3 丢帧 + reset | select + setpts=PTS-STARTPTS |
145 | 6.042s | 1017.7KB |
S2 是最容易踩的坑:帧数已经对了(145),时长却还是 6.083s。因为丢掉的帧在时间线上留下了一个空档------第 72 帧在 3.000s,下一帧直接跳到 3.083s,中间多出 1/24 秒的"空拍"。setpts=PTS-STARTPTS 把第二段的时间戳拉回 0,空档才真正闭合。
正确的拼接:
bash
ffmpeg -i F2_A2B.webm -i F3_B2A.webm -filter_complex \
"[0:v]setpts=PTS-STARTPTS[a];\
[1:v]select='gte(n,1)',setpts=PTS-STARTPTS[b];\
[a][b]concat=n=2:v=1:a=0[v]" \
-map "[v]" -c:v libx264 -crf 18 -pix_fmt yuv420p \
-movflags +faststart 接力.mp4
一句话记:丢帧不是删数据,是删时间线上的一个格子;不重置 PTS,格子还在,只是空着。 这里的 CRF 18 只是中间质量,发布档在第五节统一换成 CRF 23。
四、接缝:同锚硬切,跨镜头才 xfade
拼接解决"长度",接缝解决"看不看得出来"。
同锚接缝(F2 末帧 → F3 第 2 帧)的全图平均差是 1.99 ,段内普通相邻帧是 1.41 ------同一量级,肉眼看不出来。重复帧那一版是 2.00 ,差得也不多,但它让 B 在时间线上连续占两帧,24fps 下就是 1/24 秒的停顿,正好落在动作转向的节骨眼上。所以同锚接缝的答案是:丢重复帧 + 硬切,不要 xfade。
如果给同锚接缝加 0.5s xfade,结果反而更差:总长从 6.042s 变成 5.583s(134 帧) ,少了 0.459s;B 段被拉成 0.5s 的混合,动作"慢半拍"。xfade 的时长账是 总长 = A + B - d,落到 24fps 还要按帧取整------它不是免费的转场。
offset 是最容易写错的参数:它不是转场时长,而是转场在第一段上的起点 。按"转场在第一段结尾收口"的用法,公式是 offset = 第一段时长 − d。同锚两段各 3.042s、d=0.5,offset=2.542;总长 3.042+3.042−0.5=5.584s,24fps 取整落到 134 帧/5.583s。中途那组先把第一段截到 1.5s、第二段从 1.5s 起截到结尾(1.542s),offset=1.5−0.4=1.1,总长 1.5+1.542−0.4=2.642s,落到 64 帧/2.667s。公式先算、帧数后验,别等导出了才发现少了半秒。
跨镜头是另一回事。拿 F1 的即兴版末帧去接 F2 的首帧(A),全图差 10.49 、头区 23.06,硬切会明显跳。上 0.5s xfade,中间帧是两张画面的双重曝光:猫的耳朵、胡须、脑袋边缘都出现重影。

运动中途硬切也一样:F2 第 1.5 秒接 F3 第 1.5 秒,全图差 4.61 、头区 9.91 ,是段内相邻帧的 3 倍多;xfade 0.4s 能把最大邻帧差压到 1.62,但代价是 0.4s 的双影。它能遮"跳变",遮不住"两张不同的画"。
| 接缝类型 | 做法 | 代价 |
|---|---|---|
| 同锚/同帧 | 丢重复帧 + 硬切 | 无 |
| 同场景、运动相位连续 | 硬切 | 几乎无 |
| 不同机位/构图 | 0.4--0.5s xfade | 中间帧双影 |
| 运动相位反转 | 0.4s xfade 只能止血 | 双影 + 节奏变慢,最好重生成 |
五、转码:CRF 18/23/28 与 VideoToolbox
转码只做一次:一次解码 → 一次编码。如果要做多档对比,中间用无损母版(FFV1),不要拿 CRF 18 的成片再压一次------那是在损失上叠损失。
以 145 帧、544×960 的无损母版为基准,四档实测(纯视频轨,不含音频/字幕):
| 档位 | 体积 | 码率 | 编码耗时 | PSNR | SSIM |
|---|---|---|---|---|---|
| CRF 18 | 947.6KB | 1285kbps | 1.20s | 41.27dB | 0.990 |
| CRF 23 | 497.9KB | 675kbps | 1.14s | 40.64dB | 0.985 |
| CRF 28 | 244.0KB | 331kbps | 0.92s | 39.77dB | 0.978 |
| VideoToolbox 4M | 2513.8KB | 3409kbps | 0.76s | 41.26dB | 0.990 |

三个读数:
- CRF 18→23,体积砍半,PSNR 只掉 0.63dB。这条片子的纹理少(扁平插画),CRF 23 肉眼几乎无差,适合作为发布版;CRF 18 留作高质量存档;CRF 28 只适合预览。
- VideoToolbox 快 0.44 秒,但体积是 CRF 18 的 2.65 倍,质量几乎一样。6 秒片无所谓,30 秒、4K 就要算码率账------硬件编码省的是时间,不是带宽。
- PSNR/SSIM 是代理指标,不代表人眼评分。实拍素材的拐点会更早,别把这张表当万能公式;先看自己的素材。
交付编码固定四件套:-c:v libx264 -crf 18/23 -pix_fmt yuv420p -movflags +faststart。yuv420p 保证老播放器能解,faststart 把 moov 挪到文件头,网页播放不用先下完整个文件。
补一组 preset 对照,仍然是 CRF 23、145 帧:veryfast 0.46s / 400.2KB / 40.30dB,medium 0.94s / 497.9KB / 40.64dB,slow 0.92s / 483.4KB / 40.64dB。veryfast 省了约 20% 体积,代价是 0.33dB;slow 相对 medium 只省 3%,6 秒片上的时间差被进程启动噪声吃掉了(同一档位多跑几次,耗时会在 ±0.2s 内浮动,表里的耗时只看量级)。preset 调的是搜索强度,不是质量目标 :这条扁平插画片 medium 足够,长片、实拍再考虑 slow。profile/level 也不用手写------8-bit yuv420p + 这组参数,x264 自己选了 High/3.1;只有要给老设备兜底时才显式 -profile:v main。
六、字幕:没有 libass 也能烧
先查本机 ffmpeg 有没有字幕滤镜:
bash
ffmpeg -hide_banner -filters | grep -E "subtitles|drawtext"
这台 mini 的 Homebrew ffmpeg 9.0.2 是精简版,没有 libass,也没有 drawtext ------subtitles 和 drawtext 都不在。两条路:
- 装完整版:
brew install ffmpeg-full(keg-only,依赖里含 libass/freetype/fontconfig),用/opt/homebrew/opt/ffmpeg-full/bin/ffmpeg -vf "subtitles=film.srt"; - 不装:用 PIL 把字幕渲成 544×960 透明 PNG,再
overlay进去。
本篇走第二条------不额外装东西,而且字幕的字体、字号、圆角底、安全区都能像素级控制。两条字幕:0.4--2.8s"先望着窗外的雨",3.1--6.0s"然后,回头看你";字号 30px(544 宽),底条落在画面高度 84.4% 的位置。
PIL 这层的几个参数值得写死:ImageFont.truetype(..., 30, index=2) 取 Hiragino Sans GB 的 W6 字重;textbbox 量出文字实际宽高,再各加 22/12px 内边距;圆角半径取高度一半,得到一个胶囊底;底色 (10,16,28,120),白字 alpha 240。字幕层直接渲成整幅 544×960 透明 PNG,而不是一张小图------overlay 时不用算偏移,也不会因为缩放糊掉。淡入淡出用 fade=t=in:st=0.4:d=0.2:alpha=1 这类写法,各 0.2s;显示窗口用 enable='between(t,0.4,2.8)' 控制。
为什么不用 SRT 软字幕:网页播放器对软字幕的支持参差不齐,手机端经常默认不显示,静音自动播放时更没人会去点字幕开关。要保证"谁看都一样",只能烧进画面。代价是字幕成了画面的一部分,改字就得重导------所以时间轴和文案要在渲染前定稿。
bash
ffmpeg -i base.mp4 -loop 1 -framerate 24 -t 6.042 -i sub.png \
-filter_complex "[1:v]format=rgba,\
fade=t=in:st=0.4:d=0.2:alpha=1,\
fade=t=out:st=2.6:d=0.2:alpha=1[s];\
[0:v][s]overlay=0:0:shortest=1:\
enable='between(t,0.4,2.8)',format=yuv420p[v]" \
-map "[v]" -c:v libx264 -crf 18 -pix_fmt yuv420p out.mp4
这里藏着一个 146 帧的坑:-loop 1 -t 6.042 的 PNG 输入实际有 146 帧(0 到 6.0417s),overlay 默认取最长输入,会把 145 帧的正片撑成 146 帧/6.083s。加 shortest=1,让 overlay 跟着正片结束------这一个参数,和第 11 篇丢帧不 reset PTS 是同一类问题:时间线不会自己对齐,得你告诉它按谁收口。
七、声音:BGM 两遍 loudnorm 到 -16 LUFS
素材没有音轨,BGM 从零混。选《玄穹》母带的 20--26.042s,淡入 0.4s、淡出 0.6s,再做两遍 loudnorm:
bash
# 第一遍:只测量,不输出
ffmpeg -ss 20 -t 6.042 -i xuankong-master-117s.flac -af \
"afade=t=in:st=0:d=0.4,afade=t=out:st=5.442:d=0.6,\
loudnorm=I=-16:TP=-1.5:LRA=11:print_format=json" -f null -
# 第二遍:把测得的 input_i/input_tp/input_lra/input_thresh/offset 填回去,linear=true
ffmpeg -ss 20 -t 6.042 -i xuankong-master-117s.flac -af \
"afade=t=in:st=0:d=0.4,afade=t=out:st=5.442:d=0.6,\
loudnorm=I=-16:TP=-1.5:LRA=11:\
measured_I=-23.55:measured_TP=-11.83:measured_LRA=0.40:\
measured_thresh=-33.55:offset=-0.05:linear=true" bgm.wav
实测:第一遍输入 -23.55 LUFS / -11.83 dBTP / LRA 0.40 ;第二遍线性归一后,成片整体 -15.9 LUFS / -4.3 dBTP / LRA 0.5。目标 -16,误差 0.1dB。
只跑一遍 loudnorm 也能出 -15.95 LUFS,但那是 dynamic 模式:它会在时间线上动态调增益,遇到峰值就压,听起来像有个看不见的推子在推音量。两遍的意义是把第一遍测到的 input_i/input_tp/input_lra/input_thresh/offset 喂回第二遍,linear=true 只做一次性线性增益。能不能线性,先算一笔账:-23.55 → -16 需要 +7.55dB,峰值 -11.83+7.55=-4.28dBTP,低于 -1.5 的天花板,所以线性可行;如果源峰值是 -5dBTP,+7.55 会顶到 +2.55,loudnorm 只能退回 dynamic,或者把目标降到 -18。
为什么是 -16:网页和移动端通用,给平台留一点余量;短视频平台常压到 -14,但 CSDN 没有强制口径,-16 更稳。BGM 是 -24 LUFS 的母带,直接混会太小声;两遍 loudnorm 是线性增益,不是压限,动态不会被压扁。
八、验收与脚本:让 ffprobe 说话
先把这一轮真正踩过、或一查就能定位的报错收进一张表:
| 症状 | 原因 | 处理 |
|---|---|---|
Unknown filter 'subtitles' |
ffmpeg 精简版没有 libass | 装 ffmpeg-full,或改 PIL 字幕层 |
| 成片 146 帧 / 6.083s | overlay 默认取最长输入 | 每个 overlay 加 shortest=1 |
| 接缝处 1/24s 停顿 | 丢帧后没有重置 PTS | setpts=PTS-STARTPTS |
| 网页要等整段下载完 | moov 在文件尾 |
-movflags +faststart |
| xfade 后总长变短 | 转场重叠被吃掉 | 先按 A+B−d 算总长,再决定要不要 |
| 手机外放听不清 | BGM 没做响度归一 | loudnorm 两遍到 -16 LUFS |
成片不能"看着没问题"就发。固定检查六项:
| 项 | 目标 | 实测 |
|---|---|---|
| 视频编码 | H.264 High / level 3.1 | ✅ |
| 像素格式 | yuv420p | ✅ |
| 分辨率/帧率 | 544×960 / 24fps | ✅ |
| 帧数/时长 | 145 帧 / 6.042s | ✅ |
| 音频 | AAC 44.1kHz 立体声 | ✅ |
| faststart | moov 在 mdat 前 |
moov@36 < mdat@6063 |
faststart 不用猜,读文件头就知道:
bash
python3 -c "b=open('成片.mp4','rb').read(65536); print(b.find(b'moov'), b.find(b'mdat'))"
把整条流水线固化成脚本,一条命令:
bash
python3 film12.py \
--clips F2_A2B.webm F3_B2A.webm --drop-first 1 \
--subs subs.json \
--bgm xuankong-master-117s.flac --bgm-start 20 \
--tag ai_tag_video.png \
--out 成片-第12篇-6s.mp4
脚本会自己体检、渲字幕、跑两遍 loudnorm、拼滤镜、编码,最后打印帧数/时长/编码/faststart。批量就是外面套一层循环------每条片子的素材、字幕、BGM、输出写进一个 JSON:
bash
for cfg in films/*.json; do
python3 film12.py --config "$cfg"
done
本轮用同一脚本跑了 6.04s 的接力片和 3.04s 的单锚片:前者 145 帧/654.8KB,后者 73 帧/427.3KB;两条串行,总墙钟 2.5 秒。脚本的价值不是把命令藏起来,是把"每次都要检查的东西"固化下来。
成片(6.04 秒,字幕 + BGM + AI 角标):
ffmpeg 流水线:6 秒猫片从素材到成片
九、下一步
视频篇到这里收尾:第 10 篇让照片动起来,第 11 篇让动作有终点、能接力,第 12 篇把素材送进交付线。下一步是声音------第 13 篇回到生成端,实测 Stable Audio 3 与 MiniMax Music3,看看 BGM 除了"找一段现成的",还能怎么自己造。
互动:你手里最想做成片的是哪段素材? 是 AI 生成的一段动作、实拍的一段空镜,还是几张照片?说清楚素材形态和想要的时长,我挑三条用这套流水线跑给你们看。
口径脚注:全部数字实测于 2026-10-29(mini:M5 Pro/64GB/macOS/ffmpeg 9.0.2 Homebrew core)。素材为第 11 篇 F2/F3(VP9、544×960、24fps、73 帧/3.042s、无音轨)。拼接对照 S1/S2/S3 只改"丢帧/重置 PTS";接缝对照只改接法(同锚/跨镜头/中途)。转码以无损 FFV1 母版为基准,PSNR/SSIM 为全片平均,样本为扁平插画、纹理少,实拍素材的拐点会更早。BGM 为《玄穹》母带 20--26.042s,loudnorm 两遍线性归一。成片 145 帧/6.042s,H.264 CRF23 / yuv420p / AAC 192k / faststart,-15.9 LUFS / -4.3 dBTP,右上角烧录"AI 生成"角标。字幕用 Hiragino Sans GB W6 30px 渲透明 PNG 后 overlay;本机 ffmpeg 无 libass,完整版路线见第六节。