FFmpeg视音频流封装避坑:容器、时间戳与码流格式实战
用 FFmpeg 做视音频流封装,最怕的不是命令背不下来,而是命令敲完、进度条跑完、文件也能打开,一播放却发现音画对不上、拖进度条直接黑屏、换个播放器就报错。这类问题九成出在封装层,跟编码质量没半点关系。我带过的几个做录播和直播回看的团队里,凡是出过"文件能播但不合规"事故的,最后追根溯源都落在容器格式、时间戳、码流格式这三件事上。这篇内容就是把我这些年做 FFmpeg 视音频流封装踩过的坑、验证过的参数组合、以及一套可以照着复现的操作流程整理出来,面向的是已经会用 FFmpeg 转码、但对封装环节还停留在"加个 -c copy 就完事"阶段的朋友,也适合做多媒体中间件、录播系统、监控回看、短视频处理的工程同学当作速查手册。
1. 封装这一步为什么最容易翻车
1.1 容器、编码、封装三者到底是什么关系
很多人把"格式"当成一个整体概念,实际上它至少拆成三层:编码格式决定每一帧画面用什么样的算法压缩,比如 H.264、H.265、AAC;容器格式决定这些压缩后的数据怎么组织成一个文件,比如 MP4、MKV、TS、FLV;封装就是把已经压好的数据按容器的规矩重新排列、打上时间戳、写好索引和元数据的过程。这三层可以自由组合,MP4 里能装 H.264 也能装 H.265,TS 里能装 H.264 也能装 MPEG-2,MKV 几乎什么都能装。理解了这一层,你就会明白为什么-c copy只能改容器、不能改画质——它压根不碰编码数据,只是把一个个数据包从旧盒子里搬到新盒子里,重新贴上标签。
这个"搬家"的动作听起来简单,麻烦在于每个容器对新家的规矩要求不一样。MP4 讲究索引,所有关键帧的位置要写在一张表里,表放在文件头还是文件尾,决定了这个文件能不能边下边播;TS 讲究流式,188 字节一个包,每个包都要带连续计数器,丢了包播放器立刻能发现;FLV 讲究轻量,标签头开销小,适合低延迟推流。你把 TS 的数据原样搬进 MP4,如果不做码流格式转换,MP4 里就会出现一堆播放器不认识的东西,表现就是画面黑屏、只有声音。
再往深一层,封装还承担了音视频同步的责任。视频帧和音频帧各自有 PTS(显示时间戳)和 DTS(解码时间戳),封装器要做的是把这些时间戳按容器的时基重新换算,并且在文件里交错排列好,让播放器读一小段就能同时拿到视频和音频,不至于先读完整个视频轨再读音频轨。这一步做不好,就是开头说的音画不同步。
1.2 封装失败的四类典型现场
我把见过的封装问题归成四类,基本能覆盖日常九成以上的故障。第一类是"能播但不合规",文件在本地播放器里正常,传到网页端或者移动端就起不来,典型是 MP4 的 moov 索引在文件尾部,浏览器要下完整个文件才能开始播。第二类是"时间戳错乱",表现为进度条长度不对、拖动后画面卡住、音画差几百毫秒,根因通常是源流起始时间戳不是 0,或者封装时没有处理负的 DTS。第三类是"码流格式不匹配",TS 转 MP4 后视频黑屏、只有音频有声音,或者反过来 MP4 转 TS 后某些播放器解不出来,这是 H.264 的 Annex B 与 AVCC 两种封装格式没有转换。第四类是"参数不兼容",文件本身没坏,但目标平台不认,比如把 HEVC 装进 MP4 却没打对 tag,苹果设备直接拒绝播放。
这四类问题有个共同特点:FFmpeg 默认不会报错。它把活干完了,退出码是 0,日志里也没有 error 字样,你必须主动去验证产物才能发现。这就是封装比转码更需要"验收"的原因,转码出来画质糊了你一眼能看出来,封装出来的问题往往要等到上线才暴露。
注意:
-c copy的封装过程几乎不消耗 CPU,很多人拿它做批量处理,跑完几千个文件才去抽检,结果一批全是坏的。批量任务里必须内置校验环节,这个后面会给具体做法。
2. 动手前的准备与方案拆解
2.1 用 ffprobe 把输入流的家底摸清楚
封装前最该做的一件事,是把输入流的真实情况打印出来,而不是凭文件名猜。我见过太多人以为.ts就是 H.264 + AAC,结果里面是 H.265 + MP2,封装到 MP4 后一半播放器不认。摸家底用 ffprobe,一条命令把关键信息全打出来:
ffprobe -v error \ -show_entries stream=index,codec_name,codec_type,profile,level,time_base,\ r_frame_rate,avg_frame_rate,start_time,duration,bit_rate,nb_frames,sample_rate,channels \ -show_entries format=format_name,duration,start_time,bit_rate,nb_streams \ -of json input.ts这条命令里几个字段值得重点盯。codec_name决定目标容器能不能装,比如 MP4 装不了 MP2 音频,就得转成 AAC 或者换 MKV。time_base决定时间戳换算的精度,TS 通常是 1/90000,MP4 视频轨常见 1/90000 或 1/1000,音频轨是 1/采样率。start_time是最容易被忽略的字段,很多从直播流里切出来的 TS,起始时间戳是几十万甚至上百万,封装到 MP4 后进度条就从几十万秒开始,播放器直接懵。r_frame_rate和avg_frame_rate不一致,说明源有变帧率,封装时就要考虑要不要固定帧率。
还有一个字段 ffprobe 默认不显示但极其关键:每个流的codec_tag_string。H.264 在 MP4 里应该是avc1,在 TS 里是[27](Annex B);HEVC 在 MP4 里应该是hvc1或者hev1,苹果设备只认前者。这个字段用-show_entries stream=codec_tag_string单独打出来看,能省掉大量"文件明明没问题为什么播不了"的扯皮。
2.2 容器选型:四个判断维度
目标容器选哪个,不是看哪个流行,而是看四个维度:播放端支持度、是否要边下边播、是否需要流式传输、后续处理链路。我把常见容器的取舍整理成一张表,实际项目里对着选就行。
| 容器 | 适用场景 | 优势 | 主要限制 |
|---|---|---|---|
| MP4 | 点播、网页播放、移动端分发 | 兼容性最好,索引结构成熟 | 必须 faststart 才能边下边播,对某些编码 tag 敏感 |
| MKV | 本地存档、多轨多字幕、中间文件 | 几乎什么都能装,容错强 | 网页和移动端原生支持弱 |
| MPEG-TS | 直播、HLS 切片、广播链路 | 流式友好,丢包可检测 | 体积开销大,不适合长期点播存储 |
| FLV | 低延迟推流、部分老旧播放器 | 封装开销小,延迟低 | 生态在收缩,不适合归档 |
| fMP4 | 现代 HLS/DASH 切片 | 切片可复用、支持 DRM | 生成流程比 TS 复杂,需要初始化段 |
选型时有个经验判断:只要产物最终要在浏览器里播,MP4 加 faststart 是默认答案;只要产物要进 CDN 做长链路分发,TS 或者 fMP4 更稳;只在内部做中间文件、后面还要再处理,MKV 最省心,因为它不会因为码流格式不对就报错。至于"既要又要"的情况,通常是走两条产物线,一份归档一份分发,别指望一个文件通吃。
2.3 时间基与时间戳模型的搭建
时间基是封装绕不开的坎。你可以把它理解成"时间戳的刻度单位":时基 1/90000 意味着每个时间戳单位是 1/90000 秒,一帧 25fps 的视频,相邻两帧的时间戳差 3600。FFmpeg 在封装时会做时基转换,把输入的 PTS 从源时基换算到目标容器的时基,换算过程取整会丢精度,源时基越小、换算次数越多,累积误差越大。这就是为什么跨容器多次转封装之后,音画会慢慢漂移。
控制这个问题的办法有两个。一是尽量减少转封装次数,能一次到位就别中转。二是在 MP4 输出时显式指定视频轨时基,让所有文件统一:
ffmpeg -i input.ts -map 0:v:0 -map 0:a:0 -c copy \ -video_track_timescale 90000 \ -movflags +faststart \ output.mp4-video_track_timescale 90000这一项在单文件场景看不出差别,但如果你后面要做拼接、要做多段合并,统一时基能避免大量"拼接后音画错位"的诡异问题。我做过一个模拟项目,把同一路摄像机的 200 段录像拼成一个大文件,前几十段都很正常,到后面越来越漂,最后发现就是各段时基不一致导致的累积误差。
时间戳模型还要处理起始点。封装到 MP4 时,理想状态是第一个视频帧的 PTS 为 0,音频也是从 0 开始。源流不是这样的时候,用-avoid_negative_ts make_zero让 FFmpeg 把负时间戳整体平移,或者用-copyts -start_at_zero保留原始时间戳但对齐到 0。这两组参数的区别后面会细讲,先记住一个原则:源起始时间戳不为 0 的时候,不要指望默认行为,一定显式处理。
3. 核心命令与关键参数逐条拆解
3.1 -c copy 加上 -map,先把"搬什么"定清楚
默认情况下 FFmpeg 会自动挑流:视频挑分辨率最高的,音频挑声道最多的,字幕挑第一个。这个"自动"在多流文件里经常挑错,比如一个 TS 里带了主码流、子码流、两路音频,自动挑的结果可能是子码流加第二路音频。所以封装命令里必须显式写-map:
ffmpeg -i input.ts \ -map 0:v:0 -map 0:a:0 \ -c copy \ -movflags +faststart \ output.mp4-map 0:v:0的含义是"取第 0 个输入文件的第 0 路视频",0:a:0同理。要全部音频就写-map 0:a,要排除某一路就用-map 0 -map -0:a:1的负向写法。多流场景里我习惯把所有需要的流都列出来,不用任何自动行为,这样命令本身就成了文档,别人接手一眼能看懂。
-c copy有两个容易搞错的点。第一,它不能跨流统一指定,-c copy是全局的,但如果你写了-c:v copy -c:a aac,就是视频复制、音频重编码,这个混用非常实用——源音频是 MP2 的时候,转成 AAC 只损失音频一点点质量,视频完全不动。第二,-c copy之后所有跟编码相关的参数全部失效,-b:v、-crf、-s、-r写了也不生效,想改分辨率改帧率就必须重编码,没有捷径。
实操心得:先用
ffmpeg -i input -map 0 -c copy -f null -跑一遍,这条命令不做任何输出,只做解封装和流复制,能在几秒内暴露源的损坏、时间戳跳变、码流格式错误。批量任务前跑这一遍,比事后排查省十倍时间。
3.2 时间戳相关的五个开关
封装参数里最容易记混的就是时间戳这一组,它们名字相近、作用交叉,我按使用优先级重新梳理一遍。
-fflags +genpts是给那些根本没带 PTS 的源用的,比如裸 H.264 ES 流、某些设备输出的畸形 TS。加上它,FFmpeg 会按照帧率推算时间戳。注意它只在解析阶段起作用,如果源本身时间戳齐全但错乱,它不会修正。
-avoid_negative_ts处理负时间戳。B 帧重排序会让 DTS 小于 PTS,某些源的 DTS 甚至为负,MP4 不允许负 DTS,所以需要挪。取值make_zero是把整体时间轴平移到 0 开始,make_non_negative只保证不为负,disabled是不处理、直接报错。默认是auto,行为取决于输出格式,我一般在 MP4 输出时显式写make_zero,避免不同版本行为差异。
-copyts是保留源时间戳不做任何调整,通常和-start_at_zero配对使用,后者在保留时间戳的同时把起始点归到 0。这两个参数在切片、拼接、对齐多个文件的时候非常有用,但普通单文件封装不要加,加了容易出现进度条从非零开始。
-fps_mode(老版本叫-vsync)控制帧率处理方式,取值passthrough、cfr、vfr、auto。纯复制封装时必须用passthrough,否则 FFmpeg 会按目标帧率插帧或者丢帧,虽然-c copy下它一般不真动手,但某些版本会在时间戳层面动手脚,导致封装后帧数对不上。
-muxdelay和-muxpreload是输出侧的缓冲参数,默认-muxdelay 0.7,意思是输出流在开始写之前先缓冲 0.7 秒。这个默认值对直播推流是好事,但对文件封装就是坏事了——它会让输出文件的start_time变成 1.4 秒之类莫名其妙的值。做文件封装时建议显式写-muxdelay 0 -muxpreload 0,让时间戳从干净的 0 开始。
3.3 码流过滤器:什么时候必须动 bsf
码流过滤器(bitstream filter,简称 bsf)是封装环节最容易被跳过、又最容易出问题的一环。它的作用是修改压缩数据本身的封装形态,注意不是改编码内容,而是改"同一种编码的两种打包方式"。H.264 就有两种打包方式:Annex B 用起始码00 00 00 01分隔 NALU,TS 和裸流用这种;AVCC 在 NALU 前面加长度前缀,MP4 用这种。两者编码数据一模一样,只是外壳不同,不做转换就会出现"数据在但解不出来"的黑屏。
好消息是新版 FFmpeg 在大多数跨容器场景会自动加对应的 bsf,坏消息是自动行为不可靠,尤其是版本混杂、或者中间经过了别的工具处理。所以我习惯显式写出来,写出来也不会有副作用:
# TS 转 MP4:AAC 的 ADTS 头要剥掉 ffmpeg -i input.ts -map 0:v:0 -map 0:a:0 -c copy \ -bsf:a aac_adtstoasc \ -movflags +faststart output.mp4 # MP4 转 TS:H.264 要加起始码,AAC 要加 ADTS 头 ffmpeg -i input.mp4 -map 0:v:0 -map 0:a:0 -c copy \ -bsf:v h264_mp4toannexb \ -bsf:a aac_adtstoasc_inverse \ -f mpegts output.ts第二段命令里的aac_adtstoasc_inverse在老版本里不存在,新版 FFmpeg 转 TS 时会自动处理 AAC,保险的做法是先不加,用 ffprobe 检查输出的codec_tag_string,不对再补。另外 HEVC 对应的过滤器是hevc_mp4toannexb,用 H.265 的时候别写成 H.264 那个,写错了 FFmpeg 会直接报错,这个倒是不会静默失败。
还有一个参数-tag:v hvc1,专门解决 HEVC 装进 MP4 后苹果设备不认的问题。FFmpeg 默认给 HEVC 打的 tag 是hev1,这个 tag 在某些播放器上表示"参数集可能变化",苹果设备直接拒绝。加上-tag:v hvc1就能正常播放,代价是要求参数集在文件头里固定下来,一般封装场景都满足。
3.4 交织缓冲与 -max_interleave_delta
封装器在写文件的时候要决定音视频包的交错顺序。理想情况是按 DTS 排序,读一小段就能拿到同一时间点的音视频。但现实里音频和视频的包大小差很多,音频包小且密集,视频包大且稀疏,如果严格按 DTS 排,会频繁在文件里来回跳着写,效率很低。FFmpeg 的做法是设一个缓冲窗口-max_interleave_delta,默认 10 秒,意思是只要两个流的包时间差在 10 秒以内就先攒着,超过 10 秒才强制切换。
这个默认值在普通视频里没问题,但在两种场景下会出事。一是纯音频文件混了很短的一段视频,视频流只有几帧,封装器可能一直在等音频,导致视频包写到了文件很后面,播放器读到视频时前面已经播完了。二是直播录制的长文件,音视频的时间戳如果本身就差了十几秒,缓冲窗口不够,封装出来的文件在中间某处会有一段明显错位。遇到这两种情况,把窗口调小或者直接设 0:
ffmpeg -i input.ts -map 0:v:0 -map 0:a:0 -c copy \ -max_interleave_delta 0 \ -movflags +faststart output.mp4设 0 的意思是严格按 DTS 排序、不做缓冲,写出来的文件流交织最规整,代价是写盘次数变多。对 SSD 或者内存盘来说这点开销可以忽略,对机械盘批量处理时建议还是保留一个较小的值,比如-max_interleave_delta 1000000(单位是微秒,即 1 秒)。这一项没有标准答案,我的做法是先用默认值跑,产物用 ffprobe 检查包序,发现交织异常再调。
4. 实战走一遍:四类典型封装场景
4.1 场景一:裸 ES 流封成 MP4
裸流是最原始的形态,从某些编码芯片、老式采集卡里直接出来的就是.h264、.h265、.aac这样的单流文件,没有容器、没有时间戳、没有帧率信息。封装的难点在于 FFmpeg 得靠猜来建立时间轴,猜错了整个文件就废了。
先看一个典型的裸 H.264 封装命令:
ffmpeg -fflags +genpts \ -r 25 -i video.h264 \ -c copy \ -video_track_timescale 90000 \ -avoid_negative_ts make_zero \ -movflags +faststart \ output.mp4关键在于-r 25放在-i前面。这个位置的-r是输入帧率声明,告诉 FFmpeg"这个裸流按 25fps 理解",它会据此生成时间戳。如果放到-i后面,就变成了输出帧率,在-c copy下不生效,时间戳还是乱的。这个位置差异坑过很多人,务必记牢:输入侧的参数一律放-i前。
裸 AAC 封装成 M4A 的逻辑一样,但它没有帧率概念,靠的是采样率:
ffmpeg -fflags +genpts -f aac -i audio.aac -c copy \ -movflags +faststart output.m4a-f aac是必须的,裸 AAC 文件没有扩展名识别依据的时候 FFmpeg 会识别失败。封装完用 ffprobe 检查duration字段,如果算出来的时长和实际听感差很多,多半是采样率识别错了,重新用-ar指定输入采样率再来一遍。
裸流场景有个常见陷阱:视频和音频分别来自两个文件,要合到一个 MP4 里。这时候两个文件的时间轴是各自独立的,直接合会从 0 对 0,但实际录制时可能差了半秒。正确做法是先确认两个流的真实起始关系,用-itsoffset做偏移:
ffmpeg -fflags +genpts -r 25 -i video.h264 \ -itsoffset 0.32 -f aac -i audio.aac \ -map 0:v:0 -map 1:a:0 -c copy \ -movflags +faststart output.mp4-itsoffset 0.32表示音频整体往后推 0.32 秒。这个值怎么来的?靠人工对齐:找一个有明显声音特征的画面(比如拍手、关门),在音频波形里找到对应位置,两者的时间差就是偏移量。模拟项目里我处理过一批双设备录制的素材,音视频分别独立录制,偏移量从 0.1 秒到 1.2 秒不等,每一个都得单独对,没有通用值。
4.2 场景二:TS 转 MP4 并做 faststart
这是最常见的一类需求,监控回看、直播录制转点播,基本都是 TS 进 MP4 出。完整命令在前面出现过,这里把细节补齐:
ffmpeg -v warning -stats \ -i input.ts \ -map 0:v:0 -map 0:a:0 \ -c copy \ -bsf:a aac_adtstoasc \ -video_track_timescale 90000 \ -avoid_negative_ts make_zero \ -muxdelay 0 -muxpreload 0 \ -max_interleave_delta 1000000 \ -movflags +faststart \ output.mp4-movflags +faststart是这条命令的核心。MP4 的索引(moov box)默认写在文件尾部,因为封装时不知道每个关键帧的最终偏移,只能等所有数据写完再回填。faststart 的作用是在封装结束后做一次文件内重排,把 moov 挪到文件头。这个动作需要二次读写整个文件,所以:输出到管道、输出到不可寻址的流、输出到标准输出的时候,faststart 全部无效。批量处理大文件时要留意磁盘 IO,一个 4GB 的文件重排意味着额外 4GB 的读写。
还有一个隐形成本:faststart 会让处理时间翻倍左右。如果目标平台不要求边下边播(比如内部归档、离线审核),完全可以不加,省一半时间。我一般按用途分两套流程,分发用的加,归档用的不加。
TS 转 MP4 最容易出的问题是时长不对。表现是 ffprobe 显示的 duration 比实际短,播放到后面直接结束。根因通常是 TS 的最后一个包不完整,或者 PCR 和 PTS 的关系异常。验证办法是对比两边:
ffprobe -v error -show_entries format=duration -of csv=p=0 input.ts ffprobe -v error -show_entries format=duration -of csv=p=0 output.mp4两个值差在 0.5 秒以内算正常,差得多就要查。处理办法是加-ignore_unknown忽略未知流,或者用-fflags +igndts忽略异常的 DTS 只用 PTS。后者要谨慎,加了之后 B 帧的显示顺序可能乱,只在确认源 DTS 全错的情况下用。
4.3 场景三:MP4 转 TS 与 HLS 切片
反向转换的需求主要来自直播分发。MP4 是点播格式,要做直播或者 HLS,就得转成 TS 或者 fMP4 切片。单文件转 TS 的命令不复杂:
ffmpeg -i input.mp4 -map 0:v:0 -map 0:a:0 -c copy \ -bsf:v h264_mp4toannexb \ -muxdelay 0 -muxpreload 0 \ -f mpegts output.ts真正有意思的是切片。HLS 的规矩是每个切片必须从关键帧开始,否则播放器切换码率或者跳转时会花屏。源文件的关键帧间隔如果不均匀,直接切就会切在非关键帧上。解决办法是重编码时强制关键帧位置,或者复制封装时用-forced-idr配合检查。
重编码切片的标准命令:
ffmpeg -i input.mp4 \ -c:v libx264 -preset medium -crf 20 \ -g 100 -keyint_min 100 -sc_threshold 0 \ -force_key_frames "expr:gte(t,n_forced*4)" \ -c:a aac -b:a 128k -ar 48000 \ -f hls -hls_time 4 -hls_playlist_type vod \ -hls_segment_filename "seg_%05d.ts" \ -hls_flags independent_segments \ playlist.m3u8参数之间的对应关系要算清楚。假设源是 25fps,切片 4 秒,那么每个切片 100 帧,所以-g 100(GOP 长度)和-hls_time 4必须对齐。如果 GOP 是 100 但 hls_time 写 3,切出来每片 75 帧,落不到关键帧上,FFmpeg 会自己往前找最近的关键帧,结果就是切片时长忽长忽短,播放器卡顿。-sc_threshold 0是关掉场景切换自动插入关键帧,保证 GOP 严格等长,这一项在切片场景里几乎是必须的。-force_key_frames是双保险,即使编码器因为别的原因没在预期位置插关键帧,这个表达式也会强制插。
纯复制做切片也可以,前提是源的关键帧间隔本身规整:
ffmpeg -i input.mp4 -c copy -f hls -hls_time 4 \ -hls_playlist_type vod \ -hls_segment_filename "seg_%05d.ts" playlist.m3u8复制切片的优势是秒出,劣势是完全受制于源。如果源的关键帧间隔是 2 秒,那切出来就是 2 秒一片,-hls_time 4会被无视。判断办法是先查关键帧分布:
ffprobe -v error -select_streams v:0 \ -show_entries packet=pts_time,flags -of csv=p=0 input.mp4 \ | grep "K_" | head -20输出里带K标志的就是关键帧,看相邻两个的时间差就知道间隔是否规整。
4.4 场景四:批量脚本与产物校验
单条命令跑通只是开始,真实项目都是批量。批量处理的核心不是循环调用,而是"失败可发现、可重试、可回滚"。我用的脚本结构大致是这样:
#!/usr/bin/env bash set -uo pipefail INPUT_DIR="./input" OUTPUT_DIR="./output" LOG="./mux.log" mkdir -p "$OUTPUT_DIR" ok=0; fail=0 for f in "$INPUT_DIR"/*.ts; do base=$(basename "$f" .ts) out="$OUTPUT_DIR/$base.mp4" # 第一步:健康检查,不产生产物 if ! ffmpeg -v error -i "$f" -map 0 -c copy -f null - 2>>"$LOG"; then echo "SKIP damaged: $f" | tee -a "$LOG" fail=$((fail+1)); continue fi # 第二步:封装 if ! ffmpeg -v error -i "$f" -map 0:v:0 -map 0:a:0? -c copy \ -bsf:a aac_adtstoasc \ -avoid_negative_ts make_zero \ -muxdelay 0 -muxpreload 0 \ -movflags +faststart "$out" 2>>"$LOG"; then echo "FAIL mux: $f" | tee -a "$LOG" fail=$((fail+1)); continue fi # 第三步:产物校验 dur_in=$(ffprobe -v error -show_entries format=duration -of csv=p=0 "$f") dur_out=$(ffprobe -v error -show_entries format=duration -of csv=p=0 "$out") delta=$(awk -v a="$dur_in" -v b="$dur_out" 'BEGIN{print (a>b?a-b:b-a)}') if awk -v d="$delta" 'BEGIN{exit !(d>1.0)}'; then echo "WARN duration drift ${delta}s: $f" | tee -a "$LOG" fail=$((fail+1)); continue fi ok=$((ok+1)) done echo "done ok=$ok fail=$fail"脚本里有几个设计点值得说明。-map 0:a:0?末尾的问号表示"如果有音频就映射,没有就跳过",不加问号的话,纯视频源会直接报错退出。set -uo pipefail让脚本在变量未定义和管道失败时立即暴露问题,但这不包含set -e,因为我们需要循环继续跑完。第三步的时长比对设 1.0 秒阈值,太严会误报,太松会漏掉真正的问题,这个值是我测了几百个文件之后定下来的。
批量任务还要注意磁盘和并发。-movflags +faststart会二次读写文件,10 个并发就相当于 20 个文件的 IO,机械盘上很容易拖垮。我的做法是封装阶段限制并发数为 CPU 核数的一半,faststart 单独放到一个串行队列里,或者干脆分两批跑。
5. 常见问题与排查技巧实录
5.1 现象与根因对照表
封装问题的排查效率,取决于你能不能从现象直接跳到根因。我把这些年积累的对应关系整理成表,遇到问题先在这里找一眼。
| 现象 | 最可能的原因 | 验证方式 | 处理办法 |
|---|---|---|---|
| 网页播放器要下完整个文件才开始播 | moov 在文件尾 | ffprobe看文件头前 1KB 是否含 moov | 加-movflags +faststart |
| 进度条时长比实际短,播到后面中断 | TS 尾部包不完整或 PCR 异常 | 对比输入输出format=duration | 加-fflags +igndts,或截断尾部重封 |
| 只有声音没画面 | H.264 仍是 Annex B 未转 AVCC | ffprobe看codec_tag_string是否为[27] | 补-bsf:v h264_mp4toannexb反向操作 |
| 苹果设备不播 HEVC 文件 | tag 是hev1不是hvc1 | ffprobe看codec_tag_string | 加-tag:v hvc1 |
| 音画差几百毫秒且固定 | 源起始时间戳不同,未对齐 | 分别看两个流的start_time | 用-itsoffset补偿 |
| 音画越播越不同步 | 时基不一致,多次转封装累积误差 | 看两流的time_base和总帧数 | 统一-video_track_timescale,减少中转次数 |
| 拖进度条后黑屏或卡住 | 索引里记录的关键帧位置失效 | ffprobe查 packet 的flags | 重新封装,或重编码固定 GOP |
| 封装后文件体积暴涨 | 误加了重编码参数 | 对比输入输出bit_rate | 检查命令里有没有-c:v覆盖 copy |
| 多流文件只出了一路 | 自动选流挑错 | ffprobe看输入流数量 | 显式-map |
| 切片时长忽长忽短 | GOP 与 hls_time 不对齐 | 查关键帧间隔 | -g与-hls_time按帧率换算对齐 |
这张表里,前四条占了实际故障的大半。尤其是第一条,很多人封装完在本地 VLC 里播得好好的,传到网页端就不行,以为是编码问题,其实是索引位置问题。
5.2 一套固定的排查动作
遇到封装后的文件有问题,我有一套固定动作,按顺序走,八成的问题在第三步之前就能定位。
第一步,看容器层。ffprobe -v error -show_format -show_streams output.mp4,重点看format_name对不对、流数量对不对、每个流的codec_name和codec_tag_string是否符合预期。这一步能抓出选流错误和 tag 错误。
第二步,看时间轴。打前 30 个包的时间戳:
ffprobe -v error -select_streams v:0 \ -show_entries packet=pts_time,dts_time,duration_time,flags \ -of csv=p=0 output.mp4 | head -30正常情况下 pts 递增、dts 递增、相邻差值约等于 1/帧率。如果出现负数、跳变、或者 dts 大于 pts(非 B 帧场景),就是时间戳处理出了问题。
第三步,看解码。用-f null做一次完整解码,不解封装只解编码,能抓出码流层面的损坏:
ffmpeg -v error -i output.mp4 -f null - 2>&1 | head -20有输出就说明解码器在报错,没有输出说明码流本身是干净的,问题在容器层或者播放器兼容性。
第四步,换播放器交叉验证。同一个文件在三个以上播放器里试,如果只有一个播不了,大概率是那个播放器的兼容性问题,不是文件坏了。这一步能避免大量无效排查。
5.3 我个人踩过的坑
说几个具体案例,都是当时排查了很久、事后觉得特别蠢的问题。
第一个是负 DTS。有一批从采集设备出来的 TS,封装成 MP4 后前 3 秒画面卡住不动,之后才正常。查了很久以为是关键帧问题,最后打时间戳发现前几帧的 DTS 是负的,MP4 不允许,FFmpeg 把它们裁掉了,但 PTS 没同步调整,导致前几帧的显示时间落到了文件开头之前。加-avoid_negative_ts make_zero之后彻底解决。这个参数现在是我所有 MP4 封装命令的标配。
第二个是 faststart 和管道的冲突。有个流程是把封装结果直接通过管道传给下一个处理环节,为了省一次落盘。结果传过去的文件在网页端播不了,本地播正常。查了半天才想起来 faststart 需要可寻址输出,管道不可寻址,这个 flag 被静默忽略了。后来改成先落临时文件再加 faststart,然后读文件往下传。这个坑的教训是:任何"优化"都要验证产物,不能因为命令没报错就认为生效了。
第三个是时基漂移。模拟项目里做过一个多段录像合并的需求,200 段各 10 分钟的 TS,逐段转 MP4 再拼接。前 50 段完全正常,从第 60 段开始音画差 100 毫秒,到第 150 段差了近 1 秒。原因是每段 TS 的时基略有差异,转 MP4 时各自取整,误差累积。解决办法是所有段统一-video_track_timescale 90000,并且在拼接前用-copyts保留原始时间戳,让拼接器自己处理偏移。改完之后 200 段合并的误差在 20 毫秒以内。
第四个比较隐蔽,是 AAC 的编码延迟。某些设备录制的 AAC 音频,第一帧前面有 1024 个采样的 priming 数据,封装到 MP4 后如果不写 edit list,播放器会把这 1024 个采样当正常音频播出来,听起来就是音频比视频早了 20 多毫秒。这个差异人耳勉强能察觉,在对口型要求高的场景(比如课程录制)就很明显。处理办法是让 FFmpeg 自己写 edit list,不要手动加-movflags +omit_tfhd_offset之类的裁剪参数,默认行为反而是对的。
6. 几个值得记住的经验判断
封装这件事,参数多、组合多,但真正需要记的判断没那么多。我自己的经验是:只要产物要在浏览器里播,MP4 加 faststart 就是默认答案,别纠结;只要涉及跨容器,就先想 bsf 有没有配对,H.264 的 Annex B 与 AVCC、AAC 的 ADTS 与 ASC 这两组转换覆盖了绝大多数黑屏和无声问题;只要源的时间戳不干净,就先上-avoid_negative_ts make_zero加-muxdelay 0 -muxpreload 0,这三个参数一起用几乎没有副作用,却能消掉一大半的时长和同步异常。
还有一点是关于验证的。封装命令跑完不等于活干完了,我现在的习惯是每个产物至少过三道检查:ffprobe 看流和时长、-f null走一遍解码、在目标播放环境里实际播一次。前两道是自动化能做的,第三道必须人工抽检。批量任务里我会按 5% 的比例抽,抽到问题就全量回查。这套流程听起来啰嗦,但比上线之后被用户反馈"视频打不开"要省事得多。
最后分享一个小技巧:封装命令写复杂之后,把它固化成脚本模板,参数用变量传,不要每次手敲。我自己的模板里,容器类型、是否 faststart、是否保留原始时间戳、音频是否重编码,这四个都是开关,其余全部固定。这样既避免了手误,也让团队里其他人接手时不用重新理解一遍参数含义。模板化之后,我们处理同类素材的平均耗时从十几分钟压到了两分钟以内,出错率也降下来了。