🎬 第七章 FFmpeg解码音视频及流媒体
📄 一句话概括

mp4 是一串「盒子」,索引盒子 moov 默认写在文件尾。 不搬到前面,网络播放就得下完整个文件才开始。

📝 本节和前面几节的关系

上一节在字节层面折腾了一圈,这一节回到「文件结构」。 faststart 是个很具体的优化:它只做一件事,却决定了一个网站的视频「点了能不能立刻播」。 这一节还会顺带建立「盒子」这个概念,后面看任何 mp4 相关的问题都用得上。

一、mp4 是一串盒子

mp4(ISO BMFF)的结构非常机械:每个盒子 = 4 字节长度 + 4 字节类型 + 内容,一层套一层。 所以「解析 mp4」在原理上就是「按这个格式一路读下去」。

盒子 装什么 为什么重要
ftyp 文件类型与兼容品牌 告诉播放器「我是什么」
moov 索引:几条流、什么编码、每帧在哪 没有它就没法播
mdat 真正的音视频数据 占体积的绝大部分
free 填充 给后续编辑留位置

关键点在于「moov 和 mdat 的先后」:默认封装是「先写数据、再补索引」, 因为编码的时候你并不知道索引要多大——得等所有帧都写完了才能算出来。

这个「先写后补」的顺序很方便,但对网络播放是灾难: 播放器必须先拿到 moov 才能知道「有几条流、每帧在哪」,而它在文件尾部——所以只能等下完。

二、faststart 只做一件事

-movflags +faststart 做的事只有一件: 写完文件之后,把 moov 从尾部搬到 ftyp 后面,并把里面所有偏移加一遍。

Demo 里两个文件的对比很直白:

版本 盒子顺序 moov 偏移 起播要下
默认 ftyp → free → mdat → moov 6892 68.8%
faststart ftyp → moov → free → mdat 32 0.3%

「起播要下」这一列就是全部意义:68.8% 对 0.3%, 在慢网络下是「等一分钟」和「立刻」的区别。

注意那个偏移 32 不是 0:ftyp 在前面占了 32 字节,所以 moov 紧跟在它后面。 这也说明 faststart 不是「把 moov 挪到最前面」,是「挪到 ftyp 后面」——ftyp 必须第一个,播放器靠它判断「这是不是 mp4」。

顺带说那个 free 盒子:它是「占位填充」, faststart 挪完 moov 之后原来的位置会空出来,就用 free 填上。 所以两个文件大小完全一样(都是 10017 字节)——搬移不改变体积,只改顺序。

三、什么时候值得,什么时候不值

这个操作的代价要讲清:它要「多读一遍、多写一遍」, 所以封装时间会变长,而且需要额外的临时空间。 对一个大文件,这个代价不小。

场景 要不要 faststart 原因
网络点播 要 能立刻起播
本地录像 不要 反正要等录完
直播切片(HLS) 要 每个切片都要能快速起播
存档母版 不要 只求无损,不求起播

第三行值得一提:HLS 的每个 .ts 分片其实不需要 faststart(TS 没有 moov), 但如果是 fmp4 分片(CMAF),就必须。 所以「要不要 faststart」这个问题,答案是「看你的分发方式」。

还有一个更省事的替代方案:如果服务端支持 HTTP Range 请求, 播放器可以先请求「文件尾部最后几十 KB」拿到 moov,再回头请求数据段。 这就是很多播放器「不用 faststart 也能秒开」的原因。 但它依赖服务端,不如 faststart 稳。

所以工程上的建议是「两道保险都上」:压制时加 +faststart,服务端也开 Range。 前者保证文件本身没问题,后者能救「忘了加」的老文件。

四、动手 Demo:faststart 把索引从文件尾搬到文件头

这段用同样的源生成两个 mp4,一个默认、一个带 +faststart,然后解析顶层盒子的顺序与偏移。 解析代码只有十几行——因为 mp4 的结构就是「长度 + 类型」这么机械。

off = 0
while off < size:
    f.seek(off)
    sz, typ = struct.unpack(">I4s", f.read(8))
    print(typ, off, sz)
    off += sz

# 关键就看 moov 在什么位置
# 默认:ftyp -> free -> mdat -> moov
# faststart:ftyp -> moov -> free -> mdat

实跑输出:

--------------------------------------------------------------------
faststart 把索引从文件尾搬到文件头
--------------------------------------------------------------------
**mp4 的结构是「一串盒子」**,**其中最要紧的两个是 `mdat`(真正的数据)和 `moov`(索引)**。

  **默认(不 faststart)**:共 10017 字节
    box 顺序:**ftyp → free → mdat → moov**
    `moov` 偏移 **6892**,要下完 **68.8%** 才能开始播

  **faststart**:共 10017 字节
    box 顺序:**ftyp → moov → free → mdat**
    `moov` 偏移 **32**,要下完 **0.3%** 才能开始播

**默认那个文件的第二个盒子是 `free`**,**`moov` 落到最后面去了。**

**这意味着什么**:**播放器必须先拿到 `moov` 才知道「有几条流、每条流用什么编码、每帧在哪」**,
**所以它得先下完整个文件,再回头播。**
**在本地磁盘上看不出来(反正都读得到),一到网络就变成「转圈转到百分之百才开始」。**

**`+faststart` 做的事只有一件**:
**写完文件之后,把 `moov` 从尾部搬到 `ftyp` 后面,并把所有偏移加一遍。**
**它要多读一遍文件、多写一次**,所以封装会变慢一点——**但换来「边下边播」。**

**这个取舍在什么场景下值**:**网络点播值**(能立刻起播);**本地录像不值**(反正要等录完)。
**所以「压片给网站用」的流程里,`-movflags +faststart` 是标配的第二遍。**

两组输出的差别只有一行:盒子顺序变了,moov 的偏移从六千多变成 32。 而总字节数一模一样——这就是「搬移不改体积」的直接证据。

「要下完 68.8% 才能开始播」这个数要这么理解:它不是「下 68.8% 就够」, 而是「必须把 moov 下到,而它恰好在 68.8% 的位置」。 在真正的网络环境里,你还得考虑「下到 moov 之后,数据段还得重新请求」——所以实际体验比 68.8% 更差。

解析这段代码有个坑要提:盒子长度字段是 32 位,最大 4 GB, 所以大于 4 GB 的文件会用 size == 1 + 一个 64 位长度。 这段代码处理了这种情况(读 8 字节再解),写生产代码时不能省。

最后一个观察:free 盒子在两个文件里位置不同,但大小都在。 这说明「搬移」不是「删了再写」,是「原地换位置 + 留个空位」。 所以它确实需要能随机写的文件——推到管道里(比如直接推到 stdout)就没法 faststart 了。

30 秒实验

  1. 用 +faststart 压一段更大的视频,看封装时间多了多少。
  2. 算一下:一个 100 MB 的文件,moov 在 90% 处,在 1 MB/s 的网络下要等多久才能起播?
  3. 判断一下:本地录像的文件要不要加 faststart?为什么?

常见误区

  1. 以为 faststart 是「压缩」。 它只搬 moov 的位置,文件大小完全不变。
  2. 以为 moov 会挪到最前面。 它挪到 ftyp 后面,因为 ftyp 必须第一个。
  3. 所有文件都加 faststart。 要额外读写一遍,本地录像和存档母版没必要。
  4. 以为不加 faststart 就一定慢。 服务端支持 Range 请求时,播放器可以先去尾部取 moov。 但那是依赖服务端的保险。
自测三问(点开对答案) 1. mp4 的盒子结构是什么? —— 每个盒子是「4 字节长度 + 4 字节类型 + 内容」,可以嵌套。`moov` 存索引、`mdat` 存数据。 2. faststart 具体做了什么? —— 写完文件后把 `moov` 从尾部搬到 `ftyp` 后面,并把内部偏移全部加一遍, 让播放器一下就能拿到索引。 3. 为什么默认封装会把 `moov` 放在尾部? —— 因为编码时不知道索引会多大,必须等所有帧写完才能算出索引内容,所以只能「先写数据、 后补索引」。

命令行这一层看完了,下面进入 API。先不写代码,先把「一次播放要经过哪八步」画出来——这个骨架后面会反复用。

相关笔记