从素材到成片:ffmpeg 拼接、转码与字幕,一条流水线交付

从素材到成片: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 都不在。两条路:

  1. 装完整版:brew install ffmpeg-full(keg-only,依赖里含 libass/freetype/fontconfig),用 /opt/homebrew/opt/ffmpeg-full/bin/ffmpeg -vf "subtitles=film.srt";
  2. 不装:用 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,完整版路线见第六节。

相关推荐
TomEval6 小时前
【测AI】第22篇:Agent 系统测试方法 — AI 学会“动手“了
人工智能·python·prompt·aigc·测试用例
Dawson Zhu7 小时前
【AI编译器入门⑥】端到端编译流程实战:ONNX → TVM → TensorRT全链路
人工智能·语言模型·架构·aigc·agi
向阳是我7 小时前
Mac 命令行获取 APK 公钥和证书 MD5 指纹
macos
福大大架构师每日一题8 小时前
ComfyUI v0.43.0发布:MOSS-Image、Fibo 2.0、Qwen 3.x、动态显存缓存与海量合作节点全面升级
comfyui
lpfasd12310 小时前
Ollama 还是 LM Studio:在 24GB 的 M6 Mac 上,我把 Ollama 卸载了
macos·策略模式
情 九10 小时前
MCP 开发环境搭建:Mac 与 Windows 跑通 Python 服务和 stdio 客户端
windows·python·macos·mcp
摘星编程11 小时前
Mac玩游戏选什么模拟器?MuMu Mac版体验:金铲铲165帧、明日方舟时停键,操作录制也上线了
macos·玩游戏·远程软件
扶尔魔ocy11 小时前
【QT+FFmpeg】知识点编目及传送门
qt·ffmpeg
海盗123411 小时前
微软技术日报 2026-10-29:Copilot harness 进 Foundry,云重建能拉镜像
人工智能·microsoft·机器人·aigc·copilot