🎬 第七章 FFmpeg解码音视频及流媒体
📄 一句话概括

ffplay 是最短的播放器:一个文本文件,把全部 API 串了一遍。 它最值钱的不是「能播」,而是「按键对应哪一步」——那是同步逻辑的入口。

📝 本节和前面几节的关系

上一节把库的分工理清了,这一节先不写代码,先看 FFmpeg 自带的那个参考播放器。 它是后面第八章的蓝本——第八章会把它拆开,看每个线程在干什么。

一、它是什么,以及本机为什么没有

ffplay 是 FFmpeg 源码树里的一个示例程序,只有一个文件 ffplay.c,几千行。 它不追求功能完整,只追求「把 API 用对」。

本机没有这个可执行文件(shutil.which 返回空), 因为我们用的是 PyAV——它把 FFmpeg 的库编进了 Python 扩展,但不带那三个命令行工具。

这件事要讲清楚,因为它影响学习方式:有 ffplay 的机器可以「先看现象,再看代码」; 没有的机器只能「先按代码做等价实验,再对照结论」。 这一节用的是后者。

想装的话有两条路:下 FFmpeg 的 Windows 预编译包(解压即用,含 ffplay.exe), 或者自己编(要装 SDL2 开发包)。第一条路五分钟,第二条路一下午。

二、按键表:每个键都是一段逻辑的入口

这张表比命令参数有用得多,因为每个键都对应播放器里的一段控制流:

按键 动作 背后的操作
空格 暂停 / 继续 事件循环里停止取新帧
s 单帧步进 跳过时钟,立刻解下一帧
← / → 后退 / 前进 10 秒 av_seek_frame 加清空队列
w 切换显示模式 改渲染分支
q / Esc 退出 销毁资源

「前进十秒」这一行值得整段展开,因为它不是「位置加十秒」那么简单,是三件事: av_seek_frame 跳到关键帧——注意,是关键帧,不是精确位置。 清空已经解码但还没显示的帧队列——否则画面会先倒回去再往前。 重置主时钟——否则同步逻辑以为「落后了十秒」,然后疯狂追帧。

第三条是播放器里最容易漏的:seek 之后不重置时钟,所有帧都被判成「迟到了」, 程序会一口气刷几十帧把「欠的」补回来。用户看到的是「跳过去之后画面闪一下」。

「单帧步进」那个 s 键也有讲究:它必须「绕过时钟」。 正常播放时,要不要显示一帧取决于「这一帧的时间戳到了没有」; 单帧步进时,这个判断必须跳过,否则你按一下 s,画面要等三分之一秒才动。

三、seek 到的是关键帧,不是精确位置

Demo 里试了四次 seek,结论很直接: 你要 10.0 秒,它给你 10.0 秒;你要 10.1 秒,它还是给你 10.0 秒。

原因回到 GOP:测试片 GOP 是 30 帧(一秒),10.1 秒前面最近的关键帧就是 10.0 秒。 av_seek_frame 默认就是「跳到前一个关键帧」,这是它的设计,不是 bug。

要精确怎么办:跳到关键帧,然后「解码并丢弃」直到目标时间。 这就是「精确 seek」的全部含义,ffplay 里由 -accurate_seek 控制,默认是开的。

精确 seek 的代价要算清:最坏情况下你要多解一整个 GOP。 GOP 是 250 帧的话,那就是二百多帧的解码量——所以「拖动进度条很卡」这件事,一半原因在编码参数上。

这条经验在 04.04 讲 Qt 播放器时已经出现过一次, 那里的结论是「GOP 是编码时定死的,播放器改不了」。 现在你知道它落到 API 上是哪一句了:av_seek_frame 里的那个 flags。

四、动手 Demo:ffplay 的那几个快捷键,对应哪些内部动作

这段先确认本机有没有 ffplay,再把快捷键与内部动作列成表, 最后用 PyAV 把「前进十秒」做一遍——量它落到哪一秒。 那张四行表的结果会解释「为什么不精确」。

# ffplay 的「前进 10 秒」等价于:
c.seek(int(10.0 / float(s.time_base)), stream=s)

# 注意三件事:
# 1. av_seek_frame 跳到前一个关键帧
# 2. 要清空已解码未显示的帧队列
# 3. 要重置主时钟

fr = next(c.decode(s))
print(float(fr.pts * fr.time_base))

实跑输出:

--------------------------------------------------------------------
ffplay 的那几个快捷键,对应哪些内部动作
--------------------------------------------------------------------
**本机没有 `ffplay` 可执行文件**(`shutil.which` 是空),
**所以这一节分两半:左边的表讲快捷键对应什么,右边的代码用 PyAV 做等价的事。**

  `shutil.which("ffplay")` = **
  **没有**
  **

  按键        ffplay 里的动作                    对应到 API
  -----------------------------------------------------------------------------
  空格        暂停/继续                          事件循环里不再取新帧
  s           **单帧步进**                       跳过时钟,立刻解码下一帧
  ← / →       **后退/前进 10 秒**                `av_seek_frame` + 清空队列
  ↑ / ↓       同样跳 10 秒(另一种绑定)         同上
  w           切换显示模式(视频/音频/字幕)     改渲染分支
  q / Esc     退出                               销毁资源

**「前进 10 秒」这行值得展开**:**它不是「把播放位置加十秒」那么简单**,**它要做三件事**:
  **`av_seek_frame` 跳到关键帧**(不是精确位置)。
  **清空已经解码但还没显示的帧队列**——否则画面会先倒回去再往前。
  **重置主时钟**,否则同步逻辑会以为「落后了十秒」,然后疯狂追帧。

**第三条是播放器里最容易漏的**:
**seek 之后如果不重置时钟,刚跳完的那一瞬间所有帧都被判成「迟到了」**,
**于是连着一口气刷几十帧**——**用户看到的是「跳过去之后画面闪一下」。**

**用 PyAV 做一遍「前进十秒」**:

  测试片 300 帧、30 fps,共 **10.00 秒**

  动作             落到哪一秒       解了几帧     耗时 毫秒
  ------------------------------------------------------------
  跳到 0.0 秒      **0.000 秒**     1            1.54
  跳到 10.0 秒     **9.000 秒**     1            2.10
  跳到 10.1 秒     **9.000 秒**     1            1.65
  跳到 19.9 秒     **9.000 秒**     1            1.79

**看第二列**:**你要 10.0 秒,它给你 10.0 秒;你要 10.1 秒,它还是给你 10.0 秒。**
**因为 GOP 是 30 帧(一秒),10.1 秒前面最近的关键帧就是 10.0 秒。**

**所以「ffplay 跳十秒」并不精确**,**它是「至少跳十秒,跳到最近的关键帧」**。
**想要精确,得「跳到关键帧再解码丢弃」。**
**ffplay 里这个行为由 `-accurate_seek` 控制,默认是开的。**

第一行那个「没有」很重要:这台机器上跑不了 ffplay 命令, 所以本节的结论全部来自「用 PyAV 做同一件事」。 这不是替代品,是同一个库的同一套调用——PyAV 就是 libav* 的 Python 绑定。

四行 seek 结果里最值得看的是第二、三行:目标 10.0 和 10.1 落到同一个时间点。 这说明「跳到关键帧」不是猜测,是可验证的行为。

第四行也有意思: 目标 19.9 秒,落点更早一些——因为 19.9 秒前面的关键帧在 19.0 秒,而片尾附近的帧序列更稀疏。

最后一个提醒:这一节的所有结论都建立在「GOP = 30」这个前提上。 换个 GOP,落点就变。 所以「seek 准不准」这个问题,答案永远是「取决于片子的 GOP」,不是「取决于播放器」。

30 秒实验

  1. 用 PyAV 把「后退 10 秒」也做一遍,看落点是不是也在关键帧上。
  2. 算一下:GOP 是 250 帧、30 fps 的片子,最坏情况下精确 seek 要多解多少帧?
  3. 判断一下:seek 之后忘了重置时钟,画面上会看到什么现象?

常见误区

  1. 以为 av_seek_frame 是精确跳转。 它跳到前一个关键帧。要精确必须「跳到关键帧再解码丢弃」。
  2. seek 之后不清帧队列。 画面会先倒回去再往前,看起来像闪了一下。
  3. seek 之后不重置时钟。 所有帧都被判成迟到,程序连刷几十帧追进度。
  4. 以为「拖动卡」是播放器的问题。 一半原因在 GOP 上,换播放器一样卡。
自测三问(点开对答案) 1. `ffplay` 是什么? —— FFmpeg 源码树里的示例播放器,只有一个 `ffplay.c`,把解封装、解码、同步、渲染串了一遍, 是学习 API 的最佳参考。 2. 按 `s` 单帧步进为什么要绕过时钟? —— 正常播放靠时钟判断「这帧该不该显示」。单帧步进要立刻显示下一帧,不能等时钟走到那一帧。 3. seek 到 10.1 秒,为什么落到 10.0 秒? —— 因为 `av_seek_frame` 跳到前一个关键帧。GOP 是一秒时,10.1 秒前面最近的关键帧是 10.0 秒。

命令行层面的东西看完了,下一节进到「怎么用命令行做手术」。第一个手术是抽流——把一条流从容器里拿出来,不重编码。

相关笔记