🎬 第九章 FFplay源码剖析及音视频同步
📄 一句话概括

暂停不是「停线程」,是「停时钟」——线程照跑,队列照填,只是不再推进。 seek 也不是「跳过去」,而是「退到最近的关键帧」,所以精度等于关键帧间隔。

📝 本节和前面几节的关系

前几节讲的都是「数据怎么流」,这一节讲「人怎么干预它」。 播放器的「手感」几乎全在这一层:按播放要立刻动、拖进度条要跟手、暂停再恢复不能跳。 这些体验背后都是几个标志位和几行队列清理。

一、暂停:停时钟,不是停线程

最直觉的做法是「暂停时让线程 sleep」,但 ffplay 不这么干,它只改一个标志,然后让:

环节 暂停时
读线程 继续读,队列满了自然停
解码线程 继续解,帧队列满了自然停
主线程 不进 compute_target_delay,直接等
时钟 停止外推

第四行是关键:时钟一停,get_clock() 就不再涨, 于是「现在该显示哪一帧」这个问题就没有答案了,主线程自然卡在那里。

为什么不能真把线程停掉: 因为「停下来」和「卡住」是两件事——停下来要处理锁、要处理队列状态,恢复时还要重建; 而「卡住」什么状态都没变,恢复只要把时钟放回去。

这个思路叫「状态驱动」:不去操作线程,只改状态,让线程自己按状态行事。 它比「命令驱动」健壮得多,因为不用考虑「命令发出去时线程正在干什么」。

二、seek:三件事一起做

seek 在代码里不是一个调用,是三个动作:

动作 为什么
清空三个包队列 旧数据不能再解,否则会「倒退」
重新初始化解码器 avcodec_flush_buffers,扔掉内部残留
重置时钟与 serial 让同步逻辑知道「这是新一轮」

三件事缺一不可:只清队列不刷解码器,解码器里还攒着旧帧;只刷解码器不换 serial, 旧的时钟值会跟新数据混在一起。

第二行那个 avcodec_flush_buffers 特别容易被忘, 忘了的症状是「拖完进度条,头几帧是上一次的画面」。这个 bug 很难查,因为它「只错一下」。

第三行的 serial 在 09.02 提过一次,这里能看出它真正的用处: 它是一个「纪元号」,每次 seek 加一,任何带着旧纪元号的包和帧一律作废。

三、seek 的精度 = 关键帧间隔

Demo 量了六次 seek,「偏差」那一列全是负数或者零,这是 AVSEEK_FLAG_BACKWARD 的效果。

为什么必须往后找:因为解码要从关键帧开始,中间那些帧依赖前面的帧。 落到「目标前面最近的关键帧」,解码器就能一路解到目标位置;落到后面,前面那一段就永远解不出来了。

由此推出一条产品层面的结论:拖动精度等于关键帧间隔。 GOP 10 帧、30 帧每秒,精度就是 1/3 秒;GOP 250 帧(直播常见),精度就是八秒多。

所以「为什么我拖到 10 秒,它跳回 2 秒」这种投诉,根子在编码参数上,不在播放器。 这一点跟用户解释起来很费劲,但它是事实。

顺带一个观察:seek 本身只要一两毫秒, 因为开销主要在「查索引」和「重初始化解码器」,跟文件大小关系不大。 真正让拖动「感觉卡」的是后面那一串解码——要解到目标位置,中间那些帧都得解一遍。

四、静音与音量:不改流,只改增益

这两个功能最容易被想复杂,实际上 ffplay 只做一件事:给每个样本乘一个系数。

| 功能 | 做什么 | 不动什么 | |---|---| | 静音 | 系数设成 0 | 流不动、解码不停 | | 音量 | 系数改大改小 | 同上 |

「流不动」这一点很重要:静音了还在解码,所以恢复的时候立刻就有人声,不用等。 如果静音就停止解码,恢复时会有一小段空白。

乘系数这件事在 s16 上要注意溢出: 超过量程要截断,不能让它绕回去——绕回去就是「爆音」。 这也是为什么音量放大最好交给声卡或者浮点处理。

顺带一提:「变速不变调」「音量归一化」这些,都不属于播放控制的范畴, 它们要动采样数据本身,属于滤镜层的事。 分不清这两层,就会在播放控制里找不到对应代码。

五、单步与逐帧

「逐帧前进」这个功能,本质上是「暂停 + 手动推进一帧」:

步骤 做什么
1 保持在暂停状态
2 frame_queue_next,把读指针推一格
3 立刻重绘一次

注意第二步推的是「读指针」,不是「解码」——因为帧已经在队列里了, 逐帧看的时候根本不需要解码。

这也是为什么「逐帧」在缓冲充足时特别跟手:它只是一个指针移动加一次重绘。 而在缓冲不足时,它会卡在「等下一帧解码」上。

这个功能还暴露了一个设计事实:帧队列里存的是「还没显示过的帧」, 所以「看下一帧」这个操作天然存在。 如果队列设计成「取走即销毁」,逐帧就没法实现了。

六、直播场景:控制的意义反过来了

点播里,「控制」是为了「看得舒服」;直播里,「控制」是为了「追得上」。

| 场景 | 核心矛盾 | 手段 | |---|---| | 点播 | 平滑 | 缓冲大一点,卡了等 | | 直播 | 延迟 | 缓冲小一点,卡了丢 |

第二行的手段在 ffplay 里是缺的:它没有「丢包追帧」的策略, 所以拿 ffplay 直接播直播流,延迟只会越积越大。 书上也提到了这一点,但没有给出实现——因为那属于「直播播放器」的范畴,不是「示例播放器」的范畴。

真正的直播播放器的做法通常是这样:每隔一段时间检查「当前播放位置离最新数据有多远」, 超过阈值就整段跳过、重新对齐到最新关键帧。 观感上是「卡一下然后追上」,比「越看越慢」好得多。

这也是本课反复出现的那个取舍:平滑和实时是一对矛盾,靠缓冲的大小在两者之间移动。

七、动手 Demo:播放控制——seek 落在哪一帧,是可以算的

seek 的行为不是「黑盒」,它落在哪一帧完全由关键帧位置决定,可以预测。 Demo 在一个 GOP 为 10 帧的片子上取六个目标点,看每一次实际落在哪里、偏了多少、花了多久。

c.seek(int(target / tb), stream=s)   # 默认带 BACKWARD 语义
for pk in c.demux(s):
    if pk.pts is not None:
        got = pk.pts * tb             # 第一帧的 pts
        break

实跑输出:

--------------------------------------------------------------------
播放控制——seek 落在哪一帧,是可以算的
--------------------------------------------------------------------
**播放器要能拖动进度条**,**而拖动在 API 层只有 `seek` 一个动作。**
**但这个动作做不到「精确到某一帧」,只能「退到不晚于目标最近的关键帧」。**

**测试片子的关键帧间隔是 10 帧**(Demo 建片子时设的 `gop`),
**每秒 30 帧,所以关键帧每 1/3 秒一个。**
**片子总长只有 2 秒,所以目标时间都取在 2 秒以内。**
**超出片尾的 seek 会被夹到最后一帧,那组数没有意义。**

  目标(秒)     seek 后第一帧的 pts     偏差(秒)     耗时
  -------------------------------------------------------------------
  0.10           0.000                   -0.100         0.14 毫秒
  0.40           0.333                   -0.067         0.13 毫秒
  0.75           0.667                   -0.083         0.15 毫秒
  1.00           1.000                   +0.000         0.14 毫秒
  1.35           1.333                   -0.017         0.14 毫秒
  1.90           1.667                   -0.233         0.25 毫秒

**「偏差」那一列永远是负数或者零**,**这是 `AVSEEK_FLAG_BACKWARD` 的效果**,
**也是 ffplay 拖动时的默认行为:宁可退一点,不要冲过头。**

**为什么必须退**:**因为解码要从关键帧开始,中间那些帧依赖前面的帧。**
**落到「目标前面最近的关键帧」,解码器就能一路解到目标位置。**
**落到后面,前面那一段就永远解不出来了。**

**由此推出一条产品层面的结论**:**拖动精度 = 关键帧间隔。**
**GOP 是 10 帧,精度就是 1/3 秒;GOP 是 250 帧(直播常见),精度就是 8 秒多。**
**所以「为什么我拖到 10 秒,它跳回 2 秒」这种投诉,根子在编码参数上,不在播放器。**

**顺带量到的一件事**:**seek 一次只要两三毫秒**,
**因为开销全在「找到索引」和「重新初始化解码器」上,跟文件大小关系不大。**
**这也是「为什么拖动不卡」的原因——代价被算进了下一帧的解码里。**

「偏差」那一列全是负数或零,这一列就是 AVSEEK_FLAG_BACKWARD 的可视化。

偏多少不固定,取决于「目标离前一个关键帧有多远」。 目标正好落在关键帧上,偏差是零;刚过一个关键帧,偏差就接近一个 GOP。

所以「拖动精度」这个指标可以精确算出来:最大偏差 = 一个 GOP 的时长。 这也是为什么「GOP 太长的片子拖起来很不准」。

耗时那一列都在零点几毫秒,说明「找位置」本身很便宜。 用户感觉到的「拖动之后要顿一下」,顿的是「解码到目标位置」那一段,不是 seek 本身。

最后提醒一个测试上的坑:目标时间必须落在片子长度之内。 超出片尾的 seek 会被夹到最后,测出来的偏差会大得离谱,看着像 bug,其实是输入不对。

30 秒实验

  1. 把目标改成 1.95 秒(片尾附近),看偏差有什么变化,为什么。
  2. 算一下:GOP 是 250 帧、25 帧每秒,最大拖动偏差是多少秒?
  3. 判断一下:静音之后恢复,为什么不需要等?

常见误区

  1. 以为暂停是把线程停下来。 它只是停时钟,线程照跑、队列照填。
  2. seek 只清队列不刷解码器。 会看到「拖完进度条,头几帧是旧画面」。
  3. 以为 seek 能精确到帧。 只能退到最近的关键帧,精度等于一个 GOP。
  4. 以为 seek 慢是因为查索引慢。 查索引只要一两毫秒,慢的是随后解码到目标位置。
自测三问(点开对答案) 1. 暂停时线程在做什么? —— 照常跑:读线程填包队列,解码线程填帧队列,满了就阻塞。 变化的是时钟不再外推,所以主线程不推进。 2. seek 时为什么要换 `serial`? —— 因为它标记「新一轮播放」,队列里带旧 serial 的数据一律作废,避免时钟倒退。 3. 拖动精度由什么决定? —— 关键帧间隔。seek 只能退到目标之前最近的关键帧,最大偏差就是一个 GOP 的时长。

最后一个问题是全场压轴: 两条流各自解、各自进队列,凭什么能对上口型?答案是一个时钟、两个阈值、三种手段。

相关笔记