5、FFmpeg解码实战:FFmpeg解码流程、AVPacket与AVFrame、音频解码、视频解码
各位同学,欢迎来到第五章。前面几章我们把FFmpeg的编译、基础数据结构都过了一遍。今天咱们来点真格的——解码实战。
说实话,我当年刚接触FFmpeg解码时,最头疼的就是搞不清AVPacket和AVFrame到底谁先谁后。代码跑起来要么花屏,要么没声音。后来踩了无数坑,才把整个流程理顺。今天我就把这点压箱底的经验全倒出来。
5.1 解码核心流程:别被API吓到
FFmpeg的解码,说白了就三步:打开 -> 喂数据 -> 取结果。但中间有几个关键节点,我习惯用一张图来记。
嗯,这张图我画了好几次才满意。你看,前面四个步骤都是准备工作,真正的解码在最后那个循环里。我个人习惯把最后三步叫做「喂-拉-取」模型。
5.2 AVPacket 与 AVFrame:解码前后的「容器」
这两个结构体,是解码过程中最重要的两个「容器」。我刚开始学的时候,总把它们搞混。后来想了个笨办法:AVPacket是压缩后的数据,AVFrame是解码后的数据。
5.2.1 AVPacket:压缩数据包
AVPacket 里装的是什么?说白了就是编码器吐出来的原始码流。比如 H.264 的 NALU,或者 AAC 的 ADTS 帧。它有几个关键字段:
| 字段 | 说明 |
|---|---|
| uint8_t *data | 压缩数据指针 |
| int size | 数据大小 |
| int64_t pts | 显示时间戳 |
| int64_t dts | 解码时间戳 |
| int stream_index | 所属流索引 |
5.2.2 AVFrame:解码后的「画面」或「声音」
AVFrame 就丰富多了。对于视频,它存的是像素数据;对于音频,它存的是 PCM 采样数据。我挑几个常用的字段说说:
| 字段 | 说明 |
|---|---|
| uint8_t *data[8] | 数据指针数组(YUV 或 PCM) |
| int linesize[8] | 每行字节数(对齐后的宽度) |
| int width, height | 视频宽高 |
| int nb_samples | 音频采样数 |
| int format | 像素格式或采样格式 |
| int64_t pts | 显示时间戳 |
5.3 视频解码实战:从 H.264 到 YUV
好了,理论说够了,咱们直接上代码。视频解码的核心逻辑,其实就是那个「喂-拉-取」循环。
// 1. 打开输入文件
AVFormatContext *fmt_ctx = NULL;
avformat_open_input(&fmt_ctx, "input.mp4", NULL, NULL);
avformat_find_stream_info(fmt_ctx, NULL);
// 2. 找到视频流
int video_stream_idx = av_find_best_stream(fmt_ctx, AVMEDIA_TYPE_VIDEO, -1, -1, NULL, 0);
AVStream *video_stream = fmt_ctx->streams[video_stream_idx];
// 3. 打开解码器
const AVCodec *decoder = avcodec_find_decoder(video_stream->codecpar->codec_id);
AVCodecContext *codec_ctx = avcodec_alloc_context3(decoder);
avcodec_parameters_to_context(codec_ctx, video_stream->codecpar);
avcodec_open2(codec_ctx, decoder, NULL);
// 4. 分配 packet 和 frame
AVPacket *pkt = av_packet_alloc();
AVFrame *frame = av_frame_alloc();
// 5. 解码循环
while (av_read_frame(fmt_ctx, pkt) >= 0) {
if (pkt->stream_index == video_stream_idx) {
// 喂数据给解码器
int ret = avcodec_send_packet(codec_ctx, pkt);
if (ret < 0) continue;
// 拉取解码后的帧
while (ret >= 0) {
ret = avcodec_receive_frame(codec_ctx, frame);
if (ret == AVERROR(EAGAIN) || ret == AVERROR_EOF) break;
if (ret < 0) break;
// 到这里,frame 里就是 YUV 数据了
// 你可以渲染、保存或做其他处理
// 比如:save_yuv_frame(frame, width, height);
}
}
av_packet_unref(pkt);
}
// 6. 清理
av_frame_free(&frame);
av_packet_free(&pkt);
avcodec_free_context(&codec_ctx);
avformat_close_input(&fmt_ctx);
这段代码我几乎每个项目都会复用。你注意看那个内层 while 循环——avcodec_send_packet 一次,avcodec_receive_frame 可能多次。为什么?因为有些解码器(比如某些硬件解码器)会缓存帧,一次喂入可能吐出多帧。反过来,也可能喂多次才吐一帧。所以这个「一喂多取」的模式是最稳妥的。
5.4 音频解码实战:从 AAC 到 PCM
音频解码和视频解码的流程几乎一模一样。区别在于:音频的 AVFrame 里装的是 PCM 采样数据,而且有声道、采样率这些概念。
// 找到音频流
int audio_stream_idx = av_find_best_stream(fmt_ctx, AVMEDIA_TYPE_AUDIO, -1, -1, NULL, 0);
AVStream *audio_stream = fmt_ctx->streams[audio_stream_idx];
// 打开音频解码器
const AVCodec *audio_decoder = avcodec_find_decoder(audio_stream->codecpar->codec_id);
AVCodecContext *audio_codec_ctx = avcodec_alloc_context3(audio_decoder);
avcodec_parameters_to_context(audio_codec_ctx, audio_stream->codecpar);
avcodec_open2(audio_codec_ctx, audio_decoder, NULL);
// 解码循环(和视频一样)
while (av_read_frame(fmt_ctx, pkt) >= 0) {
if (pkt->stream_index == audio_stream_idx) {
avcodec_send_packet(audio_codec_ctx, pkt);
while (avcodec_receive_frame(audio_codec_ctx, frame) >= 0) {
// frame->data[0] 就是 PCM 数据
// frame->nb_samples 是采样点数
// frame->channels 是声道数
// 你可以播放、混音或保存为 WAV
}
}
av_packet_unref(pkt);
}
5.5 避坑指南:我踩过的那些坑
做解码这么多年,有些坑我几乎每个项目都会遇到。今天一次性说清楚:
- 时间戳乱跳:av_read_frame 拿到的 pkt->pts 可能是乱的,尤其是 B 帧多的视频。解决办法是用
av_packet_rescale_ts()统一转成微秒时间基。 - 解码器没刷新:文件读完后,要调用一次
avcodec_send_packet(NULL)来刷新解码器缓冲区。否则最后几帧可能丢。 - 内存泄漏:AVPacket 和 AVFrame 一定要配对使用 av_packet_unref 和 av_frame_unref。我见过有人只 alloc 不 free,跑 10 分钟就 OOM。
- 硬件解码回退:如果用了硬件解码器(如 MediaCodec),记得加个 fallback 到软件解码的逻辑。有些设备硬件解码器不支持某些 profile。
嗯,说到硬件解码,我记得有一次在某个低端平板上,硬解 H.265 一直报错,最后发现是驱动 bug。后来我加了个「硬解失败自动切软解」的逻辑,才算彻底解决。
5.6 本章小结
这一章我们走通了 FFmpeg 解码的完整流程。从 AVPacket 到 AVFrame,从视频到音频,核心就是那个「喂-拉-取」循环。你只要记住:av_read_frame 拿压缩包,avcodec_send_packet 喂给解码器,avcodec_receive_frame 取解码结果。剩下的就是处理各种边界情况。
下一章我们会深入音视频同步,那才是真正考验功底的地方。到时候我会分享一个我在直播项目中用的同步算法,保证让你眼前一亮。