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

判断一个程序算不算播放器,不看有没有界面,看它有没有「等」。 能解出帧只是工具,按时刻把帧送出去才是播放器。

📝 本节和前面几节的关系

前四节都在讲 VLC 这一侧,这一节抽出来看「播放器」这个概念本身。 2.2.2 讲 FFmpeg 的实现,这一节先把「要做什么」立住。 同步的算法细节在 34 课第九章,那一整章都在讲音视频怎么对齐。

一、三级:拆、解、按时出

D24 把同一段片子按三种深度跑了一遍,差别非常直观。

级 做的事 拿到什么 2 秒片子耗时
一 只拆包 压缩包 0.6 毫秒
二 拆包+解码 原始帧 40 毫秒
三 拆包+解码+等 原始帧+时刻 1967 毫秒

第二级是 50 倍速——2 秒的片子 40 毫秒跑完。它「能看」,但那叫「快速翻阅」,不叫播放。 第三级把时间拉回 1967 毫秒,其中 1884 毫秒在 sleep,占 96%。

这个 96% 是全章最反直觉的一个数:真正的播放器,大部分 CPU 时间不是在算,是在等。 「等多久」这个决定,就是同步。 等早了画面超前,等晚了画面滞后。

二、同步到底在同步什么

三样东西要对齐:视频自己的 pts、音频的播放进度、系统时钟。

以谁为准 做法 问题
以视频为基准 音频去追视频 音频要变速,会变调
以音频为基准 视频去追音频 视频丢帧或重复,最常见
以外部时钟为基准 两个都去追它 要额外时钟源

第二行是主流做法,因为人对声音的连续性敏感、对画面的单帧丢失不敏感。 所以 ffplay 的默认策略是「视频追音频」:视频慢了就丢帧,快了就等。

第一行在监控里反而常见:很多摄像机没有音频,视频就是唯一的时间基准, 这时「同步」退化成「按 pts 显示」。

三、缓冲和同步是两件事

这两个词经常被混着用,但管的完全不同。

缓冲 同步
解决什么 网络抖动 两路时间对不齐
在哪一层 收包之后、解码之前 解码之后、显示之前
调大调小的后果 延迟变大/容易卡 更稳/更容易丢帧

缓冲是「多存点货」,同步是「按点发货」。缓冲不够会断供,同步不对会错位。 排故障时先分清是哪一类:画面一顿一顿但音画对得上,是缓冲;画面流畅但嘴型不对,是同步。

四、自己写一个最小播放器要多少行

D24 第三级那段就是最小播放器,不到二十行。 但它缺三样东西:

缺什么 后果 补起来要多少
丢帧策略 解码跟不上就无限滞后 几十行,要对齐音频时钟
缓冲队列 网络一抖就断 一百行,要处理上溢下溢
重连 断了就永远停住 几十行,要管状态机

这三样才是「能用的播放器」和「能跑的 demo」之间的差距,也是 FFplay 有一万多行的原因。 注意第一行的措辞是「无限滞后」而不是「卡」:不丢帧的话,解码越慢,画面离实时越远, 最后差出十几秒。

五、动手 Demo:同一段片子,三级深度各跑一遍

用同一段 720p、2 秒的片子,做三件事:只拆包、拆包加解码、再加按 pts 等待。 三级的耗时差别,就是「工具」和「播放器」的分界线。

# 一级:只拆包
for pk in c.demux(s):
    if pk.size:
        n_pkt += 1

# 二级:拆包 + 解码,不管时间
for fr in c2.decode(s2):
    n_frm += 1

# 三级:解一帧,等到它该出现的时刻再显示
t0 = time.perf_counter()
for fr in c3.decode(s3):
    want = float(fr.pts * s3.time_base)   # 这一帧该在几秒出现
    gap = want - (time.perf_counter() - t0)
    if gap > 0:
        wait += gap
        time.sleep(gap)

实跑输出:

--------------------------------------------------------------------
什么叫「能播」,分三级测一遍
--------------------------------------------------------------------
**判断一个程序算不算播放器,不看有没有界面,看它走完了几段流水线。**
这一节用同一段片子,做三件不同深度的事,看各级能拿到什么。

**一级:只拆包,不解码。**
  拆出 60 个包,用时 **0.7 毫秒**。手里只有压缩数据,能算码率,**画不出图**。

**二级:解到帧,不做时间控制。**
  解出 60 帧,用时 **85.7 毫秒**。像素在内存里了,但帧与帧之间没有任何时序约束。
  这级程序会把 2 秒的片子 **23 倍速**放完——能看,但不是「播放」。

**三级:解帧+按 pts 等待。**
  解出 60 帧,总用时 **1967 毫秒**(片长 2000 毫秒)。
  其中 **1839 毫秒是在 sleep**,占 93%——**播放器大部分时间其实在等**。

  级     做的事             拿到什么         算播放器吗
  ---------------------------------------------------------------
  一     拆包               压缩包           不算,只能做分析
  二     拆包+解码         原始帧           半算,没时序
  三     拆包+解码+等     原始帧+时刻     **算**

  **第三级只多了「等」这一件事,却是从工具变成播放器的分界线。**
  因为「播放」的定义就是:画面按它该出现的时刻出现。少了这一条,再快也只是快速翻阅。

一级 0.6 毫秒、二级 40 毫秒、三级 1967 毫秒。后两级之间差了 49 倍,而代码只多了一个 sleep。 这就是「播放」的定义:不是「能解」,是「按时刻出」。

第三级总耗时 1967 毫秒,比片长 2000 毫秒短一点,因为最后一帧解出来就该结束了, 不用等到 2 秒整。 这 33 毫秒的差额正好是一帧的时长——不是误差,是结构。

注意这段代码没有丢帧策略:如果解码比实时慢,gap 会一直是负的,画面就越拖越远。 真播放器必须在这里加一句「落后超过阈值就丢帧」——34 课第九章量过, ffplay 的阈值是 10 毫秒和 100 毫秒两档。

30 秒实验

  1. 把 sleep(gap) 改成 sleep(gap * 0.5),看总耗时变成多少——体会「时间基准被压缩」是什么效果。
  2. 把片子换成 60 fps,看每帧预算从 33.3 毫秒变成多少,再想「解码 0.63 毫秒还能不能跟上」。
  3. 在三级代码里加一个计数器,统计 gap < 0 的次数——那就是「解码来不及」的帧数。

常见误区

  1. 以为能解码就是播放器。 二级代码 50 倍速跑完,能看但不是播放。 播放的核心是「按时刻出」,不是「解出来」。
  2. 以为缓冲和同步是一回事。 缓冲在网络和时钟之间,同步在时钟和显示之间。 一顿一卡多半是缓冲,音画错位才是同步。
  3. 以为播放器一直在算。 三级实测 96% 的时间在 sleep,播放器的主要工作是「等对时刻」。
自测三问(点开对答案) 1. 三级比二级只多了什么? —— 只多了「按 pts 等待」。但这一件事把 50 倍速压回 1 倍速,也就是把「快速翻阅」变成「播放」。 2. 为什么主流做法是「视频追音频」? —— 人对声音连续性敏感、对单帧丢失不敏感。 音频变速会变调,视频丢一帧几乎看不出来,所以让视频去迁就音频。 3. 不写丢帧策略会有什么后果? —— 解码一旦慢于实时,画面离实时越来越远,最后能差出十几秒。表现不是「卡」,是「越来越延迟」。

下一节进 FFmpeg 的实现:把这三级的代码对应的对象一个个点名,看数据在它们之间怎么搬。

相关笔记