🎬 第一章 音视频及流媒体播放原理
📄 一句话概括

FFmpeg 不是一个库,是六个各管一段的库。 播放器把它们串起来的方式,就是「包进帧出」这一条链——理解了 AVPacket 到 AVFrame 的转换, 就理解了整个架构。

📝 本节和前面几节的关系

上一节画了五段流水的骨架,这一节把每一段对应的库对上号。 这一节也是后面七章的地图:第二到六章讲别人怎么用这些库(VLC、Qt、OpenCV、SDL), 第七章自己动手用,第八九章拆开 FFplay 看官方是怎么用的。

一、六个库的分工

名字里都带 av,因为它们共享一套基础类型(libavutil)。

库 管什么 播放器里用在哪
libavformat 容器与协议 打开输入、读包
libavcodec 编解码器 把包变成帧
libavutil 公共结构与工具 帧、包、时间基、像素格式
libswscale 图像缩放与转换 YUV 转 RGB、改尺寸
libswresample 音频重采样 改采样率、改声道布局
libavfilter 滤镜图 播放器很少用,转码常用

这六个库里,播放器必须用的是前五个。libavfilter 在播放器里基本不用——它的价值在转码与特效(31 课讲的就是它)。

从依赖关系看更清楚:libavformat 和 libavcodec 都依赖 libavutil, swscale 与 swresample 又各自依赖 avutil。所以 avutil 是最底层,所有类型都从它来。

二、四个关键对象

整个播放链路就是四个对象在传递:

对象 代表什么 谁产生 谁消费
AVFormatContext 一个打开的文件或流 avformat_open_input 解封装
AVStream 里面的一条流 同一函数填好 选解码器用
AVPacket 一个压缩包 av_read_frame 解码器
AVFrame 一帧解开的画面或声音 解码器 转换与渲染

这四个对象的顺序就是数据的流向:先有上下文,上下文里有若干流,从流里读出包,包解出帧。

最容易混的是「流」与「上下文」:一个 MP4 里有视频流和音频流两条, 它们共用一个上下文,但各有自己的时间基、各有自己的解码器。同步这件事就是跨这两条流做的。

三、包与帧差多少

这一节最该记住的一个数字:demo 里第一个包 870 字节,解出来的帧 115200 字节——一百三十多倍。

这个倍数就是「压缩」的直观含义:包是「怎么把这一帧画出来」的说明书, 帧是「每个像素什么颜色」的全量数据。

由此可以推出一件工程上的事: 解码器需要缓冲,而且缓冲是按帧算的——缓 10 帧 1080p 就是 30 MB,不是几 KB。 这就是为什么「预缓冲几秒」在 4K 播放器上很吃内存。

四、时间基:时间戳的单位

时间戳不是秒,是一个整数,单位由 time_base 决定。 time_base 是一个分数,比如 1/15360,意思是「一个刻度等于 1/15360 秒」。

为什么要这么绕:因为整数运算快、且没有浮点累积误差。 但麻烦在于三条流的时间基常常不一样:视频可能是 1/15360,音频可能是 1/48000, 容器可能是 1/1000。

所以要比较两个时间戳,必须先换算到同一个时间基: av_compare_ts 与 av_rescale_q 就是干这个的。

时间基写错是新手最常踩的坑:把视频的 pts 直接和音频的 pts 相减, 得到的数在两边单位不同的情况下毫无意义,但程序不会报错,只会「莫名其妙不同步」。

五、一个最小的播放循环

抛开所有细节,播放器的主循环只有两种写法: 老写法:av_read_frame 读包 → avcodec_decode_video2 出帧。 新写法:av_read_frame 读包 → avcodec_send_packet 送 → avcodec_receive_frame 收。

新写法(2016 年引入)之所以必要,是因为它允许「一次进、多次出」:送一个包进去, 可以收零帧(还在缓存)、一帧,甚至多帧。 老写法用返回值表示「消耗了多少字节」,在 B 帧与多 slice 的情况下表达不清,容易写出微妙的 bug。

所以今天的正确循环长这样: 读包; 送包,可能收到 EAGAIN(表示要先收帧); 循环收帧直到再收到 EAGAIN(表示要再喂包); 读完了,送一个空包冲干净。

EAGAIN 不是错误,是流程控制信号——这段循环里最容易写错的,就是把 EAGAIN 当成失败退出。

六、2026 年的现状

这本书成于 2023 年,代码里还有 av_register_all。 这个函数在 FFmpeg 4.0 就废弃、5.0 直接删了。 今天再照抄书上的代码,第一步就编译不过。

另外几处必改的地方: avcodec_decode_video2 与 avcodec_decode_audio4 已删除,改 send/receive。 avpicture_* 系列已废弃,改用 av_image_*。 AVStream.codec 已废弃,改用 AVStream.codecpar。 错误码的判断从 < 0 改成用 AVERROR 系列的宏——AVERROR(EAGAIN) 与 AVERROR_EOF 要分开处理。

本机装的是 FFmpeg 8.1.2(libavcodec 62、libavformat 62),比书上那代新了两个大版本。 所以本课所有 Demo 都用 Python 的 PyAV 写, 它跟着新版本走,不会出现「书上的 API 已经不存在」的问题。

七、动手 Demo:FFmpeg 不是一个库,是六个库

这段打印本机六个库的版本,再把同一个视频的「包」与「帧」摆在一起对比。 重点看最后那两行的体积差。

for k in (u"libavcodec", u"libavformat", u"libavutil",
          u"libswscale", u"libswresample", u"libavfilter"):
    v = av.library_versions.get(k)
    T([k, u"%d.%d.%d" % v], [0, 0])

pkt = next(c.demux(st))
fr = pkt.decode()[0]
print(pkt.size, fr.width, fr.height, fr.format.name)

实跑输出:

--------------------------------------------------------------------
FFmpeg 不是一个库,是六个库
--------------------------------------------------------------------
**FFmpeg 这个名字底下是六个各有分工的库,播放器把它们串起来。**

  库                管什么                 播放器里用在哪
  ---------------------------------------------------------------------
  libavformat       容器格式与协议         解封装、读网络流
  libavcodec        音视频编解码           解码
  libavutil         公共工具与结构         帧、包、时间基、像素格式
  libswscale        图像缩放与像素转换     YUV 转 RGB、缩放
  libswresample     音频重采样与混音       采样率与声道转换
  libavfilter       滤镜图                 播放器一般不用,转码才用

**本机这六个库的版本**:

  库                版本号
  -------------------------------
  libavcodec        62.28.102
  libavformat       62.12.102
  libavutil         60.26.102
  libswscale        9.5.102
  libswresample     6.3.102
  libavfilter       11.14.102

**同一个视频,压缩包和帧长这样**:

  对象         字段             值
  -----------------------------------------
  AVPacket     size(字节)     870
  AVPacket     pts              0
  AVPacket     dts              -1024
  AVPacket     time_base        1/15360
  AVFrame      宽×高            320×240
  AVFrame      像素格式         yuv420p
  AVFrame      pts              0

**这两类对象的区别,就是「解码」这件事的全部意义**:
  包是压缩的——几百字节到几十 KB,看不出画面;帧是展开的——一屏像素,直接能贴。
  上面这一包里 870 字节,解出来的帧是 320×240 的 yuv420p,光像素就 115200 字节。
  **一个包不一定出一帧,一帧也可能要等好几个包**(B 帧会把顺序打乱)。
  所以解码循环永远长这样:发一个包进去,可能收零个、一个或多个帧出来。

870 字节对 115200 字节,一百三十二倍。这个比例在小分辨率下还算温和——1080p 的一帧是 3110400 字节,而它的包通常只有几十 KB,倍数上百。

pts 0 与 dts -1024 这一对值得看一眼:第一帧的显示时间是 0, 但解码时间是负的——因为 B 帧要先解后显,所以 dts 必须比 pts 早。 这就是「解码顺序与显示顺序不是一回事」的证据。

time_base 1/15360 是 libx264 的默认值,它等于 30 fps 的一帧再细分 512 份。 记住这个数不是必需的,但要知道「每家的 time_base 都不同」,所以换算不能省。

30 秒实验

  1. 在任意一个视频文件上跑一次 ffprobe(或 PyAV),看视频流与音频流的 time_base 各是多少。
  2. 用两个 time_base 把同一个时刻换算成两个整数,验证它们确实不相等。
  3. 想一想:为什么解码器不能用「一个包一个帧」的方式工作?

常见误区

  1. 以为 FFmpeg 是「一个播放器」。 它是一套库,ffplay 只是它自带的一个示例程序。
  2. 把 EAGAIN 当成错误。 它是「现在没有帧,你得再喂包」的意思,当成失败退出会解不出任何帧。
  3. 直接用两条流的 pts 相减做同步。 时间基不同,相减没有意义,必须先换算到同一时间基。
自测三问(点开对答案) 1. 播放器必须用哪几个库?哪个一般不用? —— 必须用 avformat、avcodec、avutil、swscale、swresample 五个; avfilter 在播放器里基本不用,那是转码与特效的领域。 2. 为什么新接口要改成 send/receive 两个函数? —— 因为解码不是一一对应的:一个包可能出零帧、一帧或多帧。 用两个函数把「喂」和「取」分开,才能正确表达这种关系。 3. 书上 `av_register_all` 为什么今天编译不过? —— 它在 FFmpeg 4.0 废弃、5.0 删除。现在格式与编解码器是自动注册的,不再需要显式调用。

第一章的骨架到此为止:数据是什么、怎么传、收到之后过几道手,都有了。 下一章换个角度,先看一个已经把这些全做好的播放器——VLC。

相关笔记