解码器是个漏斗:上面倒包,下面接帧,中间有缓冲。 送一个包不一定收一帧,写成一对一会丢数据。
4.2 的链里,这一节放大「解码」这一环。 下一篇 4.4 是它的镜像:编码方向。
一、为什么不能一送一收
C 侧用 avcodec_send_packet 与 avcodec_receive_frame 这一对。 看着像一问一答,其实不是。解码器内部有缓冲:送进去的包要先攒够参考帧,才能吐出第一帧。 位置 送 1 包收几帧 原因
开头 0 帧 攒参考帧 中段 1 帧 正常节奏 大切片 多帧 一个包里多个帧 收尾 最后几帧 冲空包之后
正确写法只有一种:送完就收,收到空为止;最后冲一次空包,把缓冲里剩下的收干净。 写成「送一个收一个」的循环,在真实素材上会丢帧,尤其是开头与结尾。 这个错不会报异常,只是少了帧。
二、三种缓冲状态要分清
C 侧的返回码直接对应三种状态,认清了就不会写错。 返回码 含义 该做什么
大于等于 0 收好一帧 存起来继续收 AVERROR(EAGAIN) 暂时没有 回去再送包 AVERROR_EOF 彻底没有 结束
EAGAIN 是「等一下」,EOF 是「没有了」,两者不能混。 把 EAGAIN 当结束会提前收工, 把 EOF 当等一下会死循环。 PyAV 把这两个都消化掉了:EAGAIN 变成「这次没帧」的空返回,EOF 变成迭代器停止。 所以 Python 侧看不到这两个码,代价是也看不见「解码器现在在等什么」。
三、实测:每包解出几帧
这个文件上,第一个包解出 1 帧,后面每个包 1 帧。 看起来很整齐,但那是因为 I 帧一开始就有基准。 带 B 帧的素材开头会先攒几帧, 中途还会出现「送进去 0 帧出来」。 素材类型 典型配对
无 B 帧(本文件) 一包一帧,开头也不延迟 有 B 帧 开头攒几帧,之后一包一帧 低码率大切片 一包可能解出多帧
怎么知道自己手上的素材是哪一类:看 codec_context 的max_b_frames 与 has_b_frames。 本文件两个都是 0 与 False。 这两个字段在解码侧是可读的,不像 gop_size 那样被拒绝访问——因为它们描述的是码流本身, 不是编码器的设定。
四、解码器是有状态的
这一点很多人忽略:解码器记着上一帧的样子。 P 帧与 B 帧都是差分编码,要参考前面的帧。 所以同一个包,在不同时刻喂进去,解出来的画面可能不一样。 操作 会发生什么
从中间开始解 花屏,直到下一个关键帧 跳着解 后面的帧全错
这就是拖进度条要先找关键帧的原因。 从任意位置开始解是不行的, 必须回到最近的那个 I 帧重新开始。 PyAV 的 seek 帮你做了这件事,但要知道它找的是关键帧,所以实际落点与你要求的时刻会有偏差。
五、动手 Demo:解码流程——送一次不一定收一次
数清每一次送包与收帧的配对关系。
import av
ic = av.open("_media32/tiny.mp4")
st = ic.streams.video[0]
got = []
for p in ic.demux(st):
if not p.size:
continue
got.append(len(p.decode()))
print("有效包", len(got), "帧", sum(got))
print("每包帧数", got[:16])
print("零帧的包", len([x for x in got if x == 0]))
ic.close()
实跑输出:
--------------------------------------------------------------------
解码流程——送一次不一定收一次
--------------------------------------------------------------------
**① 解码器是个漏斗:上面倒包,下面接帧,中间有缓冲。**
C 侧写 avcodec_send_packet 与 avcodec_receive_frame 这一对,最难理解的是「为什么不能一送一收」。
答案是有缓冲:
情况 送 1 包收几帧 什么时候
---------------------------------------------------------
开头 0 帧 解码器要攒够参考帧
中间 1 帧 最常见
一个包装多帧 多帧 低码率大切片
收尾冲空包 剩下的几帧 送 None 之后
**② 真跑一次,数清配对**
项 数量
------------------------
demux 迭代 51 次
其中有效包 50 个
解出的帧 50 帧
**每包解出的帧数,前十六个:**
1、1、1、1、1、1、1、1、1、1、1、1、1、1、1、1
每包帧数 包数 说明
------------------------------------------
0 帧 0 个 攒着,等参考帧
1 帧 50 个 最常见
多于 1 帧 0 个 大切片
**这个文件第一个包解出 1 帧,后面每个包 1 帧。** 第一个包是 I 帧,解完就有了基准,后面跟上。
**③ 为什么这一节要单独讲**
因为这里是全课最容易写出错的地方。
**写成「一个包换一个帧」的循环,在真实素材上会丢帧**,尤其是开头与结尾。
**正确写法只有一种**:送完就收,收到空为止;最后再冲一次空包,把缓冲里剩下的收干净。
**PyAV 的 packet.decode() 已经把这一套包好了**,所以看不到。要理解它,得回到 C 侧的两函数模型。
这个文件上首包就出帧,因为没有 B 帧。 换成带 B 帧的素材,开头会出现 0。
30 秒实验
- 打印
cc.max_b_frames与cc.has_b_frames,确认有没有 B 帧。 ic.seek(1000000)跳到第 1 秒,看第一帧的 pict_type 是不是 I 帧。- 把
len(p.decode())换成sum(1 for _ in p.decode()),验证结果相同。
常见误区
- 送一个包就收一帧。 真实素材上开头与结尾会丢帧,且不报错。
- 把 EAGAIN 当结束。 会提前收工,少解一大段。
- 从任意位置开始解。 解码器有状态,必须从关键帧起。
自测三问(点开对答案)
1. 为什么送进去的第一个包可能一帧都收不到? —— 解码器要攒够参考帧才能吐第一帧。本文件没有 B 帧,所以首包就出帧。 2. EAGAIN 与 EOF 有什么区别? —— EAGAIN 是「暂时没有,等下再要」,EOF 是「彻底没有了」。 3. 拖进度条为什么要先找关键帧? —— 解码器有状态,P 帧与 B 帧要参考前面的帧,从中间开始会花屏。- ☐ (本节完,下一节 4.4 FFmpeg 的编码流程分析)