6、FFmpeg编码与封装实战:创建输出上下文、初始化编码器、编码音视频帧、写入文件,实现一个简单的转码器。
好,咱们今天来点真家伙。前面几章我们一直在聊解封装、解码,说白了都是在「拆」。这一章我们反过来,聊聊怎么「装」回去——也就是编码和封装。
你可能会想:「解码我都会了,编码不就是反过来吗?」嗯,道理是这么个道理,但实际写起来坑不少。我当年第一次写转码器的时候,以为把解码流程镜像一下就行,结果输出文件要么打不开,要么花屏。后来才发现,编码器的初始化参数、时间基的转换、帧数据的对齐,哪个环节出问题都不行。
今天我们就手撸一个简单的转码器。它能把一个视频文件读进来,重新编码,再写成一个新文件。虽然功能简单,但编码和封装的完整流程都会走一遍。
整体流程:先看骨架,再填血肉
写代码之前,我们先理清思路。一个转码器的核心流程其实就这几步:
- 创建输出上下文——告诉FFmpeg我们要输出什么格式的文件
- 初始化编码器——为每个流(视频、音频)创建并配置编码器
- 编码音视频帧——从解码器拿到原始帧,喂给编码器
- 写入文件——把编码后的包写入输出文件
我习惯把这一步叫做「编码管线」。你想想看,解码是拆包裹,编码就是重新打包。中间那个原始数据(YUV、PCM)就是我们要处理的「货」。
核心要点:转码器不是简单地把解码器输出直接塞给编码器。时间基、像素格式、采样率这些参数,往往需要做转换。跳过这一步,你写出来的文件大概率是坏的。
下面这张图能帮你快速建立整体认知:
第一步:创建输出上下文
输出上下文,说白了就是告诉FFmpeg:「我要写一个MP4文件,或者FLV,或者TS。」FFmpeg会根据文件后缀名自动猜测格式。当然你也可以手动指定。
代码很简单,就一行:
AVFormatContext *out_ctx = NULL;
avformat_alloc_output_context2(&out_ctx, NULL, NULL, "output.mp4");
if (!out_ctx) {
// 处理错误
}
第三个参数传NULL,FFmpeg会根据文件名后缀自动选择格式。如果你想强制指定,比如输出FLV但文件名是.mp4,可以传 "flv"。
小技巧:我个人习惯先调用av_guess_format()看看FFmpeg认不认这个后缀,再创建上下文。曾经有个项目,客户给的文件后缀是.dat,FFmpeg根本不认识,折腾了半天才发现是格式猜测失败。
第二步:初始化编码器
这一步是重头戏。编码器的初始化参数非常多,但核心的就几个:
- codec_id:用什么编码器,比如AV_CODEC_ID_H264、AV_CODEC_ID_AAC
- bit_rate:码率,控制文件大小和画质
- width/height:视频分辨率
- time_base:时间基,编码器内部的时间单位
- pix_fmt:像素格式,比如AV_PIX_FMT_YUV420P
来看一段视频编码器初始化的代码:
// 查找编码器
const AVCodec *codec = avcodec_find_encoder(AV_CODEC_ID_H264);
if (!codec) {
fprintf(stderr, "H.264编码器未找到\n");
return -1;
}
// 创建编码器上下文
AVCodecContext *enc_ctx = avcodec_alloc_context3(codec);
enc_ctx->bit_rate = 2000000; // 2 Mbps
enc_ctx->width = 1920;
enc_ctx->height = 1080;
enc_ctx->time_base = (AVRational){1, 25}; // 25fps
enc_ctx->framerate = (AVRational){25, 1};
enc_ctx->gop_size = 10; // 每10帧一个关键帧
enc_ctx->max_b_frames = 1;
enc_ctx->pix_fmt = AV_PIX_FMT_YUV420P;
// 打开编码器
if (avcodec_open2(enc_ctx, codec, NULL) < 0) {
fprintf(stderr, "编码器打开失败\n");
return -1;
}
嗯,这里要注意。编码器的pix_fmt不一定支持所有格式。比如有些硬件编码器只支持NV12,不支持YUV420P。我建议先调用avcodec_get_supported_config()查一下编码器支持哪些像素格式,再选一个。
避坑指南:我曾经在某个嵌入式平台上,用软件编码器x264,结果avcodec_open2一直报错。查了半天发现是pix_fmt设成了AV_PIX_FMT_YUV422P,但x264默认不支持这个格式。后来改成AV_PIX_FMT_YUV420P就正常了。所以初始化之前,最好先确认编码器支持的格式列表。
第三步:编码音视频帧
编码器的API在FFmpeg 3.x之后改成了「发送-接收」模型。说白了就是:你把原始帧发进去,它给你编码后的包。一次send_frame可能对应多次receive_packet,也可能一次都没有(比如编码器内部还在缓冲)。
标准写法是这样的:
// 发送原始帧给编码器
int ret = avcodec_send_frame(enc_ctx, frame);
if (ret < 0) {
// 处理错误
}
// 循环接收编码后的包
AVPacket *pkt = av_packet_alloc();
while (ret >= 0) {
ret = avcodec_receive_packet(enc_ctx, pkt);
if (ret == AVERROR(EAGAIN) || ret == AVERROR_EOF) {
break;
} else if (ret < 0) {
// 处理错误
break;
}
// 这里pkt就是编码后的数据,准备写入文件
// 注意:需要调整pkt的pts/dts到输出流的时间基
av_packet_rescale_ts(pkt, enc_ctx->time_base, out_stream->time_base);
pkt->stream_index = out_stream->index;
av_interleaved_write_frame(out_ctx, pkt);
}
av_packet_free(&pkt);
你可能会问:「为什么avcodec_receive_packet要放在循环里?」因为编码器可能一次send_frame产生多个packet,比如B帧场景。如果你只调用一次receive_packet,后面的包就丢了。
另外,编码完成后一定要flush。怎么flush?调用avcodec_send_frame传入NULL:
avcodec_send_frame(enc_ctx, NULL);
while (avcodec_receive_packet(enc_ctx, pkt) >= 0) {
// 处理最后的缓冲帧
}
不flush的话,编码器内部缓冲的帧就写不出来了。我见过有人转码后视频时长少了几帧,就是忘了这一步。
第四步:写入文件
写入文件相对简单。但有一个细节:先写文件头,再写数据,最后写文件尾。
// 打开输出文件
if (!(out_ctx->oformat->flags & AVFMT_NOFILE)) {
int ret = avio_open(&out_ctx->pb, "output.mp4", AVIO_FLAG_WRITE);
if (ret < 0) {
fprintf(stderr, "无法打开输出文件\n");
return -1;
}
}
// 写文件头
avformat_write_header(out_ctx, NULL);
// ... 循环编码和写入帧 ...
// 写文件尾
av_write_trailer(out_ctx);
avformat_write_header会写入moov box等元数据。av_write_trailer会刷新所有缓冲并写入索引信息。这两个函数必须成对调用,否则文件不完整。
个人经验:avformat_write_header的第二个参数可以传AVDictionary,用来设置编码器参数。比如你想设置H.264的preset和profile:
AVDictionary *opts = NULL;
av_dict_set(&opts, "preset", "medium", 0);
av_dict_set(&opts, "profile", "high", 0);
avformat_write_header(out_ctx, &opts);
av_dict_free(&opts);
这样就不用去折腾编码器私有选项了,方便很多。
完整转码器骨架
把上面几块拼起来,就是一个完整的转码器。核心循环大概长这样:
while (av_read_frame(in_ctx, pkt) >= 0) {
// 解码
ret = avcodec_send_packet(dec_ctx, pkt);
if (ret < 0) continue;
while (avcodec_receive_frame(dec_ctx, frame) >= 0) {
// 帧格式转换(如果需要)
// 编码
ret = avcodec_send_frame(enc_ctx, frame);
if (ret < 0) continue;
while (avcodec_receive_packet(enc_ctx, out_pkt) >= 0) {
// 时间基转换
av_packet_rescale_ts(out_pkt, enc_ctx->time_base, out_stream->time_base);
out_pkt->stream_index = out_stream->index;
// 写入
av_interleaved_write_frame(out_ctx, out_pkt);
}
}
av_packet_unref(pkt);
}
// flush编码器
avcodec_send_frame(enc_ctx, NULL);
while (avcodec_receive_packet(enc_ctx, out_pkt) >= 0) {
av_packet_rescale_ts(out_pkt, enc_ctx->time_base, out_stream->time_base);
out_pkt->stream_index = out_stream->index;
av_interleaved_write_frame(out_ctx, out_pkt);
}
这段代码看起来嵌套了三层循环,但逻辑很清楚:读包→解码→编码→写入。每一层都有错误处理,但为了简洁我省略了。实际项目中,每个函数调用都要检查返回值。
常见问题与排查
| 问题 | 可能原因 | 解决方法 |
|---|---|---|
| avcodec_open2失败 | 编码器不支持设置的参数 | 检查pix_fmt、bit_rate、profile等是否在编码器支持范围内 |
| 输出文件打不开 | 文件头未写入或写入失败 | 检查avformat_write_header返回值,确认avio_open成功 |
| 视频时长不对 | 忘记flush编码器 | 编码结束后调用avcodec_send_frame(NULL)并接收剩余包 |
| 音视频不同步 | pts/dts时间基未正确转换 | 使用av_packet_rescale_ts统一转换到输出流的时间基 |
嗯,写到这里,我觉得有必要强调一点:编码器不是解码器的镜像。解码器你给它什么格式的包,它都能尽量解出来。但编码器很挑剔——参数不对直接罢工。所以写编码逻辑时,多查文档,多打日志,把每一步的返回值都检查一遍。
好了,这一章的内容就这些。你把这个转码器骨架跑通之后,可以试着加一些功能,比如缩放分辨率、改变帧率、添加滤镜。这些我们后面会陆续讲到。
本章总结:
- 创建输出上下文:avformat_alloc_output_context2
- 初始化编码器:avcodec_find_encoder → avcodec_alloc_context3 → 设置参数 → avcodec_open2
- 编码帧:avcodec_send_frame + avcodec_receive_packet 循环
- 写入文件:avformat_write_header → av_interleaved_write_frame → av_write_trailer
- 别忘了flush编码器,否则丢帧
公众号:蓝海资料掘金营,微信deep3321