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

解码看上去是「一个函数把文件变成画面」,其实是四步。 四步里前两步只做一次,后两步每个包都要做一遍——搞清这一点,就知道该优化哪里。

📝 本节和前面几节的关系

第 04.01~04.03 讲的都是命令行,这一节开始进代码,讲 API 怎么排。 解码原理(DCT、运动补偿)在 26 课,这里只讲调用顺序。

一、四步是什么

步 做什么 对应 API
① 打开容器,拿到流参数 avformat_open_input
② 按参数建解码器 avcodec_alloc_context3
③ 送包进去 avcodec_send_packet
④ 取帧出来 avcodec_receive_frame

① 和 ② 是「建房子」,③ 和 ④ 是「住进去」。前者只做一次,后者每个包都做。 很多教程把四步写成一次函数调用(比如 avcodec_decode_video2), 那个函数在 FFmpeg 4.0 之后已经删了,现在必须按 send/receive 两步走。

二、③④ 是「一问一答」,不是「一进一出」

这是最容易理解错的一点:送一个包进去,不保证立刻出一帧。

情况 送 1 个包 取出几帧
刚开始(还没攒够参考帧) 送 1 个包 0 帧
正常推进 送 1 个包 1 帧
收尾(冲空包) 送 None 尾巴上压着的几帧

所以标准写法是「送一个包,然后把能取的都取完」:用 while 循环反复 receive, 直到它说「现在没有」。 只 receive 一次是常见 bug:能跑,但会丢帧,而且不报错。

三、实测:时间花在哪一步

30 个包的一段裸流,逐步计时:

步 耗时量级 拿到什么
① 开容器 几毫秒 流参数、时间基
② 取流 零点几毫秒 宽高、编码名
③④ 送与取 十几毫秒 30 包 → 28 帧
收尾 零点零几毫秒 又补出 2 帧

① 那一格每次跑都不一样(几毫秒到十几毫秒),因为它要读文件头、探流参数,受磁盘缓存影响很大。 ② 几乎不花时间,因为参数在 ① 里已经探好了。 ③④ 才是真正的解码,占绝大部分时间。所以播放器里 ①② 只做一次,③④ 每帧都做。

四、为什么尾巴上会压着帧

30 个包,当场只解出 28 帧,剩下 2 帧要冲空包才出来。 这不是 bug,是 B 帧的延迟:解码器要等后面的帧到了,才能确定前面那帧什么时候显示, 所以尾巴上总压着几帧。

冲空包就是告诉解码器「没有新数据了,把剩下的都吐出来」: 送一个 NULL 包(PyAV 里是 decode(None))。 不冲就少 2 帧,而且不报错——和 02 章 D25 那个坑一模一样。

这条规矩不只解码有:编码器也要冲空包(否则少最后一帧), 重采样器也要冲空包(否则尾巴上有残留)。凡是带缓冲的环节,收尾都要冲。

五、动手 Demo:解码四步,每步都在干什么

Demo 把四步分开计时,最后再冲一次空包,把尾巴上那 2 帧逼出来。 用的是一段 30 帧的 h264 裸流,和 04.01.01 编出来的那份是同一份。

c = av.open("in.h264")            # ① 开容器
s = c.streams.video[0]            # ② 取流(参数已探好)
n_out = 0
for pk in c.demux(s):
    if pk.size == 0:
        continue
    for fr in pk.decode():         # ③④ 送包 + 取帧
        n_out += 1
for fr in s.codec_context.decode(None):   # 收尾冲空包
    n_out += 1
c.close()

实跑输出:

--------------------------------------------------------------------
解码四步,每步都在干什么
--------------------------------------------------------------------
**解码看起来是「一个函数把文件变成画面」,其实是四步。**

  步    做什么                        对应 API
  --------------------------------------------------------------
  ①     打开容器,拿到流参数          `avformat_open_input`
  ②     按参数建解码器                `avcodec_alloc_context3`
  ③     送包进去                      `avcodec_send_packet`
  ④     取帧出来                      `avcodec_receive_frame`

  **③④ 是「一问一答」的关系,不是「一进一出」。**送一个包可能取到零帧,也可能取到两帧。

  步          耗时          拿到什么
  ----------------------------------------------------
  ① 开容器    11.29 毫秒    流参数、时间基
  ② 取流      0.05 毫秒     宽高、编码名
  ③④ 送与取   9.91 毫秒     30 包 → 28 帧
  收尾        0.03 毫秒     又补出 2 帧

  **30 个包,当场只解出 28 帧,剩下 2 帧靠收尾才出来。**
  **这不是 bug,是 B 帧的延迟**:解码器要等后面的帧到了,才能确定前面那帧什么时候显示,
  **所以尾巴上总压着几帧。**
  **不冲空包就少 2 帧,而且不报错**——和 02 章 D25 那个坑是同一个。

  **③④ 那一步占了几乎全部时间**(9.91 毫秒)——这才是真正「解码」的地方。
  **① ② 是「建房子」,③④ 是「住进去」。**所以播放器里 ① ② 只做一次,③④ 每帧都做。

for fr in pk.decode() 这个写法是「送一个包、把能取的都取完」的写法, PyAV 已经帮你包好了循环。C 里要自己写 while (receive_frame >= 0)。 28 + 2 = 30 这个等式要记住:少的那 2 帧不是丢了,是压在解码器里。 少一帧不报错,所以只能靠数数发现。

30 秒实验

  1. 把收尾那三行删掉,看解出几帧——应该是 28,而且不报任何错。
  2. 把 for fr in pk.decode() 换成「每次只取一帧」,看会丢多少帧。
  3. 用一段带音频的 MP4 跑一遍,看要不要给每个流各建一个解码器。

常见误区

  1. 以为送一个包就出一帧。 开局和收尾都不成立,所以必须用 while 循环取到自己说「没了」。
  2. 以为 avcodec_decode_video2 只是改了个名字。 它被彻底删了,照抄老教程编译不过。
  3. 以为不冲空包只是少一帧无所谓。 少的那几帧正好是片尾,监控里就是「最后几秒没录上」。
自测三问(点开对答案) 1. 为什么 30 个包只解出 28 帧? —— B 帧要等后面的帧到齐才能确定显示顺序,**所以解码器尾巴上压着几帧**,要冲空包才吐出来。 2. ①② 为什么可以只做一次? —— 它们只是读文件头、建解码器,**和帧数无关**。片长从 10 秒变 1 小时,这部分开销不变。 3. 送一个包、只 receive 一次,会怎样? —— 能跑,但会丢帧——**有的包一次能出两帧**(B 帧重排后),只取一帧就漏了。

下一节把这四步拆成十个 API,逐个说清谁调用一次、谁调用每帧。

相关笔记