5、FFmpeg解码实战:初始化解码器、解码视频帧与音频帧,处理解码后的数据,实现一个帧提取工具。

说实话,解码这块儿是音视频开发里最基础也最核心的活儿。你想想看,不管你是做播放器、转码工具还是分析工具,第一步都得先把压缩的数据给解出来。今天我就带你手撸一个帧提取工具,把视频里的关键帧、音频采样点一个个拽出来。

5.1 解码器初始化——别急着干活,先搭好架子

解码器初始化这事儿,说白了就是告诉FFmpeg:「嘿,我要用你这个解码器了,帮我准备好工作台。」我刚开始学的时候,总以为直接调个函数就完事了,结果踩了不少坑。

核心流程:

  1. 查找解码器(根据编码ID或名字)
  2. 分配解码器上下文
  3. 将流中的参数复制到上下文
  4. 打开解码器

来看一段实际代码。我个人习惯把初始化解码器封装成一个函数,这样复用起来方便。

AVCodecContext *init_decoder(AVStream *stream) {
    // 1. 根据流的编码ID找解码器
    const AVCodec *codec = avcodec_find_decoder(stream->codecpar->codec_id);
    if (!codec) {
        fprintf(stderr, "找不到解码器\n");
        return NULL;
    }

    // 2. 分配解码器上下文
    AVCodecContext *codec_ctx = avcodec_alloc_context3(codec);
    if (!codec_ctx) {
        fprintf(stderr, "分配上下文失败\n");
        return NULL;
    }

    // 3. 从流参数复制到上下文
    if (avcodec_parameters_to_context(codec_ctx, stream->codecpar) < 0) {
        fprintf(stderr, "复制参数失败\n");
        avcodec_free_context(&codec_ctx);
        return NULL;
    }

    // 4. 打开解码器
    if (avcodec_open2(codec_ctx, codec, NULL) < 0) {
        fprintf(stderr, "打开解码器失败\n");
        avcodec_free_context(&codec_ctx);
        return NULL;
    }

    return codec_ctx;
}

小提示:avcodec_find_decoder 和 avcodec_find_decoder_by_name 的区别?前者用编码ID查找,后者用解码器名字(比如 "h264")。我一般用前者,因为从流里拿到的就是编码ID,省事。

5.2 解码视频帧——把压缩的像素解放出来

视频解码,说白了就是把H.264、HEVC这些压缩数据还原成一幅幅图像。这里有个关键点:不是每个packet都能解出一帧。为什么?因为视频编码有I帧、P帧、B帧的依赖关系,有时候需要攒几个packet才能解出一帧。

我在项目中遇到过这种情况:用手机录的视频,B帧特别多,解码时经常返回NULL帧。一开始我还以为代码写错了,后来才明白这是正常的。

int decode_video_frame(AVCodecContext *codec_ctx, 
                       AVPacket *pkt, 
                       AVFrame *frame) {
    // 发送压缩数据到解码器
    int ret = avcodec_send_packet(codec_ctx, pkt);
    if (ret < 0) {
        fprintf(stderr, "发送packet失败: %d\n", ret);
        return ret;
    }

    // 接收解码后的帧
    ret = avcodec_receive_frame(codec_ctx, frame);
    if (ret == AVERROR(EAGAIN)) {
        // 需要更多数据才能解出一帧
        return 0;
    } else if (ret == AVERROR_EOF) {
        // 解码结束
        return -1;
    } else if (ret < 0) {
        fprintf(stderr, "接收帧失败\n");
        return ret;
    }

    // 成功解出一帧
    return 1;
}

注意:avcodec_send_packet 和 avcodec_receive_frame 是成对出现的。你可能会想「我一次发送多个packet再一次性接收行不行?」嗯,理论上可以,但实际中我建议你发一个收一个,逻辑清晰,不容易出bug。

5.3 解码音频帧——跟视频套路差不多,但有坑

音频解码跟视频解码的API是一样的,都是send_packet + receive_frame。但音频帧跟视频帧有个本质区别:音频帧的采样点数是固定的(通常是1024或2048),而视频帧的分辨率是变化的。

我记得有一次做音频转码,发现输出音频时长不对。查了半天,原来是解码后的音频格式跟预期不一致——解码器输出的可能是FLTP(平面浮点),而我直接当S16(有符号16位)处理了。嗯,这里要注意:解码后一定要检查frame的格式

int decode_audio_frame(AVCodecContext *codec_ctx,
                       AVPacket *pkt,
                       AVFrame *frame) {
    int ret = avcodec_send_packet(codec_ctx, pkt);
    if (ret < 0) return ret;

    ret = avcodec_receive_frame(codec_ctx, frame);
    if (ret == AVERROR(EAGAIN)) return 0;
    if (ret < 0) return ret;

    // 检查音频格式
    printf("音频格式: %s, 采样率: %d, 通道数: %d\n",
           av_get_sample_fmt_name(frame->format),
           frame->sample_rate,
           frame->ch_layout.nb_channels);

    // 这里可以处理音频数据了
    // 比如重采样、保存PCM等
    return 1;
}

5.4 处理解码后的数据——从像素到文件

解码出帧之后,怎么处理就看你的需求了。做帧提取工具的话,通常需要把视频帧保存为图片,把音频帧保存为PCM文件。

保存视频帧为图片,我一般用 sws_scale 把解码后的YUV数据转成RGB,然后写BMP或PNG。这里有个性能问题:频繁创建SwsContext很慢,最好在循环外面创建一次,重复使用。

// 保存视频帧为BMP(简化版)
void save_frame_as_bmp(AVFrame *frame, int width, int height, int index) {
    // 分配RGB缓冲区
    int rgb_size = av_image_get_buffer_size(AV_PIX_FMT_RGB24, 
                                            width, height, 1);
    uint8_t *rgb_buffer = (uint8_t *)av_malloc(rgb_size);
    
    // 创建SwsContext(实际项目中应该复用)
    struct SwsContext *sws_ctx = sws_getContext(
        width, height, frame->format,
        width, height, AV_PIX_FMT_RGB24,
        SWS_BILINEAR, NULL, NULL, NULL);

    // 转换
    uint8_t *dst_data[4] = {rgb_buffer, NULL, NULL, NULL};
    int dst_linesize[4] = {width * 3, 0, 0, 0};
    sws_scale(sws_ctx, frame->data, frame->linesize,
              0, height, dst_data, dst_linesize);

    // 写BMP文件(略)
    char filename[64];
    snprintf(filename, sizeof(filename), "frame_%04d.bmp", index);
    // ... 写入文件逻辑

    av_free(rgb_buffer);
    sws_freeContext(sws_ctx);
}

经验之谈:保存音频帧时,记得考虑字节序和位深度。比如解码出的AV_SAMPLE_FMT_FLTP是32位浮点,如果你想保存为16位整数,需要做格式转换。av_samples_alloc_array_and_samples 这个函数可以帮你分配转换后的缓冲区。

5.5 帧提取工具完整实现——把上面串起来

好了,现在我们把上面这些零散的知识点串起来,做一个完整的帧提取工具。这个工具可以指定提取视频的第几帧,或者每隔几帧提取一张图片。

int main(int argc, char *argv[]) {
    // 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);
    int audio_stream_idx = av_find_best_stream(fmt_ctx, AVMEDIA_TYPE_AUDIO,
                                                -1, -1, NULL, 0);

    // 3. 初始化解码器
    AVCodecContext *video_ctx = init_decoder(fmt_ctx->streams[video_stream_idx]);
    AVCodecContext *audio_ctx = init_decoder(fmt_ctx->streams[audio_stream_idx]);

    // 4. 分配帧和packet
    AVFrame *frame = av_frame_alloc();
    AVPacket *pkt = av_packet_alloc();

    int frame_count = 0;
    // 5. 循环读取并解码
    while (av_read_frame(fmt_ctx, pkt) >= 0) {
        if (pkt->stream_index == video_stream_idx) {
            int ret = decode_video_frame(video_ctx, pkt, frame);
            if (ret == 1) {
                // 提取关键帧(I帧)
                if (frame->pict_type == AV_PICTURE_TYPE_I) {
                    save_frame_as_bmp(frame, video_ctx->width,
                                      video_ctx->height, frame_count++);
                }
            }
        } else if (pkt->stream_index == audio_stream_idx) {
            int ret = decode_audio_frame(audio_ctx, pkt, frame);
            if (ret == 1) {
                // 保存音频帧到PCM文件
                save_audio_frame_to_pcm(frame, audio_ctx);
            }
        }
        av_packet_unref(pkt);
    }

    // 6. 清理
    av_frame_free(&frame);
    av_packet_free(&pkt);
    avcodec_free_context(&video_ctx);
    avcodec_free_context(&audio_ctx);
    avformat_close_input(&fmt_ctx);

    return 0;
}

核心逻辑流程图:

帧提取工具核心流程 打开输入文件 查找视频/音频流 初始化解码器(视频+音频) 循环读取 & 解码帧 保存帧(BMP/PCM) 注:视频帧保存为BMP,音频帧保存为PCM

5.6 避坑指南——我踩过的那些坑

做解码开发,有些坑是绕不过去的。我把自己踩过的几个典型坑列出来,你遇到了可以直接对照。

问题 现象 解决方案
解码返回EAGAIN avcodec_receive_frame 返回 -11 继续发送packet,直到解出帧
音频时长不对 播放时长比源文件短 检查解码后的采样格式,可能需要重采样
内存泄漏 处理大量帧后内存暴涨 每次循环后调用 av_frame_unref 和 av_packet_unref
花屏/绿屏 解码出的图像颜色不对 检查sws_scale的像素格式参数是否正确

曾经我犯过一个低级错误:在循环里每次都用 av_frame_alloc 分配新帧,但忘了释放。处理一个10分钟的视频,内存直接飙到2GB。后来养成了习惯:帧和packet都只分配一次,循环里用 av_frame_unref 清空内容复用

嗯,解码这块儿的内容差不多就这些了。你把这个帧提取工具跑通了,基本上FFmpeg解码的套路就摸清楚了。记住核心就是三步:初始化解码器 → send_packet → receive_frame,剩下的就是根据业务需求处理帧数据。

公众号:蓝海资料掘金营,微信deep3321