FFmpeg可能是当今视/音频领域应用最为广泛的开源项目了,国内许多著名的影音程序或多或少地都用了它的代码。作为视/音频领域研究或开发的人,无论如何都不应该错过这个项目。本文就拿FFmpeg示例程序中的muxing.c文件,来对FFmpeg的使用作一篇简要介绍。胖兔的老习惯,仍然是直接从代码开撸。先看文件中定义的OutputSteam数据结构:
typedef struct OutputStream {
AVStream *st; //视频或音频流
AVCodecContext *enc; //编码配置
int64_t next_pts; //下一帧的PTS,用于视/音频同步
int samples_count; //声音采样计数
AVFrame *frame; //视频/音频帧
AVFrame *tmp_frame; //临时帧
float t, tincr, tincr2; //用于声音生成
struct SwsContext *sws_ctx; //视频转换配置
struct SwrContext *swr_ctx; //声音重采样配置
} OutputStream;
对视/音频文件的操作,实际上都是针对视频/音频流来进行的。这个OutputStream类就是用于操作视频/音频流的包装类。
接下来从主函数开始,按顺序梳理整个代码流程:
if (argc < 2) {
printf("usage: %s output_file\n"
"API example program to output a media file with libavformat.\n"
"This program generates a synthetic audio and video stream, encodes and\n"
"muxes them into a file named output_file.\n"
"The output format is automatically guessed according to the file extension.\n"
"Raw images can also be output by using '%%d' in the filename.\n"
"\n", argv[0]);
return 1;
}
这里介绍了示例程序的功能和使用方法。运行本程序的时候要带一个输出文件名参数,然后程序将生成一个同步的视频和音频流,编码复用到指定的文件中去。输出的格式是根据给定的文件扩展名自动猜取的。
filename = argv[1];
for (i = 2; i+1 < argc; i+=2) {
if (!strcmp(argv[i], "-flags") || !strcmp(argv[i], "-fflags"))
av_dict_set(&opt, argv[i]+1, argv[i+1], 0);
}
这里检查程序启动有没有带其他参数,有的话纳入到参数字典。对于不甚精通音/视频技术的初学者来说,这里直接忽略就好了。
avformat_alloc_output_context2(&oc, NULL, NULL, filename);
if (!oc) {
printf("Could not deduce output format from file extension: using MPEG.\n");
avformat_alloc_output_context2(&oc, NULL, "mpeg", filename);
}
fmt = oc->oformat;
这里初始化了AVFormatContext(格式配置),它在FFmpeg程序里是贯穿始终的一个类,非常重要。注意avformat_alloc_output_context2这个函数,它的第二个参数可以是一个AVFormat实例,用来决定视频/音频格式,如果被设为NULL就继续看第三个参数,这是一个描述格式的字符串,比如可以是“h264"、 "mpeg"等;如果它也是NULL,就看最后第四个filename,从它的扩展名来推断应该使用的格式。比如用户指定的文件名是”test.avi",就会使用普通的AVI格式。有人说那我用h264格式但文件名就想用.avi行不行,当然可以,把第三个参数设为"h264"就行了,这时就不会从文件名来推断格式了。
if (fmt->video_codec != AV_CODEC_ID_NONE) {
add_stream(&video_st, oc, &video_codec, fmt->video_codec);
have_video = 1;
encode_video = 1;
}
if (fmt->audio_codec != AV_CODEC_ID_NONE) {
add_stream(&audio_st, oc, &audio_codec, fmt->audio_codec);
have_audio = 1;
encode_audio = 1;
}
接下来根据推断出的格式添加视频/音频流。如果给定的是"mp4"这样的格式,默认是既有视频也有音频;如果给定的是"mp3",那就只有音频没有视频了。我们暂停一下main函数,去看看add_stream函数是如何定义的,注意笔者添加的中文注释(代码有删节,便于突出主要流程。本文后续其他代码同样处理):
void add_stream(OutputStream *ost, AVFormatContext *oc,
AVCodec **codec, enum AVCodecID codec_id)
{
//根据推断出的格式,寻找相应的AVCodec编码
*codec = avcodec_find_encoder(codec_id);
//分配一个视频/音频流,这里的ost就是本文一开头分析的OutputStream结构数据
ost->st = avformat_new_stream(oc, NULL);
//设定流ID号,与流在文件中的序号对应(一个文件中可以有多个视频/音频流)
ost->st->id = oc->nb_streams-1;
//分配CodecContext编码上下文,存入OutputStream结构
AVCodecContext *c = avcodec_alloc_context3(*codec);
ost->enc = c;
//根据视频、音频不同类型,初始化CodecContext编码配置
switch ((*codec)->type) {
case AVMEDIA_TYPE_AUDIO: //这部分是音频数据
c->sample_fmt = (*codec)->sample_fmts ? (*codec)->sample_fmts[0] : AV_SAMPLE_FMT_FLTP; //采样格式
c->bit_rate = 64000; //码率
c->sample_rate = 44100; //采样速率
c->channels = av_get_channel_layout_nb_channels(c->channel_layout); //声道数
c->channel_layout = AV_CH_LAYOUT_STEREO; //声道布局
c->channels = av_get_channel_layout_nb_channels(c->channel_layout);
ost->st->time_base = (AVRational){ 1, c->sample_rate }; //计时基准
break;
case AVMEDIA_TYPE_VIDEO: //这部分是视频数据
c->codec_id = codec_id; //视频编码
c->bit_rate = 400000; //码率
c->width = 352; //视频宽高,注意必须是双数,YUV420P格式要求
c->height = 288;
ost->st->time_base = (AVRational){ 1, STREAM_FRAME_RATE }; //计时基准
c->time_base = ost->st->time_base;
c->gop_size = 12;
c->pix_fmt = STREAM_PIX_FMT;
break;
}
//是否需要分离的Stream Header
if (oc->oformat->flags & AVFMT_GLOBALHEADER)
c->flags |= AV_CODEC_FLAG_GLOBAL_HEADER;
}
敲黑板!这里有重点!我们知道做视/音频程序,必须要考虑视频和音频数据的同步问题,技术上怎么实现?请看上面代码中的time_base,用来设定计时基准,它来源于图像/声音采集原理。对于视频,我们知道人眼视觉残留的时间是1/24秒,视频只要达到每秒24帧以上人就不会觉得有闪烁或卡顿,一般会设成25,也就是代码中的STREAM_FRAME_RATE常数,视频time_base设为1/25,也就是每一个视频帧停留1/25秒。再看音频,声音的采样是指一秒内采集多少次声音数据,采样频率越高声音质量越好,44.1kHz就可以达到CD音响质量,也是MPEG标准声音质量。那么它的基准就是1/44100。
继续接着看main函数:
if (have_video)
open_video(oc, video_codec, &video_st, opt);
if (have_audio)
open_audio(oc, audio_codec, &audio_st, opt);
所有参数都设好了,可以打开视频/音频编码,分配必要的缓冲区了。先看open_video函数:
void open_video(AVFormatContext *oc, AVCodec *codec, OutputStream *ost, AVDictionary *opt_arg)
{
AVCodecContext *c = ost->enc;
AVDictionary *opt = NULL;
//拷贝用户设定的参数字典
av_dict_copy(&opt, opt_arg, 0);
//打开编码器,随后释放参数字典
avcodec_open2(c, codec, &opt);
av_dict_free(&opt);
//分配并初始化一个可重复使用的视频帧,指定好像素点格式和宽高
ost->frame = alloc_picture(c->pix_fmt, c->width, c->height);
//如果输出格式不是YUV420P,那么需要一个临时的YUV420P帧便于进行转换
ost->tmp_frame = NULL;
if (c->pix_fmt != AV_PIX_FMT_YUV420P) {
ost->tmp_frame = alloc_picture(AV_PIX_FMT_YUV420P, c->width, c->height);
}
//从CodecContext中拷贝参数到流/复用器
avcodec_parameters_from_context(ost->st->codecpar, c);
}
上面的代码使用了alloc_picture函数来分配视频帧。这个函数是这样定义的:
AVFrame *alloc_picture(enum AVPixelFormat pix_fmt, int width, int height)
{
AVFrame *picture = av_frame_alloc();
picture->format = pix_fmt;
picture->width = width;
picture->height = height;
//分配帧数据缓冲区
av_frame_get_buffer(picture, 32);
return picture;
}
注意av_frame_get_buffer函数,它为帧数据分配缓冲区,第二个参数32用于对齐,如果搞不清楚怎么设的话,直接设为0就行,FFmpeg会自动处理。
视频打开了。接着看打开音频的open_audio函数:
void open_audio(AVFormatContext *oc, AVCodec *codec, OutputStream *ost, AVDictionary *opt_arg)
{
int nb_samples;
AVDictionary *opt = NULL;
AVCodecContext *c = ost->enc;
//拷贝参数字典
av_dict_copy(&opt, opt_arg, 0);
//打开编码器,释放参数字典
avcodec_open2(c, codec, &opt);
av_dict_free(&opt);
//初始化信号生成器,用于声音自动生成
ost->t = 0;
ost->tincr = 2 * M_PI * 110.0 / c->sample_rate;
ost->tincr2 = 2 * M_PI * 110.0 / c->sample_rate / c->sample_rate;
//采样大小。如果帧大小固定,则为frame_size
if (c->codec->capabilities & AV_CODEC_CAP_VARIABLE_FRAME_SIZE)
nb_samples = 10000;
else
nb_samples = c->frame_size;
//分配音频帧和临时音频帧
ost->frame = alloc_audio_frame(c->sample_fmt, c->channel_layout, c->sample_rate, nb_samples);
ost->tmp_frame = alloc_audio_frame(AV_SAMPLE_FMT_S16, c->channel_layout, c->sample_rate, nb_samples);
//从CodecContext中拷贝参数到流/复用器
avcodec_parameters_from_context(ost->st->codecpar, c);
//创建重采样配置,设定声道数、输入输出采样率、采样格式等选项
ost->swr_ctx = swr_alloc();
av_opt_set_int(ost->swr_ctx, "in_channel_count", c->channels, 0);
av_opt_set_int(ost->swr_ctx, "in_sample_rate", c->sample_rate, 0);
av_opt_set_sample_fmt(ost->swr_ctx, "in_sample_fmt", AV_SAMPLE_FMT_S16, 0);
av_opt_set_int(ost->swr_ctx, "out_channel_count", c->channels, 0);
av_opt_set_int(ost->swr_ctx, "out_sample_rate", c->sample_rate, 0);
av_opt_set_sample_fmt(ost->swr_ctx, "out_sample_fmt", c->sample_fmt, 0);
swr_init(ost->swr_ctx);
}
分配音频帧使用了alloc_audio_frame函数:
AVFrame *alloc_audio_frame(enum AVSampleFormat sample_fmt,
uint64_t channel_layout, int sample_rate, int nb_samples)
{
AVFrame *frame = av_frame_alloc();
frame->format = sample_fmt; //采样格式
frame->channel_layout = channel_layout; //声道布局
frame->sample_rate = sample_rate; //采样率
frame->nb_samples = nb_samples; //采样大小
if (nb_samples) {
av_frame_get_buffer(frame, 0);
}
return frame;
}
现在视频/音频都设定好了,回到main函数,接下来看看格式设定是否正确:
av_dump_format(oc, 0, filename, 1);
这一行在命令行下导出当前格式设定,执行以后输出示例是这样的:
Dump Format输出示例
可以看到,我们设定的输出文件名是test2.mp4,文件中包含两个流,一个视频流,H264格式,帧格式YUV420P,帧大小352*288;另一个音频流,AAC格式,采样率44.1kHz,立体声。
继续往下看:
//打开输出文件
avio_open(&oc->pb, filename, AVIO_FLAG_WRITE);
//输出流的头部
avformat_write_header(oc, &opt);
OK,现在万事俱备,只欠写入了。接着看视频和音频数据是如何写入的:
while (encode_video || encode_audio) {
if (encode_video && (!encode_audio ||
av_compare_ts(video_st.next_pts, video_st.enc->time_base,
audio_st.next_pts, audio_st.enc->time_base) <= 0)) {
encode_video = !write_video_frame(oc, &video_st);
} else {
encode_audio = !write_audio_frame(oc, &audio_st);
}
}
这里值得注意的还是视/音频同步问题。写入文件的时候,什么时候写视频帧,什么时候写音频帧?代码给出了一个办法,在有视频无音频,或者视频时间戳落后于音频的时候就写视频帧,否则就写入音频帧。av_compare_ts函数用来进行时间戳(Timestamp)比较。
接着看视频帧是怎么写入的:
int write_video_frame(AVFormatContext *oc, OutputStream *ost)
{
AVCodecContext *c = ost->enc;
//生成视频帧
AVFrame *frame = get_video_frame(ost);
int got_packet = 0;
AVPacket pkt = { 0 };
//初始化数据包
av_init_packet(&pkt);
//将视频帧编码压入数据包
avcodec_encode_video2(c, &pkt, frame, &got_packet);
if (got_packet) {
//如果有数据包生成,则写入流
ret = write_frame(oc, &c->time_base, ost->st, &pkt);
} else {
ret = 0;
}
return (frame || got_packet) ? 0 : 1;
}
流程比较一目了然。先看get_video_frame函数是如何生成视频帧的:
AVFrame *get_video_frame(OutputStream *ost)
{
AVCodecContext *c = ost->enc;
//检查是否继续生成视频帧。如果超过预定时长就停止生成
if (av_compare_ts(ost->next_pts, c->time_base,
STREAM_DURATION, (AVRational){ 1, 1 }) >= 0)
return NULL;
//使帧数据可写,此处视频数据是代码生成的,注意frame指针本身不可以修改
//因为FFmpeg内部会引用这个指针,一旦改了可能会破坏视频
av_frame_make_writable(ost->frame);
//如果目标格式不是YUV420P,那么必须要进行格式转换
if (c->pix_fmt != AV_PIX_FMT_YUV420P) {
if (!ost->sws_ctx) { //先获取转换环境
ost->sws_ctx = sws_getContext(c->width, c->height, AV_PIX_FMT_YUV420P,
c->width, c->height, c->pix_fmt, SCALE_FLAGS, NULL, NULL, NULL);
}
//向临时帧填充数据,之后转换填入当前帧
fill_yuv_image(ost->tmp_frame, ost->next_pts, c->width, c->height);
sws_scale(ost->sws_ctx, (const uint8_t * const *) ost->tmp_frame->data,
ost->tmp_frame->linesize, 0, c->height, ost->frame->data, ost->frame->linesize);
} else {
//目标格式就是YUV420P,直接转换就可以
fill_yuv_image(ost->frame, ost->next_pts, c->width, c->height);
}
//新帧生成,PTS递增
ost->frame->pts = ost->next_pts++;
return ost->frame;
}
测试程序的视频帧是由代码生成的,具体在fill_yuv_image函数里:
void fill_yuv_image(AVFrame *pict, int frame_index, int width, int height)
{
int x, y, i;
i = frame_index;
//生成Y
for (y = 0; y < height; y++)
for (x = 0; x < width; x++)
pict->data[0][y * pict->linesize[0] + x] = x + y + i * 3;
//生成Cb和Cr
for (y = 0; y < height / 2; y++) {
for (x = 0; x < width / 2; x++) {
pict->data[1][y * pict->linesize[1] + x] = 128 + y + i * 2;
pict->data[2][y * pict->linesize[2] + x] = 64 + x + i * 5;
}
}
}
这里用代码,按照一定规律填写YUV420P格式的数据,注意下面的循环,可以明白为什么视频的宽和高必须是2的倍数了吧。
回到视频帧写入函数write_video_frame,它接下来调用了avcodec_encode_video2函数,将帧数据编码压入数据包。然而,这个函数在最新版FFmpeg里已经被废弃了,新版本采用了更加灵活的编码方式。胖兔采用的是以下修改后的代码:
ret = avcodec_send_frame(c, frame); //将帧送入编码配置上下文
while (ret >= 0) {
ret = avcodec_receive_packet(c, &pkt); //循环接收数据包,直到所有包接收完成
if (ret == AVERROR(EAGAIN) || ret == AVERROR_EOF)
break;
write_frame(oc, &c->time_base, ost->st, &pkt);
}
对比一下新老代码,可以看到老代码是比较死的,送一帧进去,只能接收一个包出来;新代码则允许送一帧进去,接收N个包出来。这样能够有效避免因为帧编码压缩延迟导致数据包滞留的问题。
收到数据包之后,要把它写入视频流,使用的write_frame函数:
int write_frame(AVFormatContext *fmt_ctx, const AVRational *time_base, AVStream *st, AVPacket *pkt)
{
//转换时间戳,由数据包向视频流
av_packet_rescale_ts(pkt, *time_base, st->time_base);
pkt->stream_index = st->index; //指明包属于哪个流
return av_interleaved_write_frame(fmt_ctx, pkt); //将包写入流
}
OK,到这里视频写入就结束了。音频的采集与编码过程与之类似。这里不再详细解析了,具体可以参见示例程序代码。
回到main函数,完成视频/音频写入以后,最后还需要做的就是收尾工作:
av_write_trailer(oc); //写尾部
if (have_video)
close_stream(oc, &video_st); //关闭视频流
if (have_audio)
close_stream(oc, &audio_st); //关闭音频流
avio_closep(&oc->pb); //关闭输出文件
avformat_free_context(oc); //释放格式配置上下文
解析结束。希望对需要的人有所帮助。
最后生成的视频效果最后贴一张程序生成的视频动图(压缩后效果一般,勉强镇楼,凑合看看效果吧)。
码农也精彩!
网友评论