4、FFmpeg解封装实战:打开媒体文件、读取数据包、分离音视频流,实现一个简单的媒体信息查看器。
好,咱们今天来点真家伙。
前面几章我们把FFmpeg的环境搭好了,也聊了音视频的基础概念。说实话,光看理论容易犯困。我个人习惯是,学一个新库,先让它跑起来,看到输出,心里才踏实。
这一章,我们就手写一个简单的媒体信息查看器。它能打开一个媒体文件,读出里面的音视频流信息,然后把关键数据打印出来。说白了,就是FFmpeg版的ffprobe——当然,是简化版。
4.1 解封装到底在干什么?
先想一个问题:一个MP4文件,里面既有视频轨道,又有音频轨道,还有字幕轨道。它们是怎么塞在一个文件里的?
答案是:封装格式(Container Format)。MP4、MKV、AVI、FLV这些都是封装格式。它们像是一个集装箱,把视频流、音频流、元数据(metadata)打包在一起。
解封装(Demuxing),就是把这个集装箱拆开,把里面的货物——也就是一个个数据包(Packet)——取出来。注意,这时候取出来的还是压缩后的数据,不是我们能直接播放的像素或声音。
核心流程:
打开文件 → 探测流信息 → 循环读取数据包 → 根据流类型分发处理 → 关闭文件
我在项目中遇到过不少新手,上来就想解码,结果连封装格式都没搞清楚。你想想看,连集装箱都没拆,就想拿到里面的货物,怎么可能呢?
4.2 核心API速览
在写代码之前,我们先认识几个关键函数。它们就是今天的主角。
| 函数 | 作用 | 备注 |
|---|---|---|
avformat_open_input |
打开媒体文件,读取文件头 | 必须调用 |
avformat_find_stream_info |
探测流信息,填充流元数据 | 建议调用,否则信息不全 |
av_read_frame |
读取一个数据包 | 核心循环 |
av_packet_alloc |
分配一个Packet对象 | 记得释放 |
avformat_close_input |
关闭文件,释放资源 | 善后工作 |
嗯,这里要注意:av_read_frame返回的是压缩后的数据包,不是解码后的帧。很多初学者在这里会搞混。
4.3 实战:写一个媒体信息查看器
好,我们直接上代码。我会把每一段都拆开讲,确保你不仅能跑起来,还能理解为什么这么写。
4.3.1 初始化与打开文件
#include <iostream>
#include <cstring>
extern "C" {
#include <libavformat/avformat.h>
#include <libavutil/avutil.h>
}
int main(int argc, char* argv[]) {
if (argc < 2) {
std::cerr << "用法: " << argv[0] << " <媒体文件路径>" << std::endl;
return -1;
}
const char* filename = argv[1];
// 1. 打开输入文件
AVFormatContext* fmt_ctx = nullptr;
int ret = avformat_open_input(&fmt_ctx, filename, nullptr, nullptr);
if (ret < 0) {
char errbuf[AV_ERROR_MAX_STRING_SIZE];
av_strerror(ret, errbuf, sizeof(errbuf));
std::cerr << "打开文件失败: " << errbuf << std::endl;
return -1;
}
// 2. 探测流信息
ret = avformat_find_stream_info(fmt_ctx, nullptr);
if (ret < 0) {
std::cerr << "无法获取流信息" << std::endl;
avformat_close_input(&fmt_ctx);
return -1;
}
这段代码做了两件事:打开文件,然后探测流信息。为什么要分两步?因为有些文件(比如网络流)的流信息不是一开始就完整的,需要读一部分数据才能知道。我曾经调试过一个RTSP流,忘记调用avformat_find_stream_info,结果所有流的时长都是0,排查了半天。
4.3.2 打印文件基本信息
// 3. 打印文件基本信息
std::cout << "========== 媒体文件信息 ==========" << std::endl;
std::cout << "文件名: " << filename << std::endl;
std::cout << "封装格式: " << (fmt_ctx->iformat->name ? fmt_ctx->iformat->name : "未知") << std::endl;
std::cout << "时长: " << fmt_ctx->duration / AV_TIME_BASE << " 秒" << std::endl;
std::cout << "比特率: " << fmt_ctx->bit_rate << " bps" << std::endl;
std::cout << "流数量: " << fmt_ctx->nb_streams << std::endl;
这里有个细节:fmt_ctx->duration的单位是微秒(AV_TIME_BASE = 1,000,000),所以除以AV_TIME_BASE得到秒数。比特率是平均比特率,有些文件可能为0,这很正常。
4.3.3 遍历并打印每个流的信息
// 4. 遍历每个流
for (unsigned int i = 0; i < fmt_ctx->nb_streams; ++i) {
AVStream* stream = fmt_ctx->streams[i];
AVCodecParameters* codecpar = stream->codecpar;
std::cout << "----------------------------------------" << std::endl;
std::cout << "流索引: " << i << std::endl;
// 判断流类型
const char* type_str = "未知";
switch (codecpar->codec_type) {
case AVMEDIA_TYPE_VIDEO: type_str = "视频"; break;
case AVMEDIA_TYPE_AUDIO: type_str = "音频"; break;
case AVMEDIA_TYPE_SUBTITLE: type_str = "字幕"; break;
default: break;
}
std::cout << "类型: " << type_str << std::endl;
// 打印编码器名称
const AVCodec* codec = avcodec_find_decoder(codecpar->codec_id);
std::cout << "编码器: " << (codec ? codec->name : "未知") << std::endl;
// 视频特有信息
if (codecpar->codec_type == AVMEDIA_TYPE_VIDEO) {
std::cout << "分辨率: " << codecpar->width << "x" << codecpar->height << std::endl;
std::cout << "帧率: ";
if (stream->avg_frame_rate.den > 0) {
std::cout << av_q2d(stream->avg_frame_rate) << " fps" << std::endl;
} else {
std::cout << "未知" << std::endl;
}
}
// 音频特有信息
if (codecpar->codec_type == AVMEDIA_TYPE_AUDIO) {
std::cout << "采样率: " << codecpar->sample_rate << " Hz" << std::endl;
std::cout << "声道数: " << codecpar->ch_layout.nb_channels << std::endl;
}
}
这段代码的核心是AVCodecParameters。它包含了流的编码参数。注意,我用了avcodec_find_decoder来获取编码器名称,这比直接打印codec_id的数字要友好得多。
帧率的计算是个坑。FFmpeg中有好几个帧率相关的字段:avg_frame_rate、r_frame_rate、time_base。我个人习惯用avg_frame_rate,它更接近实际的平均帧率。但要注意,有些文件这个值可能为0/0,所以一定要做分母检查。
4.3.4 读取数据包并统计
// 5. 读取数据包并统计
AVPacket* pkt = av_packet_alloc();
int video_packets = 0, audio_packets = 0;
int64_t video_bytes = 0, audio_bytes = 0;
std::cout << "\n========== 开始读取数据包 ==========" << std::endl;
while (av_read_frame(fmt_ctx, pkt) >= 0) {
if (pkt->stream_index < fmt_ctx->nb_streams) {
AVStream* stream = fmt_ctx->streams[pkt->stream_index];
if (stream->codecpar->codec_type == AVMEDIA_TYPE_VIDEO) {
video_packets++;
video_bytes += pkt->size;
} else if (stream->codecpar->codec_type == AVMEDIA_TYPE_AUDIO) {
audio_packets++;
audio_bytes += pkt->size;
}
}
av_packet_unref(pkt);
}
std::cout << "视频包数量: " << video_packets << ", 总字节数: " << video_bytes << std::endl;
std::cout << "音频包数量: " << audio_packets << ", 总字节数: " << audio_bytes << std::endl;
// 6. 清理
av_packet_free(&pkt);
avformat_close_input(&fmt_ctx);
return 0;
}
这里有个关键点:av_read_frame每次返回一个包,我们需要用av_packet_unref释放包内数据,否则会内存泄漏。为什么不用av_packet_free?因为av_packet_free会释放整个Packet对象,而我们希望复用这个对象,只释放内部数据。
小技巧:如果你只想快速查看文件信息而不想遍历所有包,可以只执行前几步,跳过数据包读取循环。但遍历包能让你了解文件的内部结构,比如I帧和P帧的分布情况。
4.4 知识体系总览
为了让你更直观地理解整个流程,我画了一张图。这张图展示了从打开文件到读取数据包的完整链路。
这张图把整个流程串起来了。你可以看到,核心就是那个循环——av_read_frame不断吐出数据包,我们根据流类型分别处理。最后别忘了关闭输入,释放资源。
4.5 编译与运行
代码写好了,怎么编译?假设你的FFmpeg安装在/usr/local/ffmpeg,可以这样编译:
g++ -o media_info media_info.cpp \
-I/usr/local/ffmpeg/include \
-L/usr/local/ffmpeg/lib \
-lavformat -lavcodec -lavutil
然后运行:
./media_info /path/to/your/video.mp4
你会看到类似这样的输出:
========== 媒体文件信息 ==========
文件名: /path/to/your/video.mp4
封装格式: mov,mp4,m4a,3gp,3g2,mj2
时长: 120 秒
比特率: 2500000 bps
流数量: 2
----------------------------------------
流索引: 0
类型: 视频
编码器: h264
分辨率: 1920x1080
帧率: 25 fps
----------------------------------------
流索引: 1
类型: 音频
编码器: aac
采样率: 48000 Hz
声道数: 2
========== 开始读取数据包 ==========
视频包数量: 3000, 总字节数: 37500000
音频包数量: 5625, 总字节数: 5625000
注意:如果你在Windows上编译,记得链接avformat.lib、avcodec.lib、avutil.lib,并且要处理dll的运行时依赖。我曾经在Windows上折腾了半天,最后发现是PATH环境变量没配好,FFmpeg的dll找不到。
4.6 避坑指南
写这个工具的时候,有几个坑我踩过,分享给你:
- 内存泄漏:每次
av_read_frame后一定要调用av_packet_unref,否则内存会持续增长。特别是处理大文件时,几分钟就能吃掉几个G。 - 流索引越界:访问
fmt_ctx->streams[pkt->stream_index]前,一定要检查索引是否小于nb_streams。虽然理论上不会越界,但某些损坏的文件可能会搞出幺蛾子。 - 帧率计算:不要直接用
stream->r_frame_rate,它可能是容器中的猜测值。用avg_frame_rate更靠谱,但记得检查分母是否为0。 - 时长单位:
fmt_ctx->duration的单位是微秒,不是毫秒。我见过有人直接当毫秒用,结果显示的时间差了1000倍。
嗯,这些坑说起来都是小问题,但排查起来真的很费时间。尤其是内存泄漏,在嵌入式设备上跑几个小时才暴露出来,那才叫头疼。
4.7 扩展思路
这个查看器虽然简单,但已经能应付大部分日常需求了。如果你想继续扩展,可以考虑:
- 输出JSON格式,方便其他程序解析
- 支持更多流类型,比如字幕、数据流
- 显示关键帧(I帧)的位置和数量
- 计算平均关键帧间隔
我个人觉得,这个工具最大的价值不是功能多强大,而是让你亲手触摸到了FFmpeg解封装的脉搏。当你看到那些数据包一个个被读出来,你会对音视频处理有更直观的理解。
好,这一章就到这里。代码不多,但都是干货。建议你亲手敲一遍,然后找个视频文件跑一下。遇到问题别慌,回头看看避坑指南,多半能找到答案。
公众号:蓝海资料掘金营,微信deep3321