FFmpeg封装与转码:流拷贝、时间戳与HLS切片实践

发布时间:2026/10/28 4:41:44
FFmpeg封装与转码:流拷贝、时间戳与HLS切片实践

做音视频相关的工作,被问得最多的一类问题就是:我只是想把 MP4 换个容器、把音轨重新挑一条、把文件切成分片去做点播,为什么一动手就是十几分钟的转码,画质还肉眼可见掉了一档?答案往往很简单——绝大多数人把“封装”和“转码”当成了同一件事。FFmpeg 这套多媒体处理框架里,视音频流封装是独立于编码之外的一层能力,它只负责把已经压缩好的码流按某种容器规则重新组织,不碰像素、不碰采样点。用对了,一个几 GB 的文件换容器通常只需要几十秒,CPU 占用低到可以忽略;用错了,就是白白烧电还掉画质。

这篇内容面向的读者范围比较宽:刚接触 FFmpeg、只会复制粘贴命令的同学,可以从容器与编码的区别、-c copy的语义开始跟;已经能跑通基础命令、但在多轨道、时间戳、HLS 切片上反复踩坑的朋友,可以直接跳到参数计算和问题排查那几节。我会把封装这条链路上的关键概念、命令写法、参数背后的算术、以及用 libavformat 写封装器时的骨架代码全部拆开讲,中间穿插一些常规文档里不会写的教训。所有结论都来自命令行和代码两条路径上的反复实测,涉及具体数值的地方我会把计算过程摆出来,方便你按自己的场景替换。

1. 先把封装这件事讲透:容器、编码、封装到底谁管谁

1.1 容器格式与编码格式的分工边界

很多人对 FFmpeg 的第一层误解,是以为.mp4、.mkv、.ts这些后缀代表“视频格式”。准确说法是容器格式(container format,在 FFmpeg 里对应 muxer/demuxer),它管的是“怎么把若干条码流、时间戳、元数据、索引表组织进一个文件”;真正决定画面和声音怎么压缩的是编码格式(codec),比如视频侧的 H.264、H.265、AV1,音频侧的 AAC、Opus、MP3。

打个装箱的比方:编码相当于把货物真空压缩、抽掉空气,决定体积能压到多小;容器相当于集装箱的规格和装箱清单,决定这批货怎么码放、清单写在箱门内侧还是箱底、中途能不能开箱取某一件。同一批压缩好的货,可以装进标准箱(MP4),也可以装进散货船(MKV),货本身一点没变。这就是-c copy的物理含义:解复用(demux)→ 复用(mux),中间没有解码器也没有编码器参与。

理解了这个分层,很多困惑会自动消失。比如“为什么 MKV 转 MP4 之后文件小了”——大概率不是画质变了,而是 MKV 里塞了封面图、章节信息、多条字幕轨,MP4 装不下或者被默认丢弃了;“为什么 FLV 里放不了 H.265”——不是 FFmpeg 不行,是 FLV 这个容器的规范里就没给 H.265 留位置。容器对编码是有兼容性白名单的,这是硬约束,命令写得再花也绕不过去。

我把日常会碰到的几种容器做个横向对照,这张表建议存下来,选容器的时候直接查:

容器典型编码支持索引/元数据特点适合场景主要限制
MP4H.264/H.265/AV1 + AAC/MP3moov 索引可前置,支持 faststart点播、移动端、Web对部分编码和字幕格式挑剔,中断录制易损坏
MKV几乎不挑,H.265/AV1/Opus/FLAC/ASS 全收EBML 结构,章节、多字幕友好本地归档、多轨素材浏览器原生支持差,部分播放器兼容一般
TSH.264/H.265 + AAC/MP3/AC-3定长 188 字节包,自带 PAT/PMT直播、HLS 分片、录制流体积开销大,无全局索引,拖动定位差
FLVH.264 + AAC/MP3标签式结构,头部极简低延迟直播推流编码白名单窄,H.265 基本无缘
MOV与 MP4 同源,扩展更多支持时间码轨、ProRes后期剪辑中间格式通用播放器兼容性不如 MP4
WebMVP8/VP9/AV1 + Vorbis/Opus基于 MKV 的子集Web 端免插件播放编码组合受限,转封装前要确认编码

表格里最值得强调的一行是 TS。它的 188 字节定长包设计是为了抗丢包和随机接入,代价就是包头开销。一个 188 字节的包里固定有 4 字节包头,再加上周期性插入的 PAT/PMT 表,实际有效载荷比例通常在 92% 到 95% 之间。这就是为什么同一个片源从 MP4 转到 TS,文件会莫名其妙大出 5% 到 8%,这不是编码器变差了,是集装箱的隔板变厚了。

1.2 一次封装的完整链路与时间戳的核心地位

把封装链路画清楚,后面所有的坑都能对号入座。以命令行ffmpeg -i in.mp4 -c copy out.mkv为例,内部大致走这么几步:打开输入 → 读取容器头,解析出若干条 stream,每条 stream 带一份AVCodecParameters(编码类型、分辨率、采样率、extradata 等)→ 循环av_read_frame拿到一个个 packet,packet 上挂着pts、dts、duration、stream_index→ 把 packet 交给输出侧的 muxer,按输出容器的时间基重新换算时间戳 →av_interleaved_write_frame写盘 → 结束时av_write_trailer回填索引。

这条链路里,唯一容易出问题的就是时间戳换算。容器换一种,时间基(time_base)往往跟着换。MP4 的视频轨常见时间基是 1/15360 或 1/90000,TS 是 1/90000,MKV 干脆用毫秒即 1/1000。换算公式很朴素:

目标时间戳 = 源时间戳 × 源 time_base / 目标 time_base

但朴素公式架不住源文件的坑。真实素材里经常遇到start_time不为 0 的情况,尤其从直播录制下来的 TS、手机拍摄的 MOV、以及被剪辑软件二次导出过的文件。如果源视频轨从 1.4 秒开始、音频轨从 0 秒开始,直接流拷贝封进 MP4,播放器按各自的第一帧对齐,观感就是声音早了一秒多。这类问题不会报错,只会让你的用户觉得“这视频怪怪的”,排查起来极其费神。

还有一层是 PTS 与 DTS 的区别。PTS 是呈现时间,DTS 是解码时间。B 帧存在的时候,解码顺序和显示顺序不一致,DTS 会小于 PTS。流拷贝时这两个值原样搬运,但不同容器对“DTS 必须单调递增”的严格程度不一样。MP4 和 FLV 的 muxer 相对严格,遇到时间戳回退会直接甩出Non-monotonous DTS in output stream的警告,继续写下去可能得到一个音画错乱的文件。这就是后面排查章节要重点处理的一类问题。

提示:判断一个文件能不能安全地做流拷贝,先跑一遍ffprobe看每条流的codec_name、start_time、time_base三个字段。这三个值决定了你后面会不会遇到偏移、抖动和写入拒绝。

2. 环境与工具准备:别急着敲第一条命令

2.1 FFmpeg 构建版本的选择与能力核验

FFmpeg 的版本差异比很多人想象的大。同样一条-c copy命令在 4.x 和 6.x 上行为基本一致,但涉及-bsf比特流过滤器、HLS 的默认分片行为、以及某些容器的 codec_tag 自动推断时,差别就出来了。我踩过最典型的一次是 AAC 从 TS 转 MP4 时,老版本必须显式加-bsf:a aac_adtstoasc才能把 ADTS 头剥掉,新版本 muxer 会自动处理,如果你习惯性加上反而在某些输入下报冲突。

第一步永远是核验:

ffmpeg -version ffmpeg -hide_banner -muxers | grep -E "mp4|matroska|mpegts|flv" ffmpeg -hide_banner -encoders | grep -E "libx264|libx265|aac"

第一条看版本和编译配置,重点确认有没有--enable-libx264、--enable-libx265、--enable-gpl这类标记——如果后面某天你需要临时转一段码,缺了这些库就只能用内置的mpeg4编码器,画质会让你怀疑人生。第二条确认 muxer 存在,正常发行版都会全量编进去,但自己裁过的定制构建有可能砍掉mpegts。第三条看编码器,虽然封装本身不需要编码器,但排查问题时经常要顺手转一下码做对照实验。

有个细节值得说:ffmpeg -formats的输出里,D表示支持解复用,E表示支持复用,DE才是双向都行。某些容器只读不写,比如一些冷门的专业格式,写的时候会直接告诉你 “Requested output format is not a suitable output format”。提前确认能省下十分钟的困惑。

至于用哪个版本,我的建议是跟随主流发行版或官方静态构建的次新版本,不必追最新,也别用五六年前的老古董。新版本对时间戳异常的容忍策略更成熟,很多过去要手动加-fflags +genpts的场景现在自动就处理了。

2.2 封装前必做的媒体信息体检

封装是一个“搬运”动作,搬之前得先知道箱子里有什么。ffprobe就是那个开箱检查的工具,我习惯用 JSON 输出,方便肉眼扫也方便脚本解析:

ffprobe -v error -show_format -show_streams -of json input.mp4

输出里我会重点盯这几项:

  • format.start_time和format.duration:整体起点和时长,起点不为 0 就要警觉。
  • 每条 stream 的codec_name、codec_type:确认视频到底是 h264 还是 hevc,音频是 aac 还是 ac3。
  • width、height、pix_fmt:像素格式如果是yuv444p或yuv422p,某些硬解播放器和移动端会有兼容问题,虽然封装不改它,但你得知道这是隐患。
  • r_frame_rate与avg_frame_rate:两者不一致说明是可变帧率(VFR),VFR 素材封装后容易出现时间戳抖动。
  • sample_rate、channels、channel_layout:音频参数,涉及后面能不能直接塞进目标容器。
  • bit_rate:码率,用来估体积。
  • nb_frames和TAG:...:帧数与元数据标签,顺手看看有没有封面、语言标记。

一趟体检做完,能不能流拷贝基本就有答案了。判断逻辑很简单:目标容器的编码白名单里是否包含源文件的每条流的编码。都包含,放心-c copy;有一条不包含,就必须对那条流单独转码,其他流依然可以拷贝,这就是常说的“混合模式”。

举个真实场景。某监控项目导出的录像里有三条流:H.264 视频、PCM 音频、以及一条私有的数据流。想封进 MP4 给业务系统播放,PCM 在 MP4 里兼容性差,那条数据流 MP4 根本不认。正确做法是视频流拷贝、音频流转 AAC、数据流丢弃:

ffmpeg -i camera.mp4 \ -map 0:v:0 -map 0:a:0 \ -c:v copy -c:a aac -b:a 128k \ -movflags +faststart \ output.mp4

注意-map里我明确写了0:v:0和0:a:0,没有用默认的自动选择。自动选择在多轨道文件上经常选错,尤其是带封面图的音频文件,FFmpeg 可能把封面当成视频流选走。这个习惯建议从一开始就养成:只要文件里轨道数大于 2,就手动 map。

注意:-map 0表示全选,配合-map -0:d可以排除数据流。但更稳妥的写法是正向列举你要的轨道,而不是全选再减,因为“全选”在新版本里会连带把一些隐藏的元数据轨也拉进来。

3. 命令行封装实战:从零拷贝到参数精调

3.1 最核心的一条命令:流拷贝换容器

所有封装命令的母版就是这一条:

ffmpeg -i input.mp4 -c copy output.mkv

-c copy等价于-c:v copy -c:a copy -c:s copy,即所有流都不重新编码。执行速度取决于磁盘读写,一个 2 GB 的文件在普通 SSD 上通常十秒内完成,CPU 占用常年低于 5%。这个命令我一年要用上千次。

但母版不能无脑套。有几个补充参数属于“默认就该带上”的级别:

ffmpeg -hide_banner -y -i input.mp4 \ -map 0 -c copy \ -avoid_negative_ts make_zero \ output.mkv

-hide_banner只是让输出干净点,不参与逻辑。-y覆盖输出,脚本里必带,交互式使用时看情况。-avoid_negative_ts make_zero是关键补丁:当源文件存在负时间戳(B 帧导致的常见现象),muxer 在写入时可能拒绝或者产生偏移,这个参数让 FFmpeg 统一把时间轴平移到 0 起点。加不加的实际差别,在一个有 40 毫秒负 DTS 的素材上非常明显——不加的话某些播放器开头会有轻微卡顿或者首帧黑屏。

还有一个我强烈建议在批量脚本里加上的:

-max_muxing_queue_size 4096

默认队列在某些码率极高、关键帧极稀疏的素材上会不够用,报Too many packets buffered for output stream。默认值 128 对普通点播够用,对 4K 高码率或者带 B 帧的 HEVC 就有点紧。调到 4096 内存代价可以忽略,能直接消灭一类偶发报错。

3.2 轨道映射与多音轨、字幕的取舍

多轨道文件是封装环节最容易翻车的地方,因为 FFmpeg 的默认轨道选择逻辑对普通用户来说不够直观:视频选分辨率最高的,音频选声道数最多的,字幕选第一个匹配的。这个策略在大部分情况下合理,但在素材文件上经常不是你想要的那条。

显式映射的完整语法是-map 输入序号:流类型:流索引,索引从 0 开始。几个常用写法:

# 只保留第一条视频和第二条音频 ffmpeg -i in.mkv -map 0:v:0 -map 0:a:1 -c copy out.mp4 # 全部保留,但给音频打上语言标记 ffmpeg -i in.mkv -map 0 \ -c copy \ -metadata:s:a:0 language=chi \ -metadata:s:a:1 language=eng \ -metadata:s:a:0 title="国语" \ out.mkv # 字幕单独处理:MKV 存 ASS,MP4 只能用 mov_text ffmpeg -i in.mkv -map 0:v -map 0:a -map 0:s:0 \ -c:v copy -c:a copy -c:s mov_text out.mp4

字幕这块有个硬约束必须记住:MP4 只支持mov_text(也叫 tx3g)这种位图式之外的简单文本字幕,ASS/SSA 的特效、定位、字体全部会丢失,只保留文字和时间轴。如果你需要保留 ASS 的完整样式,容器只能选 MKV。强行把 ASS 塞进 MP4,FFmpeg 会提示不支持的编码,或者你加了-c:s mov_text之后得到一个样式全无的字幕轨。

音频轨道还有一层是“默认轨”标记。MP4 和 MKV 都支持disposition,用来告诉播放器优先播哪条:

ffmpeg -i in.mkv -map 0 -c copy \ -disposition:a:0 default -disposition:a:1 0 \ out.mp4

不加这个,多音轨文件在部分播放器上会默认选第一条,哪怕那是一条导演评论音轨。这个细节在交付给终端用户的场景里非常影响体验,值得花三十秒补上。

3.3 面向 Web 播放的 MP4 优化与 faststart

MP4 的索引表叫 moov,默认写在文件末尾。这意味着浏览器要先下载完整个文件(或者发 Range 请求跳到尾部)才能拿到索引,才能开始播放。对于几百 MB 的点播文件,用户感知就是“点了播放要转好久”。

-movflags +faststart做的事情是在写完之后把 moov 挪到文件头。注意它是“二次写”,会多一次磁盘 IO,所以大文件上耗时会略增,但这是值得的:

ffmpeg -i in.mp4 -map 0 -c copy -movflags +faststart web.mp4

判断是否生效有个土办法:用十六进制查看器看文件前几百字节,能搜到moov就在头部。或者更省事:

ffprobe -v trace -i web.mp4 2>&1 | grep -m1 -o "type:'moov'"

流媒体场景还有另一种选择,分片 MP4(fMP4):

ffmpeg -i in.mp4 -map 0 -c copy \ -movflags +frag_keyframe+empty_moov+default_base_moof \ out_frag.mp4

这种结构把文件切成一个个 moof+mdat 的小单元,每个关键帧处可独立开始播放,天生适合边下边播和低延迟场景。代价是兼容性略差,一些老播放器和部分编辑软件对它支持不好。我的经验是:给 Web 点播用 faststart 的普通 MP4,给直播录制或者需要快速起播的场景用 fMP4,别混着用。

提示:+faststart和+frag_keyframe不要同时加。两个 flag 的作用对象冲突,同时写的行为在不同版本上不一致,容易得到一个索引位置诡异的文件。

3.4 HLS 切片封装与 TS 流转封装的细节

HLS 本质上是“把一条流切成若干个小 TS 分片,再配一个 m3u8 播放列表”。流拷贝做切片是最省资源的方案:

ffmpeg -i input.mp4 -map 0 -c copy \ -f hls \ -hls_time 6 \ -hls_list_size 0 \ -hls_playlist_type vod \ -hls_segment_filename "seg_%04d.ts" \ index.m3u8

几个参数的含义和取舍:

  • -hls_time 6:目标分片时长,单位秒。注意是“目标”不是“精确”,因为切片只能在关键帧处切。如果源视频的 GOP 是 4 秒,你写 6 会得到实际 8 秒左右的分片;写 2 会得到 4 秒。要精确控制,得先转码时把 GOP 设成和分片时长一致。
  • -hls_list_size 0:播放列表保留全部分片。直播场景通常设成 5 到 10,只保留最近几个。
  • -hls_playlist_type vod:告诉播放器这是点播,会写#EXT-X-ENDLIST结束标记。不写的话部分播放器会一直等新分片。
  • -hls_segment_filename:分片命名模板,%04d是四位补零序号。

从 TS 转回 MP4 是另一个高频需求,比如把 HLS 的分片合并回单文件:

ffmpeg -i "index.m3u8" -map 0 -c copy -bsf:a aac_adtstoasc merged.mp4

或者直接对单个 TS 操作:

ffmpeg -i segment.ts -map 0 -c copy \ -fflags +genpts \ -avoid_negative_ts make_zero \ merged.mp4

-fflags +genpts在 TS 转 MP4 时几乎是我的标配。TS 的时间戳经常从几万、几十万开始(PCR 基准),不做重生成,封进 MP4 后可能出现巨大的起始偏移,播放器要么从中间开始播,要么时长显示成天文数字。+genpts让 FFmpeg 根据帧率和时长重新生成一套从 0 开始的 PTS,代价是它依赖解码顺序推断,极少数 B 帧结构复杂的素材可能推断得不够准,那就得加-vsync 0关掉帧同步干预,让它老老实实按原时间戳搬。

这里有个反直觉的点值得展开:很多人以为-c copy就完全不涉及解码,实际上+genpts会触发 packet 层面的时间戳分析,虽然不解码像素,但也不是纯粹的字节搬运。如果你的目标是极限速度,就不要加genpts,改用-copyts -start_at_zero的组合,前者保留原始时间戳,后者把起点平移到 0。

4. 参数背后的计算:码率、时间基与封装开销

4.1 文件体积的估算与码率预算

封装不改码流,所以输出文件的体积基本等于所有流的数据量之和加上容器开销。估算公式:

文件大小(Byte) ≈ (视频码率 + 音频码率 + 其他流码率) × 时长(秒) ÷ 8 × (1 + 开销系数)

开销系数按容器取:MP4 大约 0.5% 到 1%,MKV 1% 左右(章节、附件多的话更高),TS 5% 到 8%,FLV 1% 到 2%。

举个具体例子。一段 45 分钟的课程视频,视频码率 2.5 Mbps,音频 AAC 128 kbps,目标容器 MP4:

总码率 = 2500 + 128 = 2628 kbps 时长 = 45 × 60 = 2700 秒 数据量 = 2628 × 2700 ÷ 8 = 887,175 KB ≈ 866 MB 加 1% 开销 ≈ 875 MB

这个估算在实测里误差通常在 2% 以内,足够用来做存储规划。反过来用也很有价值:如果业务要求单个文件不超过 500 MB,时长固定 45 分钟,那总码率上限就是:

500 MB = 500 × 1024 × 8 = 4,096,000 kbit 4,096,000 ÷ 2700 ≈ 1517 kbps 音频占 128,视频可用约 1389 kbps

这就是码率预算的基本算法。注意这个式子只对恒定码率(CBR)或接近 CBR 的素材准确,VBR 素材的实际体积跟画面复杂度强相关,估算偏差能到 20% 以上。要精确,只能先转一小段采样测。

封装环节本身还有一个容易被忽略的体积因素:元数据与附件。MKV 里如果带了字体附件、封面图、多个章节,体积可能凭空多出几十 MB。做批量转换时如果不需要这些,用-map_chapters -1去掉章节、-vn之外的-map -0:t排除附件,能省下不少空间。

4.2 时间基换算与 PTS/DTS 的实际影响

时间基(time_base)是一个分数,表示“一个时间戳单位代表多少秒”。1/90000 意味着时间戳每加 1,时间前进 1/90000 秒,精度约 11 微秒。MKV 的 1/1000 精度是 1 毫秒,对 60 帧视频(每帧 16.67 毫秒)够用,但对高帧率素材就有点粗糙。

换算的实操价值在于:当你手写代码封装,或者用setpts、-itsoffset做时间轴微调时,脑子里必须有这个换算。举个真实例子:某次需要把一条音频整体延后 250 毫秒对齐画面,音频流时间基是 1/48000,那么偏移量就是:

250 毫秒 = 0.25 秒 0.25 × 48000 = 12000 个时间戳单位

对应命令是-itsoffset 0.25(这个参数吃的是秒,FFmpeg 内部自己换算),但如果是代码里直接改 packet 的 pts/dts,就得自己乘。算错一个数量级,结果就是声音跑到几分钟之后,或者干脆被 muxer 拒绝。

还有一个高频误区:以为-r能改帧率。在流拷贝模式下-r是无效的,帧率信息写在码流和容器头里,不改编码就改不了。你看到的“改了帧率”其实是 muxer 在写头信息时覆盖了字段,播放器按新帧率解释旧数据,结果是播放速度整体变快或变慢,音画直接错位。要真正改帧率,必须重新编码。

DTS 单调性是另一个必须在脑子里过的检查项。B 帧的存在使得 DTS 顺序和 PTS 顺序不同,但 DTS 本身必须严格递增。当源文件经过剪辑、拼接、或者从多个片段合并而来时,DTS 很容易出现回退。表现是 muxer 刷警告,或者输出文件在某些播放器上花屏、跳帧。处理手段按顺序试:

  1. -fflags +genpts重新生成时间戳。
  2. -vsync 0或新版写法-fps_mode passthrough,禁止帧率同步干预。
  3. -avoid_negative_ts make_zero处理负值。
  4. 实在不行,对视频流重新编码一次,用-x264-params "bframes=0"关掉 B 帧,从根上消灭 DTS 回退。

4.3 关键帧间隔与封装后的可拖动性

流拷贝不会改变关键帧(I 帧)的分布,这一点必须清楚。源文件的关键帧间隔是 10 秒,封出来的文件拖动精度就是 10 秒——用户拖进度条,画面会跳到最近的关键帧。

这个特性在 HLS 场景下影响被放大。播放器切换码率、跳转分片都依赖关键帧对齐。如果源素材的 GOP 忽长忽短(VFR 录制常见),切片出来的分片时长会非常不均匀,某些分片可能长达十几秒,起播延迟明显。

解决办法只有一个:转码时固定 GOP。常用参数:

ffmpeg -i in.mp4 -c:v libx264 -preset medium -crf 20 \ -g 120 -keyint_min 120 -sc_threshold 0 \ -c:a copy out.mp4

-g 120在 30 帧下等于 4 秒一个关键帧。-keyint_min设成和-g相同,禁止场景切换时插入额外关键帧;-sc_threshold 0关掉场景切换检测。这三个参数配合,才能得到严格等距的 I 帧,切片时长才会均匀。只写-g是不够的,场景切换依然会插 I 帧,这是很多人切片不均匀的真正原因。

5. 用代码调 libavformat 完成封装

5.1 封装器的最小骨架与关键调用顺序

命令行覆盖不了所有场景。当封装要嵌进服务端流水线、要在封装过程中做自定义的包过滤、或者要把多个源拼成一条输出时,就得直接调 libavformat。最小可用的流程是固定的,顺序错了会得到各种奇怪的错误码:

  1. avformat_open_input打开输入,avformat_find_stream_info填充流信息。
  2. avformat_alloc_output_context2按输出文件名或显式指定的 muxer 名创建输出上下文。
  3. 遍历输入流,对每条要保留的流avformat_new_stream建输出流,然后avcodec_parameters_copy拷贝编码参数,务必把out_stream->codecpar->codec_tag置 0。
  4. 设置out_stream->time_base,一般直接沿用输入流的 time_base。
  5. avio_open打开输出 IO,avformat_write_header写容器头。
  6. 循环av_read_frame→av_packet_rescale_ts→av_interleaved_write_frame。
  7. av_write_trailer收尾,avio_closep、avformat_free_context释放。

第 3 步那个codec_tag = 0是我见过最多人栽跟头的地方。avcodec_parameters_copy会把源容器的 codec_tag 一起拷过来,比如从 MP4 拷到 MKV 时带着avc1这个 tag,MKV 的 muxer 不认,直接返回Could not find tag for codec。置 0 之后让 muxer 自己按目标容器选合适的 tag,问题消失。这个细节在任何入门教程里都不会写,但不写就跑不通。

核心代码大致长这样:

AVFormatContext *in_fmt = NULL, *out_fmt = NULL; avformat_open_input(&in_fmt, input_path, NULL, NULL); avformat_find_stream_info(in_fmt, NULL); avformat_alloc_output_context2(&out_fmt, NULL, NULL, output_path); int *stream_map = av_malloc_array(in_fmt->nb_streams, sizeof(int)); for (int i = 0; i < in_fmt->nb_streams; i++) { AVStream *in_st = in_fmt->streams[i]; if (in_st->codecpar->codec_type != AVMEDIA_TYPE_VIDEO && in_st->codecpar->codec_type != AVMEDIA_TYPE_AUDIO) { stream_map[i] = -1; continue; } AVStream *out_st = avformat_new_stream(out_fmt, NULL); avcodec_parameters_copy(out_st->codecpar, in_st->codecpar); out_st->codecpar->codec_tag = 0; out_st->time_base = in_st->time_base; stream_map[i] = out_st->index; } avio_open(&out_fmt->pb, output_path, AVIO_FLAG_WRITE); avformat_write_header(out_fmt, NULL); AVPacket *pkt = av_packet_alloc(); while (av_read_frame(in_fmt, pkt) >= 0) { if (stream_map[pkt->stream_index] < 0) { av_packet_unref(pkt); continue; } AVStream *in_st = in_fmt->streams[pkt->stream_index]; AVStream *out_st = out_fmt->streams[stream_map[pkt->stream_index]]; av_packet_rescale_ts(pkt, in_st->time_base, out_st->time_base); pkt->stream_index = out_st->index; av_interleaved_write_frame(out_fmt, pkt); av_packet_unref(pkt); } av_write_trailer(out_fmt);

这段代码能跑通 90% 的换容器需求。剩下 10% 的坑集中在两处:一是avformat_write_header之前必须确保所有输出流的codecpar都已填好,某些容器(MP4 尤其)在写头时就要算好整体结构;二是av_interleaved_write_frame内部会做包的排序和缓冲,它接管了 packet 的所有权,调用后不要再去 unref 或者复用同一个 packet 的 data。

5.2 交错写入、缓冲与自定义包处理

av_interleaved_write_frame和av_write_frame的区别值得单独说。前者会按 DTS 对多条流做交错排序,保证输出的音视频包交替出现,播放器读起来才顺畅;后者是原样写入,不做排序,需要调用方自己保证顺序。封装场景一律用前者,除非你在做非常底层的特殊实验。

交错带来的副作用是缓冲。当一条流跑得太快、另一条流落后太多时,muxer 会把先到的包攒在内存里,等另一条流追上来。这个缓冲有上限,就是前面提到的max_muxing_queue_size。代码里对应的是out_fmt->max_interleave_delta和内部的 packet buffer。如果源文件的两条流时间戳差距极大(比如音频从 0 开始,视频从 300 秒开始),缓冲区会迅速撑爆。这时候的正确做法不是无限加大缓冲,而是先用-itsoffset或者手工修正起始时间戳,把两条流的起点拉到同一量级。

自定义包处理是代码路径相对命令行的最大优势。常见的两种需求:一是按时间窗口裁掉开头结尾,只搬中间一段;二是按 keyframe 切分文件。前者的实现是在循环里判断pkt->pts换算成秒是否落在窗口内,超出就跳过;后者更简单,遇到pkt->flags & AV_PKT_FLAG_KEY且是视频流时,关闭当前输出、开一个新的。这两种模式在服务端做切片、做点播转码的前置处理时非常常用,用命令行虽然也能做,但控制粒度远不如代码。

注意:流拷贝模式下不要试图修改pkt->data的内容。包里的数据是编码后的码流,你改了任何一个字节,解码器就会花屏或者直接报错。要改内容,必须先解码。

6. 常见问题与排查实录

6.1 典型报错与异常现象速查表

封装环节的报错大多有固定套路,我把这些年高频遇到的整理成一张表,遇到问题先对号入座,能省掉大量试错时间:

报错或现象根因处理方式
Non-monotonous DTS in output stream源文件 DTS 回退,多见于剪辑拼接产物加-fflags +genpts;仍报错则-vsync 0;再不行关 B 帧重编
Could not find tag for codec ... in stream #0codec_tag 与目标容器不匹配命令行换容器重试;代码里codecpar->codec_tag = 0
moov atom not foundMP4 录制中断,索引未写入用-err_detect ignore_err尝试读取;能读出流就用流拷贝抢救
输出文件 0 字节或无法播放编码不被目标容器支持,写头即失败先ffprobe核对编码,必要时单独转码那条流
音画不同步,整体偏移固定值两条流start_time不一致-avoid_negative_ts make_zero;或按差值加-itsoffset
播放器显示时长异常(几万小时/负数)TS 的 PCR 基准时间戳未归一化-fflags +genpts或-copyts -start_at_zero
Too many packets buffered for output stream交错缓冲不足-max_muxing_queue_size 4096
HLS 分片时长忽长忽短源 GOP 不均匀转码时固定-g、-keyint_min、-sc_threshold 0
MP4 拖动进度条卡顿、起播慢moov 在文件尾部-movflags +faststart
封装后字幕消失MP4 不支持 ASS/SSA转mov_text或改用 MKV 容器
音频有杂音、播放器报解码错误TS 的 AAC 带 ADTS 头被塞进 MP4-bsf:a aac_adtstoasc
内存占用随文件增大持续上涨未及时 unref packet,或交错缓冲堆积检查循环里的av_packet_unref,限制队列大小

这张表里最需要展开的是moov atom not found。这个报错意味着文件结构已经损坏,moov 索引根本没写进去,常见于录制软件异常退出、断电、或者网络流中断。抢救思路是让 FFmpeg 忽略错误继续解析,把能读出来的码流用流拷贝搬到一个新容器里:

ffmpeg -err_detect ignore_err -i broken.mp4 -map 0 -c copy repaired.mkv

成功率跟损坏程度强相关。如果文件头就烂了,基本没救;如果只是尾部索引缺失,通常能救回绝大部分数据。我处理过的一个案例是一个 8 GB 的录制文件,救回来 7.6 GB,丢掉的是最后几秒。这类操作一定要输出到新文件,别覆盖原件。

6.2 音画不同步的分层定位方法

音画不同步是封装里最让人头疼的问题,因为它不报错,只能靠耳朵和眼睛发现。我的定位方法是分三层查,从外到内:

第一层,看封装前后各条流的 start_time。用ffprobe对比输入和输出。如果输入视频 start_time 是 0.000、音频是 1.400,输出变成了两者都是 0,但内容偏移还在,那就是封装没有做归一化,加-avoid_negative_ts make_zero重来。

第二层,看时长是否匹配。视频流时长 600.0 秒、音频 599.2 秒,差 0.8 秒属于正常范围(音频编码的帧对齐导致);但如果差到几秒,说明有一路流被截断或者丢包了,问题在源文件而不在封装。

第三层,看时间基换算是否有精度损失。从高精度时间基(1/90000)封进低精度容器(MKV 的 1/1000),每帧会做一次四舍五入,误差在微秒级,累积几千帧也就几毫秒,不会造成可感知的偏移。如果偏移达到几百毫秒,那就不是精度问题,而是某一路流的时间戳被错误地整体平移了。

实际操作里我还遇到过一个隐蔽情况:源文件是多段录制拼接而成,每段之间的时间戳有跳跃。流拷贝会把这些跳跃原样保留,播放时表现为“每隔几分钟音画错位一次,然后自己又对上”。这种素材必须先做时间戳重写,-fflags +genpts对这种场景效果有限,更可靠的做法是解码后按帧重新打时间戳再编码,成本高但结果干净。

ffmpeg -i jumpy.mp4 -map 0 \ -c:v libx264 -crf 18 -g 60 \ -c:a aac -b:a 192k \ -fflags +genpts -vsync cfr \ smooth.mp4

-vsync cfr(新版是-fps_mode cfr)强制恒定帧率输出,配合重新编码能把时间戳彻底理顺。这是“放弃速度换正确性”的方案,只在源文件确实有问题时才用。

6.3 批量封装的脚本化与实测心得

单文件操作可以手敲,一旦上量就必须脚本化。我维护的一套批量封装脚本里有几条规则,都是从事故里总结出来的:

第一条,先探测再决定。脚本里对每个文件先跑ffprobe拿 JSON,判断所有流的编码是否都在目标容器的白名单里。全部兼容才走纯拷贝,有一条不兼容就走混合模式,对不兼容的那条单独转码。不做这个判断,批量跑一千个文件,中间几十个失败,你得一个个回头找。

第二条,失败要能定位到具体文件。for循环里用ffmpeg -v error -y,把 stderr 重定向到以文件名命名的日志。不要用-v quiet,出了问题什么都看不到。我用的是:

for f in *.mp4; do base="${f%.*}" ffmpeg -hide_banner -v error -y -i "$f" \ -map 0 -c copy -movflags +faststart \ -max_muxing_queue_size 4096 \ "${base}_web.mp4" 2> "${base}.log" if [ $? -ne 0 ]; then echo "FAILED: $f" >> failed.txt fi done

第三条,并发数要压住。封装是 IO 密集型,磁盘跑满之后并发再高也没收益,反而会因为磁头来回寻道拖慢整体。机械盘上我一般并发 2 到 4,SSD 上可以到 8。用xargs -P控制就行,别写&一把梭。

第四条,输出目录和输入目录分开,而且先写临时文件名,成功之后再改名。这条规则救过我一次:脚本跑到一半机器重启,如果直接写目标文件名,会留下一堆看起来正常但实际截断的文件,混在成品里根本发现不了。写成.tmp后缀,最后统一 rename,一眼就能看出哪些没完成。

还有个实测出来的小结论分享给大家:同一个素材,用-c copy从 MP4 转 MKV 再转回 MP4,往返十次,文件大小每次会有几百字节的波动,但码流内容完全一致,用md5sum校验视频流的数据能对上。这说明容器切换是无损的。反过来,任何一次不小心漏写了-c copy,转码就会带来不可逆的画质损失。所以我在所有脚本的 ffmpeg 调用里,都把-c copy放在命令的最前面,就是为了让它显眼,防止被后面的参数覆盖或者遗漏。

最后说一个关于批处理的现实判断:不是所有封装都值得做。一个 200 MB 的小文件,流拷贝耗时 2 秒,转码耗时 40 秒,为了省这 38 秒去做复杂的兼容性判断,性价比不高,直接转码反而更省心。真正值得上流拷贝的,是那些几百 MB 到几 GB、CPU 转码要跑几分钟甚至几十分钟的大文件,以及那些必须保持原始画质的归档素材。判断标准很简单:转码耗时超过了写脚本调试的成本,才值得为它做兼容性分支。这个账我算过很多次,每次都能避免过度工程。