
做音视频相关的工作被问得最多的一类问题就是我只是想把 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后期剪辑中间格式通用播放器兼容性不如 MP4WebMVP8/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拿到一个个 packetpacket 上挂着pts、dts、duration、stream_index→ 把 packet 交给输出侧的 muxer按输出容器的时间基重新换算时间戳 →av_interleaved_write_frame写盘 → 结束时av_write_trailer回填索引。这条链路里唯一容易出问题的就是时间戳换算。容器换一种时间基time_base往往跟着换。MP4 的视频轨常见时间基是 1/15360 或 1/90000TS 是 1/90000MKV 干脆用毫秒即 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两者不一致说明是可变帧率VFRVFR 素材封装后容易出现时间戳抖动。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 languagechi \ -metadata:s:a:1 languageeng \ -metadata:s:a:0 title国语 \ out.mkv # 字幕单独处理MKV 存 ASSMP4 只能用 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 塞进 MP4FFmpeg 会提示不支持的编码或者你加了-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 优化与 faststartMP4 的索引表叫 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 21 | grep -m1 -o type:moov流媒体场景还有另一种选择分片 MP4fMP4ffmpeg -i in.mp4 -map 0 -c copy \ -movflags frag_keyframeempty_moovdefault_base_moof \ out_frag.mp4这种结构把文件切成一个个 moofmdat 的小单元每个关键帧处可独立开始播放天生适合边下边播和低延迟场景。代价是兼容性略差一些老播放器和部分编辑软件对它支持不好。我的经验是给 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 刷警告或者输出文件在某些播放器上花屏、跳帧。处理手段按顺序试-fflags genpts重新生成时间戳。-vsync 0或新版写法-fps_mode passthrough禁止帧率同步干预。-avoid_negative_ts make_zero处理负值。实在不行对视频流重新编码一次用-x264-params bframes0关掉 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。最小可用的流程是固定的顺序错了会得到各种奇怪的错误码avformat_open_input打开输入avformat_find_stream_info填充流信息。avformat_alloc_output_context2按输出文件名或显式指定的 muxer 名创建输出上下文。遍历输入流对每条要保留的流avformat_new_stream建输出流然后avcodec_parameters_copy拷贝编码参数务必把out_stream-codecpar-codec_tag置 0。设置out_stream-time_base一般直接沿用输入流的 time_base。avio_open打开输出 IOavformat_write_header写容器头。循环av_read_frame→av_packet_rescale_ts→av_interleaved_write_frame。av_write_trailer收尾avio_closep、avformat_free_context释放。第 3 步那个codec_tag 0是我见过最多人栽跟头的地方。avcodec_parameters_copy会把源容器的 codec_tag 一起拷过来比如从 MP4 拷到 MKV 时带着avc1这个 tagMKV 的 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 0moov atom not foundMP4 录制中断索引未写入用-err_detect ignore_err尝试读取能读出流就用流拷贝抢救输出文件 0 字节或无法播放编码不被目标容器支持写头即失败先ffprobe核对编码必要时单独转码那条流音画不同步整体偏移固定值两条流start_time不一致-avoid_negative_ts make_zero或按差值加-itsoffset播放器显示时长异常几万小时/负数TS 的 PCR 基准时间戳未归一化-fflags genpts或-copyts -start_at_zeroToo many packets buffered for output stream交错缓冲不足-max_muxing_queue_size 4096HLS 分片时长忽长忽短源 GOP 不均匀转码时固定-g、-keyint_min、-sc_threshold 0MP4 拖动进度条卡顿、起播慢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 到 4SSD 上可以到 8。用xargs -P控制就行别写一把梭。第四条输出目录和输入目录分开而且先写临时文件名成功之后再改名。这条规则救过我一次脚本跑到一半机器重启如果直接写目标文件名会留下一堆看起来正常但实际截断的文件混在成品里根本发现不了。写成.tmp后缀最后统一 rename一眼就能看出哪些没完成。还有个实测出来的小结论分享给大家同一个素材用-c copy从 MP4 转 MKV 再转回 MP4往返十次文件大小每次会有几百字节的波动但码流内容完全一致用md5sum校验视频流的数据能对上。这说明容器切换是无损的。反过来任何一次不小心漏写了-c copy转码就会带来不可逆的画质损失。所以我在所有脚本的 ffmpeg 调用里都把-c copy放在命令的最前面就是为了让它显眼防止被后面的参数覆盖或者遗漏。最后说一个关于批处理的现实判断不是所有封装都值得做。一个 200 MB 的小文件流拷贝耗时 2 秒转码耗时 40 秒为了省这 38 秒去做复杂的兼容性判断性价比不高直接转码反而更省心。真正值得上流拷贝的是那些几百 MB 到几 GB、CPU 转码要跑几分钟甚至几十分钟的大文件以及那些必须保持原始画质的归档素材。判断标准很简单转码耗时超过了写脚本调试的成本才值得为它做兼容性分支。这个账我算过很多次每次都能避免过度工程。