暂停不是「停线程」,是「停时钟」——线程照跑,队列照填,只是不再推进。 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.95 秒(片尾附近),看偏差有什么变化,为什么。
- 算一下:GOP 是 250 帧、25 帧每秒,最大拖动偏差是多少秒?
- 判断一下:静音之后恢复,为什么不需要等?
常见误区
- 以为暂停是把线程停下来。 它只是停时钟,线程照跑、队列照填。
- seek 只清队列不刷解码器。 会看到「拖完进度条,头几帧是旧画面」。
- 以为 seek 能精确到帧。 只能退到最近的关键帧,精度等于一个 GOP。
- 以为 seek 慢是因为查索引慢。 查索引只要一两毫秒,慢的是随后解码到目标位置。
自测三问(点开对答案)
1. 暂停时线程在做什么? —— 照常跑:读线程填包队列,解码线程填帧队列,满了就阻塞。 变化的是时钟不再外推,所以主线程不推进。 2. seek 时为什么要换 `serial`? —— 因为它标记「新一轮播放」,队列里带旧 serial 的数据一律作废,避免时钟倒退。 3. 拖动精度由什么决定? —— 关键帧间隔。seek 只能退到目标之前最近的关键帧,最大偏差就是一个 GOP 的时长。最后一个问题是全场压轴: 两条流各自解、各自进队列,凭什么能对上口型?答案是一个时钟、两个阈值、三种手段。
- ☐ (本节完,下一节 09.08 FFplay音视频同步原理及实现)