播放器开发--音视频解码(1)
音视频解码
一个视频从文件到画面,主要经过四个步骤:解封装 → 解码 → 音视频同步 → 渲染输出。
本系列分为两篇:第一篇讲解封装(Demuxer),第二篇讲解码器(Decoder)。
解封装
我们可以把一个视频文件(mp4、mkv 等)想象成一个快递包裹,里面装着视频轨、音频轨,有时还有字幕轨。解封装的作用就是把这个包裹拆开,取出里面的数据,分别交给后续的解码器处理。
解封装器(Demuxer)从文件中提取三类关键信息:
1. 压缩比特流(Bitstream)
- 视频流:通常是 H.264、HEVC (H.265) 等编码格式的压缩数据
- 音频流:通常是 AAC、MP3、Opus 等格式的压缩数据
2. 元数据(Metadata)
包含视频分辨率、帧率(FPS)、码率、总时长、音轨语言等信息。
3. 时间戳(Timestamps)
这是最重要的部分。每个压缩帧都带有 DTS(解码时间戳)和 PTS(显示时间戳),告诉播放器:这一帧该什么时候解码、什么时候显示给观众。没有时间戳,音视频同步就无从谈起。
解封装器(Demuxer)的实现
下面拆解 Demuxer 核心函数的实现。部分代码(如 interruptCallback)在后续 seek 相关的文章中会展开,这里先聚焦主体流程。
open(const char* filepath) 函数
1 | |
前置概念:为什么 FFmpeg 处处是 Context?
在深入 open() 之前,需要理解 FFmpeg 最核心的设计模式。你会发现几乎所有 FFmpeg 操作都围绕一个 *Context 结构体展开——AVFormatContext、AVCodecContext、SwsContext、SwrContext 等等。原因有四:
封装状态信息:多媒体处理是一个有状态的过程。以解码为例,当前帧的解码往往依赖前一帧(参考帧)。Context 结构体内部维护了整个处理周期的所有状态,你只需要把同一个 Context 指针传给后续函数,它就知道”之前发生了什么,现在该做什么”。
面向对象的 C 语言实现:FFmpeg 主要是 C 语言编写的,C 没有类的概念。Context 实际上扮演了”类实例”的角色——结构体成员是属性,操作该结构体的 FFmpeg 函数是方法。
支持多路并行处理:每个 Context 都是独立的,互不干扰。这使得 FFmpeg 能轻松支持多线程解码和多流并发——比如同时播放视频轨和音频轨,各自拥有独立的 CodecContext。
统一的配置与扩展(AVOptions 机制):所有 Context 结构体的首地址通常都有
AVClass指针。这使得 FFmpeg 可以用一套统一代码管理不同模块的参数设置、日志打印和内存释放。
所以我们在 Demuxer 类中声明了 AVFormatContext* _formatContext,后续所有解封装操作都围绕它展开。
open() 的四步流程
第 1 步:旧上下文清理
打开新文件前,如果旧的上下文还存在,调用 avformat_close_input() 释放它。这确保每次 open() 都从干净的状态开始。
1 | |
第 2 步:打开文件
1 | |
这个函数会探测文件格式,分配 AVFormatContext,读取文件头信息。
第 3 步:探测流信息
1 | |
这一步会真正读取一部分数据,分析出文件中包含哪些流(视频、音频、字幕),并把每个流的信息填入 ic->streams[] 数组。之后我们才能遍历数组找到视频流和音频流的 index、获取各自的 AVCodecParameters。
第 4 步:打印信息与缓存时长
av_dump_format() 不是必做步骤,但能帮开发者快速确认文件信息(格式、码率、流类型等),调试时非常有用。随后缓存总时长和视频结束时间,避免后续解复用线程与 UI 线程竞争 AVFormatContext。
注意:FFmpeg 函数几乎都返回
int。返回值 < 0 表示错误,>= 0 表示成功或特定状态码。判断错误时用av_strerror()将错误码转为可读字符串。
readPacket() 函数
在讲代码之前,先搞清楚 packet 里面装的是什么。
AVPacket 是从文件中读取出来的一个”压缩数据包”。注意,它是编码后的压缩数据,不是可以直接显示的图像或 PCM 音频。一个 packet 携带的关键信息包括:
| 字段 | 含义 |
|---|---|
data |
指向压缩数据的指针(一帧或多帧的编码数据) |
stream_index |
属于哪个流(视频?音频?字幕?) |
pts |
显示时间戳(Presentation Time Stamp),告诉播放器这一帧该何时显示 |
dts |
解码时间戳(Decoding Time Stamp),告诉解码器这一帧该何时解码 |
duration |
这个 packet 覆盖的时长 |
flags |
标志位,比如是否是关键帧(AV_PKT_FLAG_KEY) |
对于视频流,一个 packet 通常对应一帧的压缩数据;对于音频流,一个 packet 可能包含多个音频帧。
解复用线程的核心工作就是循环调用 readPacket(),拿到 packet 后根据 stream_index 分发到视频或音频的 PacketQueue 中。
1 | |
readPacket() 的两步流程
第 1 步:分配 packet
1 | |
av_packet_alloc() 在堆上分配一个 AVPacket 并返回其指针。对应的释放函数是 av_packet_free(AVPacket **pkt)。这里用自定义 deleter 的 unique_ptr(AVPacketPtr)自动管理生命周期,出作用域时自动调用 av_packet_free,避免手动释放。具体介绍见上一篇队列文章中的 unique_ptr 扩展一节。
第 2 步:读取一帧压缩数据
1 | |
av_read_frame() 是解封装最核心的函数。它从文件中读取下一个 AVPacket,填好 data、stream_index、pts、dts 等字段后返回。这个函数可能阻塞(等待磁盘 I/O 或网络数据),所以在 stop/seek 时需要 I/O 中断机制唤醒它。注意,虽然函数名是read_frame,但是返回类型是AVPacket而不是AVFrame。
返回值处理:
== 0:成功读取,返回的 packet 交给后续分发逻辑== AVERROR_EOF:文件读完,正常结束,不打印错误- 其他负值:真正的错误,用
av_strerror()打印可读信息
findStream() 函数
packet 里只有一个 stream_index(整数编号),我们需要知道这个编号对应的是视频流还是音频流,才能把 packet 送到正确的解码通道。findStream() 就是用来建立这个映射的。
1 | |
逻辑很简单:遍历 _formatContext->streams[] 数组,找到 codec_type 为 AVMEDIA_TYPE_VIDEO 或 AVMEDIA_TYPE_AUDIO 的那条流,记录其 index。
其中:
codecpar(AVCodecParameters*,Codec Parameters 的缩写):存储了该流的最基本信息——编码格式(H.264 / AAC)、分辨率、采样率等。注意它不是解码器上下文,只是元数据,不包含解码状态。codec_type:枚举值,表示这条流是什么类型的媒体(视频、音频、字幕、数据等)。FFmpeg 用AVMEDIA_TYPE_*系列枚举来区分。
这次的插图是可爱真由理呢,来自画师Sakana٩(ˊᗜˋ*)و
图片地址:https://www.pixiv.net/artworks/123534170
嘟嘟噜~