播放器是一条五段流水:解封装、解码、转换、同步、渲染。 其中最容易被低估的是同步——它不调用任何库,却决定了「看起来顺不顺」。
前三节讲的是「数据是什么」「怎么传」。这一节开始讲「收到之后怎么办」。 这一节是全书的骨架图:后面第二章到第九章,讲的都是这条流水线上某一段的具体实现。 LibVLC 是把五段都包好了给你,FFplay 是把五段都摊开给你看。
一、一条流水线,五道工序
任何一个播放器,不管是 VLC、ffplay 还是你自己写的,做的都是这五件事:
| 工序 | 输入 | 输出 | 对应库 |
|---|---|---|---|
| 解封装 | 字节流 | 压缩包 | libavformat |
| 解码 | 压缩包 | 原始帧 | libavcodec |
| 转换 | 原始帧 | 屏幕要的格式 | libswscale |
| 同步 | 帧的时间戳 | 该等还是该丢 | 自己算 |
| 渲染 | 成品帧 | 屏幕上的像素 | SDL 或系统 API |
注意第四行「同步」没有对应的库——它是播放器自己写的一段逻辑,也是各家播放器差别最大的地方。VLC、ffplay、播放器厂商的差距,八成在这几十行里。
流水线的意思是可以并行:解码第 N 帧的时候,渲染线程正在显示第 N-1 帧, 网络线程正在收第 N+5 帧。三段同时跑,靠队列解耦。这就是第九章要拆的三条线程。
二、解封装:从容器里拆包
一个 MP4 文件里装着视频流、音频流、字幕流,还装着时间索引。 解封装就是把这些流拆开,一次吐出一个压缩包。
这一步的关键是「不碰内容」:它只管「这个包属于哪条流、它的时间戳是多少、它多大」, 完全不看包里是什么画面。所以它极快——实测只占整条流水的百分之几。
但它是唯一需要「理解容器结构」的一步,而容器的坑都在这儿: MP4 的 moov(索引)在文件头还是文件尾,决定能不能边下边播。 TS 是固定 188 字节一个包,天生适合丢包与中途接入。 FLV 是「标签流」,每个标签带自己的时间戳。
这也是为什么 ffmpeg -c copy 那么快:它只做解封装和重新封装,跳过解码与编码两步,
所以「转封装」几乎是零成本,「转码」才是真花时间。
三、解码:还原成一帧画面
解码是把「压缩后的描述」还原成「每个像素的颜色」。 这一步最贵,因为它要把编码时做的所有变换反向做一遍:反量化、反变换、运动补偿、去块滤波。
它有三个必须记住的性质: 第一,一个包不一定出一帧——B 帧需要等后面的参考帧,所以会缓存。 第二,一帧可能要几个包——一帧被切成多个 slice 时可以分包传输。 第三,流的最后一帧要「冲」出来——送一个空包让解码器把缓存吐干净。
第三条是最常见的 bug 来源:很多自写播放器播到最后少几帧,就是忘了冲空包。 32 课实测过:不冲少 48 帧,冲完正好 50 帧。
解码器还有「关键帧」的概念:只有关键帧能独立解出来,所以拖动进度条只能拖到关键帧, 这也是「拖到一个位置会先跳一下」的原因。
四、转换:把像素换个排法
解码器输出的是 yuv420p,屏幕要的是 rgb24(或它自己的格式)。 这一步就是色彩空间转换,顺带还能缩放。
它看起来简单,实际很贵——demo 里实测它占了整条流水的八成(虽然那是 Python 绑定的开销)。 原因是逐像素要做一次矩阵乘法:YUV 到 RGB 不是简单查表,每个像素的 RGB 都是 Y、U、 V 的线性组合。
两个优化方向: 能不给 CPU 就不给——把 YUV 直接交给 GPU,让着色器转(第六章 SDL 就是这么干的)。 必须给 CPU 时用查表——把 256 个 Y 值的结果预先算好,避免重复乘法。
五、同步:播放器真正的难题
声音和画面是两条独立的流,各有自己的时间戳。 「同步」就是让它们按时间戳对齐显示。
为什么这件事难: 解码速度不均匀——关键帧附近快,B 帧多的地方慢。 渲染节奏是固定的(屏幕 60 Hz),但帧率可能是 24、25、30、59.94。 24 不是 60 的约数,所以每一帧的显示时长都不能整除,必然有抖动。
工程上的做法是「以一条流为主钟」:通常以音频为主钟(因为人对声音的断续极度敏感), 视频按音频的时间走:落后太多就丢帧,超前太多就等一等。
两个阈值是行业惯例:差值在 10 毫秒以内不处理(看不出来), 超过 100 毫秒就动手(丢帧或重复帧)。 这两个数不是随便定的,是「人眼能察觉的最小差异」与「人能忍受的最大偏差」。
第九章整章都在讲这件事,因为它是播放器最难的部分。
六、渲染:最后一公里
渲染看起来只是「把图贴到窗口上」,但它决定了三个体验指标:撕裂、卡顿、功耗。
三种常见的做法: 直接往窗口 DC 上画(GDI):简单,但慢、且容易撕裂。 用 SDL 或 Direct3D 的纹理:走 GPU,能开垂直同步。 用 OpenGL 着色器:最灵活,能顺便做色彩管理。
「撕裂」的成因值得单独说:屏幕是从上往下一行行刷新的,如果中途换了帧,上半屏是新帧、 下半屏是旧帧。垂直同步就是让「换帧」发生在两帧刷新的间隙。
代价是延迟:开了垂直同步,一帧最多等 16.7 毫秒(60 Hz 下)。 这就是为什么电竞玩家关垂直同步——宁可撕裂,不要多等那十六毫秒。
七、动手 Demo:五段流水,各段吃掉多少时间
这段用 PyAV 真解一段 480×360、300 帧的视频,分别给解封装、解码、转换三段计时。 同步与渲染这两段在这里没有对应物——它们要靠真实的时钟与屏幕才能测,沙箱里只能讲原理。
it = c.demux(st)
while True:
t0 = time.perf_counter()
pkt = next(it, None) # 解封装
t1 = time.perf_counter()
if pkt is None:
break
frames = pkt.decode() # 解码
t2 = time.perf_counter()
for fr in frames:
fr.reformat(format=u"rgb24") # 转换
实跑输出:
--------------------------------------------------------------------
五段流水,各段吃掉多少时间
--------------------------------------------------------------------
**播放器不是「一步播出来的」,它是一条流水线。**
段 干什么 在 FFmpeg 里对应
----------------------------------------------------------------------------
解封装 从容器里拆出压缩包 av_read_frame
解码 压缩包还原成一帧画面 avcodec_send_packet/receive_frame
转换 像素格式与尺寸变换 sws_scale
同步 算这一帧该什么时候出现 自身逻辑,不调库
渲染 贴到窗口或交给声卡 SDL/Direct3D/OpenGL
**真跑 480×360、300 帧,逐段计时**:
段 累计毫秒 占比 每帧微秒
-----------------------------------------------------
解封装 14.1 2.8% 47
解码 84.4 16.6% 281
转换到 rgb24 410.7 80.7% 1369
合计 509.1 100.0% 1697
**一共解出 300 帧。**
解封装只占 2.8%,因为它只是「切包」,几乎不碰像素。
**转换反而占了 80.7%——这一项最容易被忽略。**
它只是换一种像素排列,却要逐像素重算一遍色彩矩阵。
不过要说清:这里量的是 PyAV 的 `reformat`,它每帧都新分配一块 ndarray 再逐像素转,
比 C 程序里直接调一次 `sws_scale` 慢。真实播放器把这一步整段丢给 GPU。
解码占 16.6%,看着不高,是因为 480×360 的帧本来就小。
**三个比例会随分辨率、编码档位、像素格式大幅变化,但顺序稳定:**
**解封装最便宜,解码与转换才是播放器真正吃 CPU 的两段。**
实测里转换占了大头,这跟「解码最贵」的直觉相反,原因值得说清楚:
PyAV 的 reformat 每帧都新分配一块 ndarray,再逐像素转,
比 C 程序里直接调一次 sws_scale 要重。所以这个比例不能当成「sws_scale 很慢」的证据。
能带走的是这个顺序: 解封装永远最便宜(它不碰像素),解码和转换才是吃 CPU 的两段。分辨率越大、像素格式越不匹配, 转换那一段越贵。
最后一行 dec.decode(None) 是冲空包,它把解码器里缓存的最后几帧吐出来。
少这一步,帧数就对不上。这段代码里它抛了 EOFError——说明之前的循环已经把帧全出完了,
这本身也是一个可观测的信号:冲空包抛错,意味着没有残留。
30 秒实验
- 用播放器播一段视频,观察「拖动进度条」时的第一帧是清晰的还是花的。
- 打开任务管理器,观察播放 1080p 时 CPU 占用;再播 4K,看翻了几倍。
- 想一想:为什么手机播放器比同配置电脑的播放器更省电?
常见误区
- 以为解码就是「把文件变成画面」。 中间还隔着解封装与格式转换,而且解码器还有缓存, 最后一帧必须冲出来。
- 以为拖动进度条是「从那个位置开始解」。 实际是找到最近的关键帧、从那里解起、 再丢到目标时间点——所以会先花一下。
- 以为音视频同步是「让它们同时开始就行」。 它们各有自己的时钟,漂移会累积,必须持续校正。
自测三问(点开对答案)
1. 五段流水里,哪一段没有对应的 FFmpeg 库?为什么? —— 同步。它是一段纯逻辑——比较两条流的时间戳,决定等还是丢,不涉及媒体数据的处理,所以不需要库。 2. 为什么 `ffmpeg -c copy` 几乎不花时间? —— 因为它跳过了解码与编码,只做解封装与重新封装,这两步都不碰像素与采样点。 3. 视频落后音频 150 毫秒,播放器该怎么做? —— 超过 100 毫秒阈值,应该丢帧——直接跳到时间上最接近的那一帧继续播,避免越拖越远。这条流水线上的每一段,FFmpeg 都提供了对应的库。下一节把六个库摆开,看它们各自管哪一段。
- ☐ (本节完,下一节 01.03.02 FFmpeg播放架构与原理)