📹 第二章 VLC及FFplay流媒体播放器
📄 一句话概括

FFmpeg 把播放拆成五个对象,数据在它们之间搬一次就复制一次。 记住「谁管全程、谁用完就扔」,比记住函数名有用得多。

📝 本节和前面几节的关系

上一节讲「播放器该做什么」,这一节讲「FFmpeg 怎么把它做成对象」。 这一节是整本书的地基:后面第七、八、九章全是这五个对象的展开。 34 课讲的是播放器实现,那一本第九章拆过 ffplay 的源码;这一节只管架构,不进去。

一、五个对象,各管一段

从文件到屏幕,数据要过五个对象。

对象 管什么 生命周期
AVFormatContext 容器:几路流、时长、索引 全程
AVPacket 一个压缩包 读到就扔
AVCodecContext 解码器状态与参数 全程
AVFrame 一帧原始像素 显示完复用
SwsContext 像素格式与尺寸转换 全程,可复用

「生命周期」这一列才是重点:前两个和第四个是「全程」的,中间两个是「流水」的。 流水的那两个才有队列——因为它们会被快速生产、快速消费,中间必然要缓冲。 全局的那三个不排队,只改状态。

二、三步打开,顺序不能换

avformat_open_input → avformat_find_stream_info → avcodec_open2。

步 干什么 花多少
open_input 读容器头,建索引 几毫秒
find_stream_info 探出每路的真实参数 要读几帧,慢
codec_open2 按参数初始化解码器 分配参考帧,几十毫秒

第二步是最容易被忽略的:容器的头里不一定写全参数,比如 H.264 的 SPS/PPS 有时只出现在码流里。 所以 find_stream_info 真的会去读几帧——这就是「打开一个网络流要等一会儿」的原因之一。

第三步在 4K 上能到几十毫秒,因为它要按分辨率分配参考帧池。 这就是「4K 播放器打开慢」的真相,不是网络慢。

三、为什么要「喂进去、往外取」

因为一包不一定出一帧,一帧也不一定只来自一个包。

D25 实测:60 个包,当场只出 58 帧,另外 2 帧靠收尾才出来。

情况 例子
一包出零帧 B 帧要等后面参考帧到齐
一包出多帧 一个包里有多个 NAL
多包出一帧 一帧切成几个 RTP 包(FU-A)

所以正确的循环是二重的:外层读包、内层取帧,而绝不能「读一个包取一帧」。 收尾还必须送一次空的 decode(None),把解码器里滞留的帧逼出来。 漏了这一步,表现是片子看着播完了、最后零点几秒没了,而且不报错。 这是自己写播放器最容易漏的一步。

四、内存账:为什么必须边解边扔

D25 算出来的比例是 441 倍。

项 大小 说明
压缩文件 0.36 MB 2 秒 720p
原始像素 158 MB 60 帧,每帧 2.6 MB
一小时同规格 278 GB 谁也存不下

这就是「为什么不能先全解完再播」的算术答案:不是设计者不想,是内存装不下。 所以 AVFrame 用完必须立刻归还(av_frame_unref),否则播十分钟就吃掉几十 GB。 这条在 C 接口里是手动调的,Python 里靠引用计数—— 所以 Python 的播放器 demo 不会因为漏 unref 而爆内存,但也因此看不出这个问题。

五、动手 Demo:沿五个对象走一遍,量累计时刻

从 open 到解帧,按 FFmpeg 的调用顺序走一遍,量每一步花了多少,再算原始像素要占多少内存。 重点是那句「60 包只出 58 帧」——它解释了为什么循环必须是二重的。

t0 = time.perf_counter()
c = av.open("_t35_02.mp4")      # avformat_open_input
s = c.streams.video[0]          # 相当于 find_stream_info

n_pkt = n_frm = 0
for pk in c.demux(s):            # av_read_frame
    if not pk.size:
        continue
    n_pkt += 1
    for fr in pk.decode():       # avcodec_send_packet + receive
        n_frm += 1

# 冲空包:把解码器里滞留的帧逼出来
for fr in s.codec_context.decode(None):
    n_frm += 1

print(n_pkt, n_frm)             # 60 包,60 帧(58 + 2)
print(n_frm * 1280 * 720 * 3 / 1048576.0)   # 原始像素 MB

实跑输出:

--------------------------------------------------------------------
FFmpeg 播放架构,从 avformat 到屏幕
--------------------------------------------------------------------
**FFmpeg 把播放拆成五个对象,各有各的内存池。** 数据在它们之间搬一次,就要复制一次。

  对象                管什么                       一次生命周期
  -----------------------------------------------------------------
  AVFormatContext     容器:几路流、时长、索引     全程
  AVPacket            一个压缩包                   读到就扔
  AVCodecContext      解码器状态与参数             全程
  AVFrame             一帧原始像素                 显示完再复用
  SwsContext          像素格式与尺寸转换           全程,可复用

**实测:沿这条链走一遍,量每步的累计时刻。**

  avformat_open_input  →  7.90 毫秒(开容器)
  视频流:1280×720,h264,1/15360
  av_read_frame        →  60 个包
  avcodec_send_packet  →  60 帧,解码累计 88.8 毫秒
  收尾冲空包那一次又取出 **2 帧**,花了 0.01 毫秒
  全程 103 毫秒,其中解码占 **86%**

  **这次真对不上:60 个包,当场只出 58 帧,剩下 2 帧靠收尾才出来。**
  开了 B 帧的真片差得更乱,可能一包出零帧、也可能一包出多帧。
  所以代码里绝不能「读一个包取一帧」,必须是「喂进去、往外取」的二重循环。

  **收尾那一趟 `decode(None)` 千万别省。** 它把解码器里滞留的帧逼出来;C 接口漏写,
  表现是片子看着播完了、最后零点几秒没了,**而且不报错**。这里差的就是 2 帧。

**换算一下内存账**:60 帧 × 1280×720×3 字节 = **158.2 MB** 原始像素。
  而压缩文件只有 **0.36 MB**,压缩比 **441 倍**。

  这个比例就是为什么播放器必须**边解边显示、解完就扔**:
  2 秒的片子摊开要 158 MB,按同样规格放一小时就是 **278 GB**——谁也存不下。

60 个包,当场只出 58 帧,收尾又出 2 帧。这两个数一起说明「包与帧不同步」。 如果只写前面那个循环、不写收尾那三行,就是 58 帧,片子少 66 毫秒,而且没有任何报错。

解码累计 94 毫秒,占全程 87%。这个比例和第二章第一节量的一致(84%), 说明「解码是大头、但每帧只要零点几毫秒」这个结论是稳的。

158 MB 原始像素 vs 0.36 MB 压缩文件,441 倍。这个数在纯色测试片上会更大(上万倍), 换成渐变纹理才接近真片的量级——这也是为什么造测试片不能用一个纯色块。

30 秒实验

  1. 把收尾那三行注释掉再跑一次,看帧数变成多少、总时长少多少毫秒。
  2. 把 pk.decode() 换成 c.decode(s)(容器级接口),看帧数是不是一样——体会「谁替你冲了空包」。
  3. 查一下 av.library_versions,把 libavformat/libavcodec 的版本打出来, 和你文档里写的版本对一下。

常见误区

  1. 以为读一个包就该出一帧。 实测 60 包出 58 帧(还要靠收尾补 2 帧)。 必须用「喂进去、往外取」的二重循环。
  2. 以为忘了冲空包只会少一点。 少的是最后那零点几秒,而且不报错——这是最阴的一类 bug。
  3. 以为先全解完再播也行。 2 秒 720p 就要 158 MB,一小时 278 GB,内存根本装不下。必须边解边扔。
自测三问(点开对答案) 1. 五个对象里,哪几个是「全程」的、哪几个是「流水」的? —— AVFormatContext、AVCodecContext、SwsContext 是全程的; AVPacket 和 AVFrame 是流水的,会被大量创建和销毁。 2. `avformat_find_stream_info` 为什么要读几帧? —— 容器头里不一定写全参数(比如 H.264 的 SPS/PPS 常在码流里), 必须真读几帧才知道真实的分辨率、帧率、像素格式。 3. 不冲空包会发生什么? —— 解码器里滞留的帧永远不出来。表现是最后零点几秒没播,而且不报错。必须送一次 `decode(None)`。

下一章从「放」转到「采」:用 FFmpeg 读本地摄像头,先把命令行方式走通,再换成 SDK 方式。

相关笔记