2、FFmpeg核心数据结构:AVFormatContext、AVInputFormat、AVOutputFormat 深度解析

做封装解析,说白了就是跟这三个结构体打交道。我刚开始接触FFmpeg时,看着AVFormatContext那几百个字段,说实话有点懵。后来踩了几个坑才明白——核心就三个结构体,搞懂了它们,封装层基本就通了。

2.1 AVFormatContext:封装层的“总控台”

AVFormatContext是整个封装格式层的上下文。你可以把它想象成一个总控台,所有跟容器格式相关的信息都挂在这里。不管是读文件还是写文件,第一步都是创建它。

核心定位:AVFormatContext 是 I/O 层与编解码层之间的桥梁。它管理着流信息、元数据、封装格式的输入输出状态。

2.1.1 关键字段一览

我挑几个最常用的字段说说。其他的你用到时再查文档就行,不用死记。

字段名 类型 作用 我的备注
iformat AVInputFormat* 输入封装格式指针 打开文件时自动匹配
oformat AVOutputFormat* 输出封装格式指针 写文件时需指定
nb_streams unsigned int 流数量 视频、音频、字幕都算
streams AVStream** 流指针数组 每个流对应一个AVStream
duration int64_t 时长(微秒) 有些格式不准确
bit_rate int64_t 总码率 估算值,别太当真
metadata AVDictionary* 元数据字典 标题、作者等信息
pb AVIOContext* I/O上下文 读写字节的通道

2.1.2 生命周期管理

创建和销毁有固定套路。我见过有人直接malloc,结果内存泄漏得一塌糊涂。

// 读模式:avformat_open_input 内部会创建
AVFormatContext *fmt_ctx = NULL;
avformat_open_input(&fmt_ctx, "input.mp4", NULL, NULL);
// ... 使用 ...
avformat_close_input(&fmt_ctx);  // 自动释放内部资源

// 写模式:先手动分配
AVFormatContext *ofmt_ctx = NULL;
avformat_alloc_output_context2(&ofmt_ctx, NULL, "mp4", "output.mp4");
// ... 使用 ...
avformat_free_context(ofmt_ctx);  // 手动释放

注意:avformat_open_input 和 avformat_alloc_output_context2 的释放方式不同。前者用 avformat_close_input,后者用 avformat_free_context。混用会导致 double free 或内存泄漏。我曾经在这个问题上排查了整整一下午。

2.2 AVInputFormat:解封装器的“身份证”

AVInputFormat 描述的是“怎么读”某种容器格式。每个容器格式——MP4、FLV、TS、MKV——都有一个对应的 AVInputFormat 实例。它不存储数据,只存储行为。

2.2.1 核心成员

这个结构体是只读的,你不需要自己创建。FFmpeg 内部已经注册好了所有支持的格式。

成员 说明
name 格式名称,如 "mp4"、"flv"
long_name 描述性名称,如 "QuickTime / MOV"
extensions 文件扩展名列表,逗号分隔
read_probe 探测函数,判断文件是否匹配本格式
read_header 读取文件头,解析流信息
read_packet 读取一个数据包
read_close 关闭解封装器

2.2.2 格式探测机制

你想想看,当你给 FFmpeg 一个文件时,它怎么知道该用哪个解封装器?靠的就是 av_probe_input_format 函数。它会读取文件开头的一小段数据,然后跟所有注册的 AVInputFormat 的 read_probe 函数比对得分。

// 手动探测格式的示例
AVProbeData pd = {0};
pd.buf = buf;       // 文件头数据
pd.buf_size = size; // 数据大小
pd.filename = "unknown.bin";

AVInputFormat *fmt = av_probe_input_format(&pd, 1);
if (fmt) {
    printf("检测到格式: %s\n", fmt->long_name);
}

小技巧:如果你确定文件格式,可以直接传 AVInputFormat 指针给 avformat_open_input,跳过探测步骤,能省几十毫秒。我在做批量转码工具时就这么干的。

2.3 AVOutputFormat:封装器的“施工图”

AVOutputFormat 跟 AVInputFormat 是对称的。它描述的是“怎么写”某种容器格式。写 MP4 和写 FLV 的流程不同,但都遵循同一套接口。

2.3.1 关键成员对比

AVInputFormat AVOutputFormat 说明
read_header write_header 读/写文件头
read_packet write_packet 读/写数据包
read_close write_trailer 关闭/写文件尾
read_probe 无 输出格式不需要探测
无 audio_codec 默认音频编码器ID
无 video_codec 默认视频编码器ID

2.3.2 输出格式的匹配方式

输出格式通常通过文件名后缀或显式指定。avformat_alloc_output_context2 会帮你自动匹配。

// 方式一:通过文件名后缀自动匹配
avformat_alloc_output_context2(&ctx, NULL, NULL, "output.mp4");

// 方式二:显式指定格式名
avformat_alloc_output_context2(&ctx, NULL, "flv", "output.flv");

// 方式三:直接传 AVOutputFormat 指针
AVOutputFormat *fmt = av_guess_format("mpegts", NULL, NULL);
avformat_alloc_output_context2(&ctx, fmt, NULL, "output.ts");

2.4 三者协作关系

这三个结构体不是孤立的。它们通过 AVFormatContext 串联起来,形成完整的封装处理链路。我画了张图,你看一眼就明白了。

AVFormatContext / AVInputFormat / AVOutputFormat 协作关系 AVFormatContext 封装层总控台 AVInputFormat 解封装器行为描述 read_header / read_packet AVOutputFormat 封装器行为描述 write_header / write_packet iformat 指针 oformat 指针 AVStream[] nb_streams 个流 每个流含编解码参数 AVIOContext pb 指针 字节级读写通道 核心流程 AVFormatContext 通过 iformat/oformat 指针调用具体格式的读写函数 streams 数组管理所有媒体流,pb 负责底层 I/O

2.5 实战中的常见陷阱

嗯,这里要注意几个坑。我这些年没少在这些地方栽跟头。

2.5.1 流索引的稳定性

AVStream 在 AVFormatContext 中的索引位置,在读取过程中是稳定的。但如果你手动添加或移除流,索引就会变。我做过一个项目,在写 MP4 时动态添加字幕流,结果忘了更新索引,导致音视频不同步。

2.5.2 元数据的生命周期

AVDictionary 类型的 metadata 字段,在 avformat_close_input 时会自动释放。但如果你把 metadata 指针复制到其他地方使用,记得先 av_dict_ref 增加引用计数,否则会变成悬空指针。

2.5.3 自定义 I/O 时的 pb 处理

如果你用 avio_alloc_context 创建了自定义的 AVIOContext,记得设置 AVFormatContext 的 pb 字段,并且要自己管理 AVIOContext 的释放。avformat_close_input 不会释放你手动分配的 pb。

// 自定义 I/O 的正确释放顺序
avformat_close_input(&fmt_ctx);  // 先关格式上下文
avio_context_free(&custom_pb);   // 再释放自定义 I/O

警告:顺序反了会导致 use-after-free。avformat_close_input 内部会读写 pb,如果你先释放了 pb,程序直接崩溃。我曾经在生产环境遇到过这个问题,排查时发现 core dump 堆栈指向了 avio_read。

2.6 小结

AVFormatContext 是总控台,AVInputFormat 是读格式的行为描述,AVOutputFormat 是写格式的行为描述。三者通过指针关联,构成了 FFmpeg 封装层的核心骨架。搞懂它们,你就掌握了 FFmpeg 封装层的钥匙。

我个人习惯在写工具时,先把这三个结构体的生命周期画出来,再动手编码。这样能避免很多低级错误。你也不妨试试。