FFmpeg muxing示例程序解析

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); //释放格式配置上下文

解析结束。希望对需要的人有所帮助。

最后生成的视频效果

最后贴一张程序生成的视频动图(压缩后效果一般,勉强镇楼,凑合看看效果吧)。


码农也精彩!

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 206,311评论 6 481
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 88,339评论 2 382
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 152,671评论 0 342
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 55,252评论 1 279
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 64,253评论 5 371
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 49,031评论 1 285
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 38,340评论 3 399
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 36,973评论 0 259
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 43,466评论 1 300
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 35,937评论 2 323
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 38,039评论 1 333
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,701评论 4 323
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 39,254评论 3 307
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 30,259评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,485评论 1 262
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 45,497评论 2 354
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 42,786评论 2 345

推荐阅读更多精彩内容

  • 教程一:视频截图(Tutorial 01: Making Screencaps) 首先我们需要了解视频文件的一些基...
    90后的思维阅读 4,654评论 0 3
  • 1、通过CocoaPods安装项目名称项目信息 AFNetworking网络请求组件 FMDB本地数据库组件 SD...
    阳明先生_x阅读 15,968评论 3 119
  • 清晨,第一缕阳光拨开轻盈的雪纺窗帘,悄悄地、准时地闯进卧室,我却始终不愿睁开眼睛。一天的开始,却不是在你宠溺...
    毒女笑世阅读 208评论 0 1
  • 水戏河堤潺潺笑, 花落枝头窃窃语。 欲揽春色寄织女, 不见天宫往来人。 2018.3.11.晨
    开心点金石阅读 201评论 0 0
  • 【日精进打卡第32天】 宁波禾隆新材料有限公司 打卡人:李亮 【知~学习】 《六项精进》1遍 共66遍 《大学》1...
    禾隆李亮阅读 151评论 0 0