MP4打包与拆包深度解析:从ISO BMFF到FFmpeg实践
发布时间:2026/10/6 14:16:59 作者:尧图编辑部 阅读量:1,286

MP4 这玩意儿做视频、做直播、做播放器的天天见但真要说清楚它内部是怎么把画面和声音“装”进去的能讲明白的人不多。“MP4 打包和拆包”这个概念是我最近在群里被问爆的话题——有做播放器优化的有做转码工具的还有把 m3u8 转成 mp4 发现文件打不开的。追根溯源都是对 MP4 这个容器格式的“打包muxing”和“拆包demuxing”底层逻辑没吃透。这篇我不讲虚的直接从 ISO BMFF 的结构出发把 MP4 的行李打包逻辑、拆解过程、常见工具的操作逻辑以及那些坑一次说清楚。不管你是做音视频开发、写转码脚本、还是只是折腾视频工具的老哥这篇应该都能让你少走点弯路。1. 先说清楚MP4 到底在“包”什么——容器和编码的纠缠关系1.1 容器格式 vs 编码格式十个人里有八个搞混的概念很多人的第一个误区就是把“MP4 格式”和“H.264 编码”当成一回事。我经常遇到有人问“能不能帮我把视频转成 MP4”打开一看源文件本来就是 MP4只是编码是 H.265播放器解码不了人家以为“转格式”能解决。实际上MP4 是一种容器格式它管的是“怎么把已经编码好的视频流、音频流、字幕流、元数据组织在一个文件里”而 H.264、H.265、AAC 这些是编码格式管的是“画面/声音本身怎么用二进制表示”。打个比方编码格式是货物本身——你有一批瓶装水和一批纸杯容器格式是打包用的行李箱——箱子决定东西怎么摆放、怎么分类、贴什么标签但它不改变货物本身。你把瓶装水从行李箱A换到行李箱B水还是那瓶水但如果你非要把纸杯揉碎了塞进水瓶里那就得先拆了重做——这就是“转封装”和“转码”的本质区别。这个区别直接决定了我们后面聊的“打包”和“拆包”到底是在做什么操作。1.2 MP4 背后的标准ISO BMFF 和 QuickTime 的血缘MP4 的文件结构严格来说不是“MP4 发明”的它继承自 Apple 的 QuickTime 文件格式后来被标准化成 ISO/IEC 14496-12也就是 ISO BMFFBase Media File Format。如果你在技术文档里看到“MP4 就是 ISO BMFF 的一种 brand品牌实现”别慌翻译成人话就是MP4 只是 ISO BMFF 这套通用容器规则下的一个具体配置。这套格式最核心的设计思路是Box也叫 Atom。整个 MP4 文件就是一堆 Box 嵌套着另一堆 Box就像俄罗斯套娃加乐高积木的组合。每个 Box 有一个四字符类型标识比如 ftyp、moov、mdat还有自己的大小、版本、内容。你在一个正常 MP4 文件上随便找一段二进制数据跳到一个 Box 开头就能解析出它是什么类型、多大体积、里面包着什么。所以我一直觉得搞懂 MP4 的打包拆包本质就是搞懂 Box 树的组织方式和遍历方式。后面所有内容都围绕这一点展开。2. 打开 MP4 的“行李箱”拆包到底在拆什么2.1 从第一个字节说起ftyp、moov、mdat 三大件一个最基础的 MP4 文件你顺着字节往下扫最早遇到的通常是这三个大 BoxftypFile Type Box文件开头的身份证声明这个文件是什么品牌、遵循哪个版本规范。比如isom、mp42、avc1这些 brand 值就代表不同的兼容性要求。它很短通常几十个字节。moovMovie Box整个文件的大脑和索引里面装着所有轨道track的元数据——视频轨、音频轨各自的编码信息、时长、分辨率、采样率、帧率以及最关键的“每一个音视频帧sample在 mdat 里的具体位置和大小”。mdatMedia Data Box真正装着画面和声音数据的大仓库。视频的每一帧 I/P/B 帧、音频的每一帧 AAC全部按顺序堆在这个 Box 里。如果你把这三大件类比成一个图书馆ftyp 是门口挂着的馆名和规章制度moov 是图书检索卡片柜mdat 是藏书库房。想“借书”播放某一帧你得先翻卡片柜moov找到书在库房哪一排mdat 里的偏移量和大小再去库房取。没有 moov你面对 mdat 就只是一堆无法判断边界的二进制流没有 mdatmoov 就是一本没有书的空目录。2.2 轨道Track和 sample拆包的最小操作单位拆包英文叫 demuxing做的事情概括起来就三步找 moov、读 track、按 sample table 索引掏数据。先说 track。一个典型 MP4 里至少有两个轨道一个视频轨hdlr 里标为vide一个音频轨hdlr 里标为soun。每个轨道有自己的 tkhd轨道头、mdhd媒体头包含轨道自己的 timescale 和时长、stblsample table样本表。再说 sample。在 MP4 的术语里“sample”不是指一帧画面而是指一段可独立解码访问的媒体数据单元。对视频轨来说一般就是一帧或一个 field对音频轨来说通常是一个 AAC frame1024 个采样点。拆包的最高频操作就是遍历这些 sample取出每一帧数据按解码时间戳DTS或显示时间戳PTS排好序然后交给解码器。你可能会觉得取一帧不就是按偏移量读一段数据吗对思路就是这么简单但魔鬼藏在细节里——怎么知道每一帧的偏移量、大小、时间戳这些信息不是“说好的”而是要通过 stbl 下面那一堆小 Box 动态算出来的。2.3 真正费劲的部分sample table 里那串缩写stts/stsc/stsz/stco很多初学者拆包到 moov 就卡住了因为 stbl 里的 Box 名实在劝退。我把它们挨个讲明白这张表建议收藏Box 类型全称作用对应真实世界的比喻stsdSample Description声明编码格式、分辨率、SPS/PPS 等解码需要的参数集货箱上的规格说明书sttsDecoding Time to Sample记录每个 sample 的解码时间增量用 run-length 压缩表示每件货物的入库时间表cttsComposition Time to Sample记录有 B 帧时 DTS 和 PTS 之间的偏移上架展示时间和入库时间的差值表stscSample to Chunk把连续的 sample 分组到 chunk连续存储块中哪些货放在同一个大托盘上stszSample Size每个 sample 的字节大小每件货物的尺寸标签stco / co64Chunk Offset每个 chunk 在 mdat 内的绝对偏移地址每个大托盘在仓库里的货架坐标拆包时定位一帧的标准路径是先看 stts 算出这帧的 DTS再看 stsc 确定它在哪个 chunk然后用 stco 拿到 chunk 的偏移地址最后用 stsz 结合 chunk 内前面的 sample 大小算出该帧在文件里的精确 byte 范围。第一次手推一遍这个流程你会觉得绕得离谱但这就是 ISO BMFF 高效随机访问的代价——播放器要能“任意拖进度条到第 N 秒”而不是从头解码。把货物信息全存在 moov 的检索表里就能实现 O(1) 级别的随机定位。3. 动手拆一次一份 MP4 文件的完整拆解流程3.1 准备工作用工具定位 Box 边界理论说了一堆不如实际拆一个文件。我建议你拿一个正常的 MP4先用命令行工具把它的 Box 树列出来直观感受一下层级。FFmpeg 自带的ffprobe和 Bento4 的mp4info都可以我个人日常用mp4info输出更接近 Box 层级。# 用 mp4info 查看 MP4 的 Box 树结构 mp4info input.mp4你会看到类似这样的输出节选File: major brand: isom minor version: 512 compatible brand: isomiso2avc1mp41 duration: 60.000000 s tracks: Track 1: flags: 3 time scale: 15360 duration: 921600 (60.000000 s) language: und media: sample count: 1500 timescale: 15360 duration: 921600 (60.000000 s) media type: Video sample description: coding: avc1 sample table: segment[0]: chunk count: 30 first chunk: 0 samples per chunk: 50 ...能看到moviemoov里嵌套了tracktraktrack里有mediamdiamedia里有sample tablestbl。sample count: 1500说明视频轨有 1500 个 samplechunks per count: 30、samples per chunk: 50说明这些 sample 被封装成了 30 个 chunk每个 chunk 50 帧。为什么有这个 chunk 层级因为如果一个 60 秒的视频有 1500 帧每帧都记一个文件偏移量stco 表会有 1500 条记录浪费空间。把连续的 50 帧放进一个 chunk用“chunk 偏移 chunk 内偏移”两级索引表能瘦身到 30 条。这是打包器常用的空间优化手段拆包时要注意chunk 和 sample 不是一对一的读的时候必须先走进 chunk 再定位 sample。3.2 跟着 mp4info 走一遍看一个真实文件的 Box 树我们再往下挖sample table里每个 Box 的细粒度信息。mp4info会很贴心地展开 stbl 的每个子 Box包括 stsd 的编码器信息、stts 的时间增量、stsz 的采样大小等。# 用 mp4dump 查看更底层的 Box 字节分布包含偏移量和大小 mp4dump input.mp4这个命令会直接打印出每个 Box 的层级、类型、偏移地址和字节长度类似[ftyp] size32, offset0 [moov] size10243, offset32 [mvhd] size108, offset52 [trak] size5864, offset160 ... [trak] size3161, offset6024 [mdia] size3145, offset6048 [minf] size2733, offset6076 [stbl] size2645, offset6100 [stsd] size799, offset6128 [stts] size76, offset6927 [stsc] size48, offset7003 [stsz] size1592, offset7051 [stco] size132, offset8643 [mdat] size453157306, offset10275看到没有moov是 32 字节偏移处开始、大小 10243 字节mdat是 10275 字节偏移处开始、大小 453157306 字节。这个顺序——moov 在 mdat 前面——说明这个文件做过“faststart”moov 前置处理适合 HTTP 在线播放。如果是手机拍摄的原生文件通常 mdat 会排在 moov 前面文件一开头就是海量的画面数据。这里多说一句mp4dump 打印的偏移是绝对文件偏移意思是你可以直接用十六进制编辑器跳到 10275 处从那里开始读 453157306 字节就是 mdat 的全部内容。拆包脚本里读取数据时用的就是这个偏移值只不过为了稳定一般会通过解析 stbl 动态获取而不是硬编码。3.3 提取帧数据从 mdat 里把 H.264 裸流抠出来下面用一个 Python 小脚本演示真正“拆包”的动作定位视频轨的 sample从 mdat 中提取出 H.264 数据。注意这里我只展示了核心逻辑重点看思路。import struct def read_box(f, start): 读取一个 box 的头部返回 (size, type, header_len, payload_start) f.seek(start) header f.read(8) size, box_type struct.unpack(I4s, header) header_len 8 if size 1: # 64位扩展大小 size struct.unpack(Q, f.read(8))[0] header_len 8 elif size 0: # 延伸到文件末尾 f.seek(0, 2) size f.tell() - start return size, box_type.decode(latin-1), header_len, start header_len # 以读取 sample 大小表 stsz 为例 # 先找到 moov - trak(视频轨) - mdia - minf - stbl - stsz # sample_size stsz 中的统一采样大小如果为0说明每个 sample 大小不同需要逐个读取 # 然后通过 stsc stco 算出每个 sample 在文件中的绝对偏移流程的文字版是解析 stsc 拿到“某个 chunk 从第几个 sample 开始、每个 chunk 装几个 sample”解析 stco 拿到“每个 chunk 的偏移”再用 stsz 的每个 sample 大小累加算出 chunk 内第 N 个 sample 的偏移。这套逻辑你可以在网上找到很多现成封装比如 Python 的mp4parser库但建议你至少手写一遍彻底搞懂 offset 的推导过程。拆包过程中最烦的往往不是定位而是H.264 的格式转换。MP4 里存的 H.264 是 AVCC 格式——每帧前面用 4 字节大端整数记录该帧长度而 AnnexB 格式——用00 00 00 01起始码分隔——常用于 TS 流、裸流文件。两者编码数据完全一样但包装方式不同。拆包工具要想把 MP4 里的 H.264 喂给一个只认 AnnexB 的解码器或直出.264裸流就得先把 4 字节长度前缀替换成起始码。这也是很多人在“MP4 转 TS / m3u8 转 MP4”时困惑的根源后面专门聊。4. 反向操作如何把裸流“打”成一个合规的 MP44.1 打包前必须先决定的事timescale 和版本拆包的逆操作是打包muxing简单说就是把解码器输出的视频帧流、音频帧流按时间顺序组织成 sample算出各种偏移量和大小填进 moov 的表格里再把帧数据按顺序倒入 mdat。但真动手打包之前有几个参数必须先定死否则后面返工哭都来不及。第一个是timescale时间刻度。它表示一秒被分成多少份。比如视频轨 timescale 设 90000那么一帧以 30fps 播放的视频DTS 就是 300090000/30音频轨如果用 48000一个 AAC frame1024 样本的增量就是 1024。这个值不是随便选的它决定了时间戳的精度timescale 太小整数帧率比如 29.97fps 这种非整数帧率会累积误差太大则用 32 位整数存时间戳时容易溢出。标准实践是视频轨用 90000和 MPEG-TS 一致方便转流音频轨直接用采样率。如果你不想思考用 1000 也行但遇到 29.97fps 就会遇到微小的帧间隔抖动。第二个是track 版本和 brand。ftyp 里写什么 brand 会影响播放器兼容性。比如包含 H.265 的 MP4最好声明hvc1或hev1包含 H.264 的通常声明avc1。如果你在用第三方库打包注意选择编码参数字段是写进 stsd 的 AVCDecoderConfigurationRecord 还是 HVCDecoderConfigurationRecord——这是播放器能不能硬解的关键。作为参考主流 mp4 muxer 的默认配置一般是ftyp brandisom兼容 brandisomiso2avc1mp41或iso6moov 版本 0track id 从 1 递增。跟着这个默认走不会出大乱子。4.2 组装顺序为什么会影响线上播放——moov 前置的学问打包时有一个永远绕不开的优化点moov 应该放在 mdat 前面还是后面文件结构上ISO BMFF 允许 moov 在文件中随意位置——可以在 mdat 前面也可以在后面甚至分成多个 mooffMP4 分片格式。但对 HTTP 在线播放来说moov 的位置直接决定了首屏速度和拖动进度条的体验。如果 moov 在文件末尾播放器必须先下载完整个文件才知道每帧在哪——普通浏览器拿到文件时如果没做特殊处理只能等全部下载完后才能播那不是“在线播放”那是“下载后播放”。所以流媒体场景下打包器一般会先把所有帧数据写入临时文件的 mdat计算好所有偏移最后再写 moov 并把它前置到文件开头。这个操作有个专有名词叫faststart。FFmpeg 一条命令搞定# 将 moov 移到文件前部适合在线播放 ffmpeg -i input.mp4 -c copy -movflags faststart output.mp4-c copy是流拷贝流复制不重新编码速度极快等于做了一次重封装faststart就是让 ffmpeg 在结束时把 moov 重新写回到 mdat 前面。我做视频分发平台时上线前的最后一道工序永远是跑这个命令。如果发现某个视频拖进度条特别卡先查它 moov 的位置十有八九是这个原因——省得优化半天 CDN 才发现是文件本身的问题。4.3 用 FFmpeg remux 的视角理解打包参数很多人用 ffmpeg 只是一条命令闭眼转我建议把里面的参数拆开看尤其是 remux重封装场景——不重新编码、只改容器。典型的# 把 TS 流H.264 AAC封装成 MP4 ffmpeg -i input.ts -c copy -bsf:v h264_mp4toannexb -movflags faststart output.mp4这里-bsf:v h264_mp4toannexb是比特流过滤器。等一下TS 里的 H.264 本来就是 AnnexB封装成 MP4 应该转成 AVCC 才对为什么命令是h264_mp4toannexb其实这是 ffmpeg 的一个历史遗留命名h264_mp4toannexb这个 filter 负责在两者间转换当输入是 AnnexB 时它会转换成 AVCC 所需的形式反之亦然。它的存在也印证了我前面说的——MP4 和 TS 对同一份 H.264 数据的组织方式是不同的单纯的 remux 也不是“换个盒”那么简单需要通过 bitstream filter 做包装格式适配。再举一个上面提过的场景多个 MP4 合并成一个网络播放用的 MP4。如果你只是想把几个文件按顺序拼接有些人会直接cat二进制拼接这基本都会失败因为每个文件都有自己的 moov 和 mdat拼接出来的文件有多个 moov后面的 moov 会覆盖前面的索引播放器只会认第一个。正确做法是解封装后按时间顺序重新打包# 将两个 MP4 按时间顺序合并为一个 ffmpeg -i 1.mp4 -i 2.mp4 -filter_complex [0:v][0:a][1:v][1:a]concatn2:v1:a1[v][a] -map [v] -map [a] -c copy output.mp4这段命令里concatfilter 会解出每个文件的裸流按时间戳拼接再交给 muxer 重新打包。看到“解出”和“重新打包”你就知道它为什么能成功而cat不能。5. 和各种“打包/拆包”场景对对碰5.1 m3u8 转 mp4、ts 转 mp4 到底在转什么如果你在网上搜“m3u8 转换 mp4 格式免费软件有哪些”大概率是想把直播录播或视频网站缓存的 m3u8 播放列表转成离线 MP4。m3u8 本质上是一个文本清单指向一堆.ts分片文件每个分片都是一段 MPEG-TS 流。把 m3u8 转成 mp4就是把分片解封装、按时间顺序拼接、再重新封装成 MP4通常会顺带把 AnnexB 的 H.264 转成 AVCC 格式。工具层面ffmpeg 一条命令即可# m3u8 转 mp4流复制不转码 ffmpeg -i https://example.com/playlist.m3u8 -c copy -bsf:a aac_adtstoasc output.mp4很多同学会漏掉aac_adtstoasc这个 filter。TS 流中的 AAC 是 ADTS 格式带同步头的裸流而 MP4 里的 AAC 要求AudioSpecificConfigASC写在 stsd 里数据本身不带同步头。直接 copy 会导致 MP4 里没有任何 ASC 信息播放器不知道采样率、声道数出来的声音要么闷响要么直接不出声。这个 filter 会分析 ADTS 头提取 ASC 信息写进 MP4 的 metadata同时去掉每帧的 ADTS 头。每次遇到“转出来没声音”的问题先想想是不是漏了它。同理npkg 转 mp4、wallpaper 壁纸 pkg 转 mp4这类操作pkg 是 wallpaper engine 的封装格式解包出里面的视频文件再重新封装成 mp4核心思路一样先解封装拿到编码流再做容器转换。5.2 H.264 转 H.265 这种“压缩”为什么不能叫重新打包热搜词里有“mp4 压缩 h265”这个表述其实有歧义。把 H.264 编码的 MP4 转成 H.265 编码的 MP4这是转码transcoding是对视频帧本身做重新压缩计算量巨大耗时远高于 remux。而“重新打包remux”只是转换容器帧数据一个 bit 都不变。还被误解的是“压缩”这个词。如果目标是让文件体积变小有两个层次无损压缩/有损重编码改变帧内容文件变小画质可能有损属于转码。容器优化比如删除多余轨道、清理 metadata、优化 moov 布局也能省掉一些零头但画面数据本身不变。所以我面对“MP4 压缩 H265”的需求时一般会先看用户到底是要画质优先还是体积优先。如果是把 H.264 的录屏转成 H.265 来减小体积# H.264 转 H.265 转码CRF 28 控制画质与体积平衡 ffmpeg -i input.mp4 -c:v libx265 -crf 28 -preset medium -c:a copy -tag:v hvc1 output_h265.mp4注意那个-tag:v hvc1。如果不加ffmpeg 默认会把 H.265 标成hev1部分苹果设备比如某些 iOS 播放器只认hvc1转出来自己播没问题发给别人就打不开。这种“编码对容器不兼容”的坑就是封装参数没设对和转码本身无关。5.3 测试文件下载、预览打不开这些实操坑再聊几个具体坑。“mp4 测试文件下载”的场景一般是开发播放器或测试解码器时需要构造各种规格的 MP4。别网上随便找自己生成最可控# 生成 10 秒 1080p H.264 AAC 的标准测试 MP4 ffmpeg -f lavfi -i testsrcduration10:size1920x1080:rate30 \ -f lavfi -i sinefrequency1000:duration10 \ -c:v libx264 -preset veryfast -c:a aac -shortest test.mp4testsrc是测试图形sine是正弦波音调配合能生成完全可预期、适合测边界条件的文件。你可以再叠加-movflags faststart、换fps30000/1001测 29.97fps、加 B 帧测 ctts 等。拿真实录屏当测试源反而不容易定位问题边界。“mp4 预览”打不开是另一类高频问题。网页预览、手机相册预览打不开核心要看两件事一是编码是不是浏览器/平台支持的H.265 在部分浏览器不行ProRes 在手机上不行——这是编码和容器共同决定的二是 moov 是否前置。很多转出来的 MP4 文件明明本地播放器能放放到网站上就转圈就是因为文件大、moov 在后面浏览器没有下载完整文件就无法初始化播放器。优先排查这两点能消灭 80% 的预览问题。6. 踩坑实录和 MP4 结构有关的几个典型问题6.1 moov 头损坏导致的“半个文件”现象说个我真实遇到过的案例。有一次运营反馈一个视频文件“只有前 3 秒能播后面就黑屏”用普通播放器看也是进度条能拖但播不了。我第一反应是文件损坏但用 ffprobe 一查时长显示正常 10 分钟。这就怪了。后来 mp4dump 一看发现文件的 mdio 部分有个 chunk 偏移指向了错误的位置——stco 里记录的偏移比 mdat 实际大小还大。原因很可能是在文件传输或裁剪时某个删除操作留下了残留的 metadata导致 moov 和 mdat 对应不上。播放器按 moov 的索引去 mdat 取帧取出来的却是文件末尾的垃圾数据解码失败画面就断了。这类“moov 和 mdat 不一致”的问题轻则播放中断重则整个文件打不开。修复思路不是直接改 mdat而是重建 moov——用 ffmpeg 忽略原有 moov重扫 mdat 并重新生成索引# 忽略损坏的 moov重新扫描并重建索引 ffmpeg -i broken.mp4 -map 0 -c copy -movflags use_metadata_tags -movflags faststart fixed.mp4虽然“忽略 moov”后 ffmpeg 有可能因为找不到正确索引而失败但大部分局部损坏都能这样救回来。如果还不行就得拿 hexdump 手动检查 stco 偏移和 mdat 实际大小找出偏移错乱的区间。这种操作比较硬核常规场景用不着但知道原理以后排查起来会更快。6.2 时间戳混乱为什么拆包后画面音画不同步拆包自身是不会改数据的但如果你的拆包逻辑没有正确处理时间戳喂给播放器后就会出现音画不同步这也是自研播放器最头疼的问题之一。具体来说MP4 里每个 track 的时间戳单位和起点可能不同。视频轨的 timescale 可能是 15360音频轨的可能是 44100拆包时要按各自 timescale 归一化到统一时间轴比如毫秒再按 PTS 对齐。如果在转封装时把两路流的 DTS 直接按“轨道内部顺序”拼接而不做对齐音频流从第 0 秒开始视频流也从第 0 秒开始但因为帧数、编码延迟不同实际播放时你就会听到声音比画面慢半拍或者快半拍。另一个常见来源是音频 priming sample。AAC 编码会引入编码延迟MP4 的 audio track 里一般有一段“预热数据”用 edit listelst box或pts的负偏移来表示。拆包时如果你把这些 priming sample 当成正常 sample 播放开头会有一段噪音或者声音提前如果你把它们全部丢弃又可能导致音频整体提前和视频错位。标准做法是尊重 edit list 里定义的偏移把 priming sample 解码后丢弃或静音填充。具体到 FFmpeg 的 remux 场景它一般会自动处理但自研封装器就很容易栽在这里。6.3 B 帧和 PTS/DTS接口封装顺序≠显示顺序最后一个坑也是做转封装必踩的有 B 帧的流写入 mdat 的顺序DTS 递增序和播放显示顺序PTS 递增序是不一样的。MP4 的 sample 在 mdat 里默认按 DTS 排列也就是解码顺序。一个典型的 GOP 如果是I B B P解码器必须先拿到 I 帧和 P 帧才能解码出中间的 B 帧。所以 mdat 里的存储顺序可能是I P B B按 DTS但播放器显示时得按 PTS 显示为I B B P。如果拆包程序只按文件顺序把 sample 直接送给播放器而不做 PTS 重排轻则画面闪跳重则解码器直接报错。在 MP4 里这个“显示顺序相对解码顺序的偏移”就是 ctts box 存在的意义。每帧的 PTS DTS ctts 偏移值。如果文件里没有 B 帧比如纯直播低延迟流ctts 可以省略一旦有 B 帧拆包时你在重封装或者播放控制里就必须读取 ctts计算出每帧真实的 PTS。实践中怎么快速验证用 ffprobe 看视频轨的nb_frames和time_base再用-show_frames看单帧的pkt_dts和pts是否相等。如果pkt_dts pts说明存在 B 帧拆包程序必须处理 ctts不能天真地按存储顺序播放。我在封装测试工具时最常设置的一个“回归指标”就是重封装后用同样的解码器解码逐帧 PTS 必须和源文件完全一致。如果 PTS 有偏移不用看别的先去查 ctts 和 edit list 的解析有没有丢信息。这两个 Box 一个管“显示偏移”一个管“时间坐标起点”加起来就是 MP4 时间模型的全部秘密。说起来MP4 的打包拆包技术上并不复杂核心就是 Box 树解析、sample table 动态计算、时间戳模型对齐这三板斧。但正因为绝大多数工具把它们封装得看不见摸不着真正遇到问题时反而无从下手。我写这篇的初衷就是希望把这些藏在 ffmpeg 黑盒底下的逻辑翻出来至少下次再遇到“moov 前置”“ctts 偏移”“AVCC 转 AnnexB”这些词时你能立刻知道它在说什么、该往文件哪个位置去看。如果你也想自己写一个 mp4 解析器或者封装器建议从 mp4dump 开始把你的测试文件拆到底再照着 stbl 的索引一步步推导帧位置跑通一次之后整个 MP4 结构就再也不会忘。