📹 第四章 H.264/H.265视频编码并存储
📄 一句话概括

在 04.05.01 那条链上多套一层容器,就得到能拖进度条的文件。 多出来的动作只有四个:建轨、给 pts、每包 mux、关文件写索引。

📝 本节和前面几节的关系

上一节出的是裸流,这一节出 MP4。编码器完全没变,变的是外面的壳。 转封装的对比(不解码不编码)在下一节,两节合起来才能看清「封装」到底值多少。

一、四个动作

步 动作 对应 API
① 另存为 MP4,建视频轨 avformat_alloc_output_context2
② 每帧给 pts frame->pts = i
③ 每包都送进容器 av_interleaved_write_frame
④ 关文件写索引 av_write_trailer

这四步里有三个坑,都是「不写不报错,写完才发现」的类型:

| 漏了哪步 | 表面现象 | 真相 | |---|---|---|---| | 给 pts | 能编码,文件也有 | 时长算成 0,播放器按默认帧率猜 | | 收尾冲空包 | 少一帧,看不出 | 最后几帧压在编码器里没出来 | | close/trailer | 文件有大小 | moov 没写,整段打不开 |

第三行最要命:close() 之前文件头是残缺的。 在 Windows 上尤其容易忘,因为进程退出不一定刷盘。

二、实测:多了不到 1.2 KB

项 裸流 MP4
文件大小 10.4 KB 11.6 KB
包数 30 30
时长 不可知 1.00 秒
能拖进度 不能 能(有索引)
首帧 pts None 0

容器只多花了 1 KB 出头,换来的是「时长已知」和「能拖进度」。 这笔买卖在任何场景下都划算——所以工程里几乎不会输出裸流。

「时长」这一行是裸流永远的痛:h264 裸流一个字节的时间信息都不存, ffprobe 只能按码率估、或者数帧数除帧率;封装之后时长就是文件里写死的一个数,不用猜。

三、moov 在哪,决定能不能边下边播

box 放什么 位置
ftyp 品牌声明(isom/mp42) 文件头,偏移 4
mdat 真正的编码数据 紧跟着头
moov 索引:每帧时间、偏移、是否关键帧 默认在文件尾

moov 默认排在 mdat 后面,这就是普通 MP4 的老毛病: 播放器要播得先读到索引,索引在文件尾,所以网络播放必须把整个文件拉完才敢开播。

这就是 -movflags faststart 存在的理由:它写完之后把 moov 挪到前面, 代价是多读一遍文件。 监控的远程回放几乎一定要开这个——否则用户点开要等整段下载完。

四、三个数要对得上

30 帧进、30 帧出、1.00 秒,三个数能对上,说明 pts 编对了。 对不上就是 pts 有洞(跳过号)或者重复:

症状 原因 查法
帧数少 pts 重复,被容器合并 打印每帧 pts
时长偏短 pts 跳号(漏了号) 看最后一个 pts 是不是 n−1
时长偏长 pts 从 1 开始而不是 0 看首帧 pts

第一行最阴:MP4 的索引按时间排,两个帧时间戳一样,后一个会被丢掉,而且播放器不会报错, 只是「少了一帧」。

五、动手 Demo:YUV 编码成 MP4,一次把「编码 + 封装」走完

Demo 编 30 帧进 MP4,然后把文件当二进制读一遍,找出 ftyp/moov/mdat 的偏移。 最后再解回来数帧数,验证三个数对得上。

out = av.open("out.mp4", "w")
st = out.add_stream("libx264", rate=30)
st.width, st.height, st.pix_fmt = 320, 240, "yuv420p"
st.options = {"crf": "23"}
for i in range(30):
    fr = av.VideoFrame.from_ndarray(frame(i), format="yuv420p")
    fr.pts = i
    for pk in st.encode(fr):
        out.mux(pk)
for pk in st.encode(None):
    out.mux(pk)
out.close()                       # 这一句才写 moov
data = open("out.mp4", "rb").read()
print(data.find(b"mdat"), data.find(b"moov"))

实跑输出:

--------------------------------------------------------------------
YUV 编码成 MP4,一次把「编码 + 封装」走完
--------------------------------------------------------------------
**上一节(04.05.01)编出来的是裸流,这一节把同样 30 帧编成 MP4。**

  两者差的不是编码器,是 **「多了一个容器」**——编码器算出 nal 单元,容器负责把它们排好、加索引、
  写时长。

**和上一节的裸流版比,只多了四个动作,但一个都不能少:**

  步    动作                      对应 API
  ------------------------------------------------------------------
  ①     另存为 MP4,建视频轨      `av.open(…, "w")` + `add_stream`
  ②     每帧给 pts                `frame.pts = i`
  ③     每包都送进容器            `av_interleaved_write_frame`
  ④     关文件写索引              `av_write_trailer`

**这四步里有三个坑,都是「不写不报错,写完才发现」的类型:**

  漏了哪句              表面现象              真相
  ----------------------------------------------------------------------------
  `fr.pts`              能编码,文件也有      时长算成 0,播放器按默认帧率猜
  收尾 `encode(None)`   少一帧,看不出        最后几帧压在编码器里没出来
  `out.close()`         文件有大小            moov 没写,整段打不开

  **第三行最要命**:`close()` 之前文件头是残缺的,这一步在 Windows 上尤其容易忘,
  因为不 `close()` 时进程退出也不一定刷盘。

**实测:**

  项                裸流                MP4
  ----------------------------------------------------------------
  文件大小          10.4 KB             11.6 KB
  包数              30                  30
  时长              不可知              1.00 秒
  能拖进度          不能                能(有索引)
  首帧 pts          None                0

  **「时长」这一行是裸流永远的痛**:h264 裸流一个字节的时间信息都不存,
  所以 `ffprobe` 只能按码率估、或者数帧数除帧率。
  **封装之后时长就是文件里写死的一个数**,不用猜。

**再看文件里两个 box 的位置**——这是「能不能边下边播」的关键:

  box       偏移          放什么
  ----------------------------------------------------------
  `ftyp`    4             品牌声明(isom/mp42)
  `moov`    10710         索引:每帧时间、偏移、是否关键帧
  `mdat`    44            真正的编码数据

  **`moov` 排在 `mdat` 后面**——这就是普通 MP4 的老毛病:播放器要播得先读到索引,索引在文件尾,
  所以网络播放必须把整个文件拉完才敢开播。
  **这就是 `-movflags faststart` 那个参数存在的理由**:它在写完之后把 `moov` 挪到前面,
  代价是多读一遍文件。

**再确认一遍「编进去的确实是画面」,读回来解一遍:**

  项                值
  ----------------------------------------------------
  解出帧数          30
  尺寸              320 × 240
  像素格式          yuv420p
  视频轨时间基      1/15360
  编码 + 封装耗时   67 毫秒

  **30 帧进、30 帧出、1.00 秒**——三个数能对上,说明 pts 编对了。
  **对不上就是 pts 有洞(跳过号)或者重复。**

moov 的偏移比 mdat 大,说明索引在文件尾——这就是「网络播放要先下完」的物理原因。 ftyp 的偏移是 4,因为前面 4 个字节是 box 长度, box 的格式永远是「4 字节长度 + 4 字节类型 + 内容」。看懂这一条,就能自己写个 MP4 结构查看器。

30 秒实验

  1. 把 out.close() 注释掉,重跑,看文件还能不能打开。
  2. 给所有帧 fr.pts = i // 2(两帧同一个 pts),看重跑时解出几帧。
  3. 用 -movflags faststart 那个思路,写完再手动把 moov 挪前面,看播放器起播变化。

常见误区

  1. 以为封装会改动编码数据。 一个字节都不改,只是加头和索引。
  2. 以为不 close 也能打开。 没有 moov,整段没法解,文件管理器看大小还是正常的。
  3. 以为 pts 随便给都行。 重复会被合并、跳号会让时长不对,而且都不报错。
自测三问(点开对答案) 1. 封装多花了多少字节? —— 实测 10.4 KB → 11.6 KB,**大约 1.2 KB**,换来的是时长和索引。 2. 为什么普通 MP4 不能边下边播? —— `moov` 索引默认在文件尾,**播放器要先读索引才知道每帧在哪**,所以要等下完。 `-movflags faststart` 把索引挪到前面就能边下边播。 3. 两帧 pts 一样会怎样? —— MP4 的索引按时间排,**后一帧会被丢掉**,播放时不报错,只是少一帧。

下一节反过来:手上有 H.264 裸流,不解码不编码,直接塞进 MP4。

相关笔记