FFmpeg muxing示例程序解析

作者: 魏兆华 | 来源:发表于2018-11-23 14:59 被阅读68次

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

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

    最后生成的视频效果

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


    码农也精彩!

    相关文章

      网友评论

        本文标题:FFmpeg muxing示例程序解析

        本文链接:https://www.haomeiwen.com/subject/ugsdqqtx.html