解码看上去是「一个函数把文件变成画面」,其实是四步。 四步里前两步只做一次,后两步每个包都要做一遍——搞清这一点,就知道该优化哪里。
第 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 秒实验
- 把收尾那三行删掉,看解出几帧——应该是 28,而且不报任何错。
- 把
for fr in pk.decode()换成「每次只取一帧」,看会丢多少帧。 - 用一段带音频的 MP4 跑一遍,看要不要给每个流各建一个解码器。
常见误区
- 以为送一个包就出一帧。 开局和收尾都不成立,所以必须用 while 循环取到自己说「没了」。
- 以为
avcodec_decode_video2只是改了个名字。 它被彻底删了,照抄老教程编译不过。 - 以为不冲空包只是少一帧无所谓。 少的那几帧正好是片尾,监控里就是「最后几秒没录上」。
自测三问(点开对答案)
1. 为什么 30 个包只解出 28 帧? —— B 帧要等后面的帧到齐才能确定显示顺序,**所以解码器尾巴上压着几帧**,要冲空包才吐出来。 2. ①② 为什么可以只做一次? —— 它们只是读文件头、建解码器,**和帧数无关**。片长从 10 秒变 1 小时,这部分开销不变。 3. 送一个包、只 receive 一次,会怎样? —— 能跑,但会丢帧——**有的包一次能出两帧**(B 帧重排后),只取一帧就漏了。下一节把这四步拆成十个 API,逐个说清谁调用一次、谁调用每帧。
- ☐ (本节完,下一节 04.04.02 视频解码流程及主要API)