🎬 第四章 精通FFmpeg框架流程:击鼓传花之责任链设计模式
📄 一句话概括

解码器是个漏斗:上面倒包,下面接帧,中间有缓冲。 送一个包不一定收一帧,写成一对一会丢数据。

📝 本节和前面几节的关系

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 秒实验

  1. 打印 cc.max_b_frames 与 cc.has_b_frames,确认有没有 B 帧。
  2. ic.seek(1000000) 跳到第 1 秒,看第一帧的 pict_type 是不是 I 帧。
  3. 把 len(p.decode()) 换成 sum(1 for _ in p.decode()),验证结果相同。

常见误区

  1. 送一个包就收一帧。 真实素材上开头与结尾会丢帧,且不报错。
  2. 把 EAGAIN 当结束。 会提前收工,少解一大段。
  3. 从任意位置开始解。 解码器有状态,必须从关键帧起。
自测三问(点开对答案) 1. 为什么送进去的第一个包可能一帧都收不到? —— 解码器要攒够参考帧才能吐第一帧。本文件没有 B 帧,所以首包就出帧。 2. EAGAIN 与 EOF 有什么区别? —— EAGAIN 是「暂时没有,等下再要」,EOF 是「彻底没有了」。 3. 拖进度条为什么要先找关键帧? —— 解码器有状态,P 帧与 B 帧要参考前面的帧,从中间开始会花屏。

相关笔记