解码循环只有四句:读包、发码、收帧、处理。 收帧要写成循环,收尾要冲空包——这两件事各能让帧数少一截。
上一节把环境配好了,这一节把代码写完。 这一节之后,你就能自己写一个「能数出帧数」的最小解码器,而它是第八章那个播放器的内核。 重点不是记住代码,而是记住两个「必须」:必须循环收帧、必须冲空包。
一、四句话的循环
while (av_read_frame(fmt, pkt) >= 0) {
if (pkt->stream_index == vst->index) {
avcodec_send_packet(dec, pkt);
while (avcodec_receive_frame(dec, frame) == 0) {
// 处理 frame
}
}
av_packet_unref(pkt);
}
// 收尾:一定要冲空包
avcodec_send_packet(dec, NULL);
「必须写成 while」这件事在 Demo 里能量出来:90 个包里,有 2 个包一帧都没解出来, 它们的内容被攒着,等后面的包来了才吐。 如果你写成「一个包取一帧」,这 2 帧就丢了。
为什么会有「解不出帧的包」:B 帧要参考「它后面那一帧」, 所以解码器必须先收到后面的帧才能解它。 这在压缩视频里是常态,不是异常。
av_packet_unref 那一句也不能漏:av_read_frame 给的包带着一块引用计数的缓冲,
不 unref 就每帧漏一份内存。
一个 1080p 的包几十 KB,60 fps 下每秒漏几 MB,几分钟就能把内存吃光。
二、冲空包:本课第三次遇到同一件事
Demo 里的数是这样的:循环里解出 88 帧,冲空包又出来 2 帧,合计 90 帧。
注意这 2 帧跟上面那 2 个「解不出帧的包」是同一件事的两面: 内容进了解码器,但要等「后面没有更多包了」这个信息到了才吐出来。 而这个信息只能靠「发一个空包」来告诉它。
这件事在本课里已经是第三次:
音频重采样:swr_convert(None),不冲少几十个样本。
解码 B 帧:avcodec_send_packet(NULL),不冲少几帧。
滤镜链:av_buffersrc_add_frame(NULL),不冲少最后几帧。
规律可以提炼成一句:所有带缓冲的转换器,都有一个「冲空」的动作,而且不冲不报错。 所以写法上应该固定成「循环结束后无条件冲一次」,哪怕你「确定」已经读完了。
为什么「不报错」:因为「少输出几帧」在接口层面是合法的——它不知道你是漏了还是故意的。 这类 bug 的典型表现是「视频比音频短零点几秒」,而人在播放时基本看不出来,直到做时长比对才发现。
三、从解码到显示还差一步
解出来的帧是 YUV,屏幕要的是 RGB,所以还要一次转换:
| 路线 | 怎么做 | 代价 |
|---|---|---|
| CPU 转 | sws_scale |
1080p 几毫秒一帧 |
| 交给 GPU | 把 YUV 三平面直接给纹理 | 几乎为零 |
这一行在 06.04 已经量过: 软件渲染器下,喂 YUV 只省了「那一次 reformat」,因为渲染器内部又替你转了一遍;硬件渲染器下才是真的零成本。
所以「解码→显示」这条链的原则是:中间不要有任何「可以交给 GPU 的 CPU 计算」。 缩放、色彩转换、旋转,全都属于「GPU 顺手就能做」的那一类。
什么时候确实得在 CPU 上转:要拿帧做分析(人脸、车牌、AI 推理)的时候。 那时候你要的是「像素数据」,不是「显示出去」, 转换就跑不掉——但可以「降分辨率之后再转」,省一个数量级。
最后回到第八章的伏笔:ffplay 为什么能把 1080p 播得那么顺?
因为它从头到尾没转过格式——解码出来是 YUV,直接进 SDL 纹理,转换交给显卡。
第九章拆它的代码时,这条会反复出现。
四、动手 Demo:解码循环,一进多出与冲空包
这段把 90 帧的片子解一遍,统计每个包解出几帧的分布, 再对比「循环内」与「冲空包」各出了多少帧。 那张分布表会告诉你「一进一出」这个假设有多不可靠。
per = []
for pk in c.demux(s):
if not pk.size:
continue
got = list(cc.decode(pk))
per.append(len(got))
flush = len(list(cc.decode(None))) # 冲空包
print(Counter(per), flush)
实跑输出:
--------------------------------------------------------------------
解码循环,一进多出与冲空包
--------------------------------------------------------------------
**解码循环的骨架只有四句**:**读包、发码、收帧、处理。**
**问题是「收帧」这一步经常被写错。**
**先看真实的进出比例**:
测试片:90 帧、30 fps、**GOP = 30**
项目 数量
----------------------------------
送进去的包 90
解出来的帧(循环内) 88
**冲空包时又出来** **2**
合计 90
**每个包解出几帧,分布是这样的**:
一个包解出 出现了几次 说明
--------------------------------------------------------------
0 帧 2 次 **先攒着,等后面的帧来重排**
1 帧 88 次 最常见
**两件事从这里能看出来**:**第一,不是一个包一帧**——有 2 个包一个都没解出来;
**第二,最后要冲空包,否则会少 2 帧。**
**「一个包解不出帧」是正常的,不是错**:**B 帧要等它后面的参考帧到了才能解**,
**所以解码器会先攒着。**
**这类「攒着」的帧只在两种时候吐出来**:**后面来了新的参考帧,或者你冲空包。**
**「不冲空包就少帧」这件事,在本课里已经是第三次出现了**(音频 `resample(None)`、
解码 `decode(None)`、这里)。
**规律是:所有带缓冲的转换器,都有一个「冲空」的动作**,**而且不冲不报错。**
**所以写法上应该固定成**:**循环结束后无条件再来一次冲空包**,**哪怕你「确定」已经读完了。**
分布表里有两行:「0 帧」出现 2 次、「1 帧」出现 88 次。 没有「2 帧」——因为这段测试片没有 B 帧重排,所以不会出现「攒两帧一起吐」。
这正是「测试片太规整」的又一次体现: 换成带 B 帧的片子,你会看到「0 帧」和「2 帧」交替出现,但平均值仍然是 1。 所以「包数 ≈ 帧数」这个观察在总量上成立,在逐个上完全不成立。
「冲空包出来 2 帧」这个数要跟「0 帧出现 2 次」对上:2 个包当时没吐,最后一起吐了 2 帧。 数目对得上,说明没有丢帧。 这种「进出账要对平」的检查习惯,在处理流的时候非常有用。
最后回到那个「第三次」的提醒:音频不冲空包会少样本、解码不冲会少帧、滤镜不冲会少帧, 三处都不报错。 所以「收尾必冲」这条,值得写进代码模板里,而不是靠每次想起来。
30 秒实验
- 把收帧那行改成「只取第一帧」,看总数少多少。
- 算一下:一个 1080p 的包平均 50 KB,60 fps 下不
unref每秒漏多少内存? - 判断一下:为什么有些包一个帧都解不出来,这算坏数据吗?
常见误区
- 一个包只取一帧。 有 B 帧时会丢帧。必须
while循环收。 - 忘了冲空包。 会少最后几帧,而且不报错。
- 忘了
av_packet_unref。 每帧漏一块缓冲,几分钟吃光内存。 - 在 CPU 上转格式再交给 GPU 显示。 多一次逐像素计算,应该直接把 YUV 三平面给纹理。
自测三问(点开对答案)
1. 为什么收帧要写成 `while` 循环? —— 因为一个包可能解出零帧、一帧或多帧。写成「一个包取一帧」会丢帧。 2. 冲空包的作用是什么? —— 告诉解码器「没有更多数据了」,让它把攒着的帧全吐出来。不冲会少最后几帧,而且不报错。 3. 为什么不要用 CPU 把 YUV 转成 RGB 再显示? —— 因为 GPU 采样时能顺手做色彩转换,几乎零成本。 在 CPU 上做是多一次逐像素计算,还多一倍上传带宽。第七章到此为止。八节走完一条线:
框架、命令行、码流、容器、API 流程、环境、实战。下一章开始拆 ffplay——那个把这一章全部内容串起来的示例程序。
- ☐ (本章完,下一节 08.01 FFplay播放器简介)