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 秒实验
- 把收尾那三行注释掉再跑一次,看帧数变成多少、总时长少多少毫秒。
- 把
pk.decode()换成c.decode(s)(容器级接口),看帧数是不是一样——体会「谁替你冲了空包」。 - 查一下
av.library_versions,把 libavformat/libavcodec 的版本打出来, 和你文档里写的版本对一下。
常见误区
- 以为读一个包就该出一帧。 实测 60 包出 58 帧(还要靠收尾补 2 帧)。 必须用「喂进去、往外取」的二重循环。
- 以为忘了冲空包只会少一点。 少的是最后那零点几秒,而且不报错——这是最阴的一类 bug。
- 以为先全解完再播也行。 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 方式。
- ☐ (本章完,下一节 03.01 FFmpeg的命令行方式处理摄像头)