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); //释放格式配置上下文
解析结束。希望对需要的人有所帮助。
作者:魏兆华
链接:https://www.jianshu.com/p/04c163718362
共同学习,写下你的评论
评论加载中...
作者其他优质文章