DongGu
播放器开发--音视频解码(1)

播放器开发--音视频解码(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
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
bool Demuxer::open(const char* filepath) {
if(_formatContext){
std::cerr << "Format context is already initialized." << std::endl;
std::cerr << "Closing existing format context before opening new file." << std::endl;
avformat_close_input(&_formatContext);
_formatContext = nullptr;
}

std::cout << "Demuxer: Opening file " << filepath << "..." << std::endl;
int ret = avformat_open_input(&_formatContext, filepath, nullptr, nullptr);
if (ret < 0) {
char errbuf[AV_ERROR_MAX_STRING_SIZE];
av_strerror(ret, errbuf, sizeof(errbuf));
std::cerr << "Could not open file: " << errbuf << std::endl;
return false;
}

std::cout << "Demuxer: Finding stream info..." << std::endl;
ret = avformat_find_stream_info(_formatContext, nullptr);
if (ret < 0) {
char errbuf[AV_ERROR_MAX_STRING_SIZE];
av_strerror(ret, errbuf, sizeof(errbuf));
std::cerr << "Could not find stream info: " << errbuf << std::endl;
return false;
}

// 设置 I/O 中断回调,stop/seek 时能中断阻塞的 av_read_frame
_formatContext->interrupt_callback.callback = &Demuxer::interruptCallback;
_formatContext->interrupt_callback.opaque = this;

std::cout << "Demuxer: Dumping format information..." << std::endl;
av_dump_format(_formatContext, 0, filepath, 0);

// 缓存 duration,避免后续与 demux 线程竞争 AVFormatContext
if (_formatContext->duration != AV_NOPTS_VALUE) {
_cachedDuration = _formatContext->duration / static_cast<double>(AV_TIME_BASE);
} else {
_cachedDuration = 0.0;
}
_cachedVideoEndTime = 0.0;

return true;
}

前置概念:为什么 FFmpeg 处处是 Context?

在深入 open() 之前,需要理解 FFmpeg 最核心的设计模式。你会发现几乎所有 FFmpeg 操作都围绕一个 *Context 结构体展开——AVFormatContextAVCodecContextSwsContextSwrContext 等等。原因有四:

  1. 封装状态信息:多媒体处理是一个有状态的过程。以解码为例,当前帧的解码往往依赖前一帧(参考帧)。Context 结构体内部维护了整个处理周期的所有状态,你只需要把同一个 Context 指针传给后续函数,它就知道”之前发生了什么,现在该做什么”。

  2. 面向对象的 C 语言实现:FFmpeg 主要是 C 语言编写的,C 没有类的概念。Context 实际上扮演了”类实例”的角色——结构体成员是属性,操作该结构体的 FFmpeg 函数是方法。

  3. 支持多路并行处理:每个 Context 都是独立的,互不干扰。这使得 FFmpeg 能轻松支持多线程解码和多流并发——比如同时播放视频轨和音频轨,各自拥有独立的 CodecContext。

  4. 统一的配置与扩展(AVOptions 机制):所有 Context 结构体的首地址通常都有 AVClass 指针。这使得 FFmpeg 可以用一套统一代码管理不同模块的参数设置、日志打印和内存释放。

所以我们在 Demuxer 类中声明了 AVFormatContext* _formatContext,后续所有解封装操作都围绕它展开。

open() 的四步流程

第 1 步:旧上下文清理

打开新文件前,如果旧的上下文还存在,调用 avformat_close_input() 释放它。这确保每次 open() 都从干净的状态开始。

1
avformat_close_input(&_formatContext);  // 释放旧上下文,同时将指针置空

第 2 步:打开文件

1
2
3
4
5
6
int avformat_open_input(
AVFormatContext **ps, // 指向上下文指针的指针(会分配新的 AVFormatContext)
const char *url, // 文件路径
const AVInputFormat *fmt, // 强制指定输入格式,传 nullptr 让 FFmpeg 自动探测
AVDictionary **options // 附加配置参数,传 nullptr 使用默认值
);

这个函数会探测文件格式,分配 AVFormatContext,读取文件头信息。

第 3 步:探测流信息

1
2
3
4
int avformat_find_stream_info(
AVFormatContext *ic, // 上下文
AVDictionary **options // 附加选项
);

这一步会真正读取一部分数据,分析出文件中包含哪些流(视频、音频、字幕),并把每个流的信息填入 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
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
AVPacketPtr Demuxer::readPacket() {
AVPacketPtr packet(av_packet_alloc(), AVPacketDeleter());
if (!packet) {
std::cerr << "Demuxer: Could not allocate packet." << std::endl;
return nullptr;
}

int ret = av_read_frame(_formatContext, packet.get());
if (ret < 0) {
if (ret != AVERROR_EOF) {
char errbuf[AV_ERROR_MAX_STRING_SIZE];
av_strerror(ret, errbuf, sizeof(errbuf));
std::cerr << "Demuxer: Could not read frame: " << errbuf << std::endl;
}
return nullptr;
}

return packet;
}

readPacket() 的两步流程

第 1 步:分配 packet

1
AVPacketPtr packet(av_packet_alloc(), AVPacketDeleter());

av_packet_alloc() 在堆上分配一个 AVPacket 并返回其指针。对应的释放函数是 av_packet_free(AVPacket **pkt)。这里用自定义 deleter 的 unique_ptrAVPacketPtr)自动管理生命周期,出作用域时自动调用 av_packet_free,避免手动释放。具体介绍见上一篇队列文章中的 unique_ptr 扩展一节。

第 2 步:读取一帧压缩数据

1
int ret = av_read_frame(_formatContext, packet.get());

av_read_frame() 是解封装最核心的函数。它从文件中读取下一个 AVPacket,填好 datastream_indexptsdts 等字段后返回。这个函数可能阻塞(等待磁盘 I/O 或网络数据),所以在 stop/seek 时需要 I/O 中断机制唤醒它。注意,虽然函数名是read_frame,但是返回类型是AVPacket而不是AVFrame。

返回值处理:

  • == 0:成功读取,返回的 packet 交给后续分发逻辑
  • == AVERROR_EOF:文件读完,正常结束,不打印错误
  • 其他负值:真正的错误,用 av_strerror() 打印可读信息

findStream() 函数

packet 里只有一个 stream_index(整数编号),我们需要知道这个编号对应的是视频流还是音频流,才能把 packet 送到正确的解码通道。findStream() 就是用来建立这个映射的。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
int Demuxer::findVideoStream() {
_videoStreamIndex = -1;
for (unsigned int i = 0; i < _formatContext->nb_streams; i++) {
if (_formatContext->streams[i]->codecpar->codec_type == AVMEDIA_TYPE_VIDEO) {
_videoStreamIndex = i;
break;
}
}
return _videoStreamIndex;
}

int Demuxer::findAudioStream() {
_audioStreamIndex = -1;
for (unsigned int i = 0; i < _formatContext->nb_streams; i++) {
if (_formatContext->streams[i]->codecpar->codec_type == AVMEDIA_TYPE_AUDIO) {
_audioStreamIndex = i;
break;
}
}
return _audioStreamIndex;
}

逻辑很简单:遍历 _formatContext->streams[] 数组,找到 codec_typeAVMEDIA_TYPE_VIDEOAVMEDIA_TYPE_AUDIO 的那条流,记录其 index。

其中:

  • codecparAVCodecParameters*,Codec Parameters 的缩写):存储了该流的最基本信息——编码格式(H.264 / AAC)、分辨率、采样率等。注意它不是解码器上下文,只是元数据,不包含解码状态。
  • codec_type:枚举值,表示这条流是什么类型的媒体(视频、音频、字幕、数据等)。FFmpeg 用 AVMEDIA_TYPE_* 系列枚举来区分。

这次的插图是可爱真由理呢,来自画师Sakana٩(ˊᗜˋ*)و
图片地址:https://www.pixiv.net/artworks/123534170
嘟嘟噜~

本项目源码请看:https://github.com/DongGuZhengHuaJi/VideoPlayer

本文作者:DongGu
本文链接:https://donggu.xyz/2026/05/09/音视频学习/音视频解码-解封装篇/
版权声明:本文采用 CC BY-NC-SA 3.0 CN 协议进行许可