5、FFmpeg解码实战:初始化解码器、解码视频帧与音频帧,处理解码后的数据,实现一个帧提取工具。
说实话,解码这块儿是音视频开发里最基础也最核心的活儿。你想想看,不管你是做播放器、转码工具还是分析工具,第一步都得先把压缩的数据给解出来。今天我就带你手撸一个帧提取工具,把视频里的关键帧、音频采样点一个个拽出来。
5.1 解码器初始化——别急着干活,先搭好架子
解码器初始化这事儿,说白了就是告诉FFmpeg:「嘿,我要用你这个解码器了,帮我准备好工作台。」我刚开始学的时候,总以为直接调个函数就完事了,结果踩了不少坑。
核心流程:
- 查找解码器(根据编码ID或名字)
- 分配解码器上下文
- 将流中的参数复制到上下文
- 打开解码器
来看一段实际代码。我个人习惯把初始化解码器封装成一个函数,这样复用起来方便。
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;
}
核心逻辑流程图:
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,剩下的就是根据业务需求处理帧数据。