音频流与视频流的合成———FLV格式

使用FFmpeg 合成 H264 和 AAC 为 FLV 文件

1. 框架分析

整体流程概括:

  1. 初始化

    • 加载所有音视频编码器,并注册。
    • 初始化URL、输出格式、输出流等。
  2. 音视频流设置

    • 分别为视频和音频设置输出流。
    • 打开对应的编码器,并进行编码器参数的设置。
  3. 写入头部信息

    • 向输出文件写入格式头。
  4. 数据编码和写入

    • 不断生成音频和视频帧。
    • 将这些帧编码并写入输出文件。
  5. 结束处理

    • 向输出文件写入格式尾。
    • 清理并关闭所有的音视频流。

2. 实现流程及代码框架

erlang 复制代码
main(主函数)
init[av_register_all() + avformat_network_init() (初始化)]
outFormat[初始化输出格式和输出流]
addStream[add_stream() (为音视频添加输出流)]
openVideo[open_video()]
videoParams[... (编码器参数设置等)]
openAudio[open_audio()]
audioParams[... (编码器参数设置等)]
writeHeader[avformat_write_header() (写入格式头)]
mainLoop[主循环]
getFrame[get_video_frame() + get_audio_frame() (获取音视频帧)]
writeFrameFunc[write_video_frame() + write_audio_frame() (编码帧并写入)]
writeFrame[write_frame() (写入编码后的数据包)]
endProcess[结束处理]
writeTrailer[av_write_trailer() (写入格式尾)]
closeStream[close_stream() (关闭音视频流并清理)]
graph TD; main --> init main --> outFormat main --> addStream addStream --> openVideo openVideo --> videoParams addStream --> openAudio openAudio --> audioParams main --> writeHeader main --> mainLoop mainLoop --> getFrame mainLoop --> writeFrameFunc writeFrameFunc --> writeFrame main --> endProcess endProcess --> writeTrailer endProcess --> closeStream

主函数详细代码

封装的结构体单个输出AVStream 复制代码
typedef struct OutputStream
{
    AVStream *st;               // 代表一个stream, 1路audio或1路video都代表独立的steam
    AVCodecContext *enc;        // 编码器上下文

    int64_t next_pts;   //将生成的下一帧的pts
    int samples_count;  // 音频的采样数量累计

    AVFrame *frame;     // 重采样后的frame,  视频叫scale
    AVFrame *tmp_frame; // 重采样前

    float t, tincr, tincr2; // 这几个参数用来生成PCM和YUV

    struct SwsContext *sws_ctx;     // 图像scale
    struct SwrContext *swr_ctx;     // 音频重采样
} OutputStream;
main 复制代码
int main(int argc, char **argv)
{
    OutputStream video_st = {0}, audio_st = {0};  // 封音视频编码相关内容
    const char *filename;  // 输出文件
    AVOutputFormat *fmt;   // 输出文件容器格式, 封装了复用规则,AVInputFormat则是封装了解复用规则
    AVFormatContext *oc;   // 输出的上下文,它保存了要生成的多媒体文件的所有相关信息
    AVCodec *audio_codec, *video_codec;  // 存储音频和视频流的编解码器信息
    
    int i, ret;
    
    /*
    表示是否有视频流或音频流,当为输出流添加视频或音频流时,这些变量会被设置为1。
    它们可以用来检查是否需要编码音频或视频数据音频或视频数据
    */
    int have_video = 0, have_audio = 0;  
    
    /*
    表示是否需要编码视频或音频流。
    在主编码循环中,这两个标志用于判断是否还需要继续编码音频或视频帧
    */
    int encode_video = 0, encode_audio = 0;
    
    AVDictionary *opt = NULL;  // 用于存储编解码器或输出格式的特定选项。在这段代码中未直接使用。

    // 准备和初始化FFmpeg库 
    av_register_all();
    avformat_network_init();
    
    // 检查命令行参数,从中获得输出文件名
    if (argc < 2) {
        printf("usage: %s output_file\nAPI example program to output a media file with libavformat.\n", argv[0]);
        return 1;
    }
    filename = argv[1];

    /*
    为输出文件初始化一个AVFormatContext,并尝试从文件名推断输出格式。
    如果无法推断,则默认使用MPEG格式
    &oc 是一个AVFormatContext指针的指针,该函数会修改它使其指向一个新AVFormatContext对象。
    第二和第三个参数为 NULL 表示让FFmpeg自己从文件名推断输出格式。
    filename是输出文件的名称。
    */
    if ((ret = avformat_alloc_output_context2(&oc, NULL, NULL, filename)) < 0) {
        fprintf(stderr, "Could not deduce output format from file extension: using MPEG.\n");
        avformat_alloc_output_context2(&oc, NULL, "mpeg", filename);
    }
    if (!oc)
        return 1;

    /*
    从已分配的AVFormatContext中获取输出格式,并将其保存在`fmt`变量中。
    oformat是AVFormatContext结构体中的一个成员,指向描述输出格式的AVOutputFormat结构体。
    */
    fmt = oc->oformat;
    
    fmt->video_codec = AV_CODEC_ID_H264;    // 指定视频编码器H264
    fmt->audio_codec = AV_CODEC_ID_AAC;     // 指定音频编码器AAC

    /* 使用指定的音视频编码格式增加音频流和视频流,add_stream需要自定义*/
    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;
    }

    /* 
    所有参数都已设置,现在可以打开音频和视频编码器并分配必要的编码缓冲区
    open_video和open_audio需要自定义
    */
    if (have_video)
        open_video(oc, video_codec, &video_st, opt);

    if (have_audio)
        open_audio(oc, audio_codec, &audio_st, opt);

    // 输出媒体格式信息
    av_dump_format(oc, 0, filename, 1);
    
    // 检查当前输出格式是否需要一个物理文件。有些格式,如某些流格式,可能不需要实际的文件。
    // 如果需要文件,尝试打开一个名为filename的文件以进行写入。oc->pb是一个指向文件的指针。
    if (!(fmt->flags & AVFMT_NOFILE)) {
        ret = avio_open(&oc->pb, filename, AVIO_FLAG_WRITE);
        if (ret < 0) {
            fprintf(stderr, "Could not open '%s': %s\n", filename, av_err2str(ret));
            return 1;
        }
    }

    /* 调用 avformat_write_header 函数将流头信息写入输出文件 */
    ret = avformat_write_header(oc, &opt);
    if (ret < 0) {
        fprintf(stderr, "Error occurred when opening output file: %s\n", av_err2str(ret));
        return 1;
    }

    /*
    循环编码音频和视频帧,只要还有音频或视频帧需要编码,这个循环就会执行。
    这个循环的目的是确保音频和视频帧按照正确的时间顺序交错地被编码和写入,
    这对于大多数媒体格式来说都是必要的,以确保在播放时同步音频和视频。
    write_audio_frame和write_video_frame为自定义函数
    */
    while (encode_video || encode_audio) {
        /* 
        这行代码决定了接下来是编码音频帧还是视频帧
        av_compare_ts 函数比较两个时间戳。此处比较的是音频流和视频流的下一个时间戳。
        如果 encode_audio 为 true 且(encode_video 为 false 或者下一个视频帧的时间戳比音频帧更大)
        则 ret 的值为1,表示接下来应该编码音频帧。
        否则,ret 的值为0,表示接下来应该编码视频帧。
        */
        ret = encode_audio && (!encode_video || av_compare_ts(video_st.next_pts, video_st.enc->time_base, audio_st.next_pts, audio_st.enc->time_base) > 0) ? 1 : 0;
        
        /*
        调用 write_audio_frame 函数编码并写入音频帧。
        如果该函数返回 true ,则表示成功编码和写入了音频帧。由于逻辑非操作,encode_audio 将被设置为 false ,表示没有更多的音频帧需要编码。反之,如果返回`false`,则`encode_audio`将被设置为`true`。
        */
        if (ret)
            encode_audio = !write_audio_frame(oc, &audio_st);
        else
            encode_video = !write_video_frame(oc, &video_st);
    }

    // 调用 av_write_trailer 函数将流尾信息写入输出文件,这里必须写,不然播放不了!
    av_write_trailer(oc);

    // 关闭音频和视频流,释放相关的资源
    if (have_video)
        close_stream(oc, &video_st);
    if (have_audio)
        close_stream(oc, &audio_st);

    // 如果之前打开了输出文件,现在关闭它
    if (!(fmt->flags & AVFMT_NOFILE))
        avio_closep(&oc->pb);

    // 释放之前分配的输出上下文资源
    avformat_free_context(oc);

    return 0;
}

主函数中调用了add_streamopen_videoopen_audiowrite_audio_framewrite_video_frame五个函数,下面依次进行介绍。

add_stream用于在给定的输出上下文中添加一个新的音频或视频流,并配置其编解码器,但此时codec并未打开。

  • 参数:
  • OutputStream *ost:表示输出流的自定义结构体,用于存储与输出流相关的数据。
  • AVFormatContext *oc: 输出媒体文件的格式上下文。
  • AVCodec **codec: 一个指向音频或视频编解码器指针的指针。通过 AVCodec **codec 这种双重指针的形式,函数可以修改传入的编解码器指针的值,使其指向一个特定的编解码器。这样,调用函数后,原始的编解码器指针会更新为指向所选择的编解码器。
  • enum AVCodecID codec_id: 想要使用的编解码器的ID。
add_stream 复制代码
static void add_stream(OutputStream *ost, AVFormatContext *oc,
                       AVCodec **codec,
                       enum AVCodecID codec_id)
{
    AVCodecContext *codec_ctx;
    int i;

    // 找到与指定 codec_id 匹配的编解码器,并检查是否找到了
    *codec = avcodec_find_encoder(codec_id);    //通过codec_id找到编码器
    if (!(*codec))
    {
        fprintf(stderr, "Could not find encoder for '%s'\n",
                avcodec_get_name(codec_id));
        exit(1);
    }
    
    // 为输出格式上下文创建新的流。这里的新流与特定的编解码器没有关联
    ost->st = avformat_new_stream(oc, NULL);  // 创建一个流,并且从0开始设置其index
    if (!ost->st)
    {
        fprintf(stderr, "Could not allocate stream\n");
        exit(1);
    }

    /*
    这一行设置新创建流的ID。因为每次调用 avformat_new_stream 时,
    nb_streams(存储在`oc`中的流的数量)会增加,所以新流的ID应该是 nb_streams - 1。
    ID号从0开始算,所以应为现有流数-1。
    */
    ost->st->id = oc->nb_streams - 1;
    
    // 这行代码使用 avcodec_alloc_context3 函数为之前查找到的编解码器分配一个新的编解码器上下文。
       此上下文将用于之后的编解码操作
    codec_ctx = avcodec_alloc_context3(*codec);
    if (!codec_ctx)
    {
        fprintf(stderr, "Could not alloc an encoding context\n");
        exit(1);
    }
    
    //将新分配的编解码器上下文保存在 OutputStream 结构体的 enc 字段中,以供后续使用
    ost->enc = codec_ctx;
    
    // 根据编解码器的类型是音频还是视频,来初始化编码器参数
    switch ((*codec)->type)
    {
    case AVMEDIA_TYPE_AUDIO:
        // 为编解码器上下文设置编解码器id
        codec_ctx->codec_id = codec_id;
        // 设置音频的采样格式。如果编解码器支持多种采样格式则使用首选格式;否则使用默认的AV_SAMPLE_FMT_FLTP
        codec_ctx->sample_fmt  = (*codec)->sample_fmts ?    
                    (*codec)->sample_fmts[0] : AV_SAMPLE_FMT_FLTP;
        codec_ctx->bit_rate    = 64000;     // 设置音频的比特率为64000
        codec_ctx->sample_rate = 44100;     // 默认采样率设置为44100Hz
        
        /*
        检查编解码器是否有支持的采样率列表。
        如果有,则更新 sample_rate 为列表中的首个采样率。
        但如果44100Hz存在于这个列表中,则会选择44100Hz。
        */
        if ((*codec)->supported_samplerates)
        {
            codec_ctx->sample_rate = (*codec)->supported_samplerates[0];
            for (i = 0; (*codec)->supported_samplerates[i]; i++)
            {
                if ((*codec)->supported_samplerates[i] == 44100)
                    codec_ctx->sample_rate = 44100;
            }
        }
        
        // 设置默认的声道布局为立体声
        codec_ctx->channel_layout = AV_CH_LAYOUT_STEREO;
        // 根据声道布局获取声道数
        codec_ctx->channels = av_get_channel_layout_nb_channels(codec_ctx->channel_layout);
        // 如果编解码器有推荐的声道布局列表,选择第一个。但如果立体声存在于列表中,则选择立体声
        if ((*codec)->channel_layouts)
        {
            codec_ctx->channel_layout = (*codec)->channel_layouts[0];
            for (i = 0; (*codec)->channel_layouts[i]; i++) {
                if ((*codec)->channel_layouts[i] == AV_CH_LAYOUT_STEREO)
                    codec_ctx->channel_layout = AV_CH_LAYOUT_STEREO;
            }
        }
        codec_ctx->channels        = av_get_channel_layout_nb_channels(codec_ctx->channel_layout);
        // 设置流的时间基为采样率的倒数,这表示音频帧的时间戳的单位
        ost->st->time_base = (AVRational){ 1, codec_ctx->sample_rate };
        break;

    /*
    当编解码器类型为视频时执行以下的初始化。
    这部分为视频编解码器上下文设置了基本的参数,
    如bit rate (比特率)、resolution (分辨率)、GOP size (图像组大小)、pixel format (像素格式)等。
    */
    case AVMEDIA_TYPE_VIDEO:
        codec_ctx->codec_id = codec_id; // 为编解码器上下文设置编解码器id
        codec_ctx->bit_rate = 400000;   //比特率
        
        codec_ctx->width    = 352;      // 分辨率(2的倍数)
        codec_ctx->height   = 288;
        codec_ctx->max_b_frames = 1;  // 指定在两个相邻的I帧或P帧之间可以有1个B帧
        
        ost->st->time_base = (AVRational){ 1, STREAM_FRAME_RATE };  //  流的时间基
        codec_ctx->time_base = ost->st->time_base;    //  编码器的时间基
        codec_ctx->gop_size = STREAM_FRAME_RATE; //  图像组大小
        codec_ctx->pix_fmt = STREAM_PIX_FMT;  //  像素格式
        break;

    default:
        break;
    }

    /* Some formats want stream headers to be separate. */
    if (oc->oformat->flags & AVFMT_GLOBALHEADER)
        codec_ctx->flags |= AV_CODEC_FLAG_GLOBAL_HEADER;    //
}

重点解释语句:codec_ctx->time_base = ost->st->time_base

在FFmpeg中,time_base 是一个非常重要的概念,它代表时间戳的单位。每个流和每个编解码器上下文都有其自己的 time_base

  1. ost->st->time_base :这是流的时间基准,它决定了流中的时间戳表示的时间单位。当将帧写入流时,这些帧的时间戳应该是基于这个 time_base 的。
  2. codec_ctx->time_base :这是编解码器上下文的时间基准。编解码器使用这个时间基准来决定如何处理和解释给定的时间戳。这影响了编解码过程和如何将时间戳与实际的媒体数据对齐。 将codec_ctx->time_base设置为ost->st->time_base确保编解码器上下文的时间基准与其对应的流是一致的。这样可以确保在编解码过程中时间戳的处理是正确和一致的。

open_video用于为一个视频输出流初始化并打开视频编码器,同时为视频编码准备所需的帧缓冲区。它确保编码器正确打开,并根据编码器的需求为其分配适当的帧缓冲区。

  • 参数:
  • AVFormatContext *oc:媒体容器的上下文。代表输出文件的格式上下文。通常包含多个流的信息(例如视频、音频等)以及其他与输出文件相关的数据。

  • AVCodec *codec:编码器,代表将用于编码视频流的特定编码器。

  • OutputStream *ost:自定义结构体,包含有关输出流的信息,如编码器上下文、帧等。

  • AVDictionary *opt_arg:字典结构体,包含编码器打开时的可选参数。这个程序没用。

open_video 复制代码
static void open_video(AVFormatContext *oc, AVCodec *codec, OutputStream *ost, AVDictionary *opt_arg)
{
    int ret;
    AVCodecContext *codec_ctx = ost->enc; // 从输出流中获取的编码器上下文
    AVDictionary *opt = NULL;

    av_dict_copy(&opt, opt_arg, 0);

    // 打开编码器
    // 1. 关联编码器
    ret = avcodec_open2(codec_ctx, codec, &opt);
    av_dict_free(&opt);
    if (ret < 0)
    {
        fprintf(stderr, "Could not open video codec: %s\n", av_err2str(ret));
        exit(1);
    }
    // 2. 分配帧buffer
    // 使用自定义的 alloc_picture 函数分配一个视频帧,并检查分配是否成功。
    ost->frame = alloc_picture(codec_ctx->pix_fmt, codec_ctx->width, codec_ctx->height);
    if (!ost->frame)
    {
        fprintf(stderr, "Could not allocate video frame\n");
        exit(1);
    }

    /* 
    如果编码器的像素格式不是 AV_PIX_FMT_YUV420P ,
    那么需要分配一个临时帧,用于颜色空间转换。
    */
    ost->tmp_frame = NULL;
    if (codec_ctx->pix_fmt != AV_PIX_FMT_YUV420P)
    {
        // 编码器格式需要的数据不是 AV_PIX_FMT_YUV420P才需要 调用图像scale
        ost->tmp_frame = alloc_picture(AV_PIX_FMT_YUV420P, codec_ctx->width, codec_ctx->height);
        if (!ost->tmp_frame)
        {
            fprintf(stderr, "Could not allocate temporary picture\n");
            exit(1);
        }
    }

    // 复制流参数到混流器
    // 使用 avcodec_parameters_from_context 将编码器上下文的参数复制到输出流的参数中
    ret = avcodec_parameters_from_context(ost->st->codecpar, codec_ctx);
    if (ret < 0)
    {
        fprintf(stderr, "Could not copy the stream parameters\n");
        exit(1);
    }
}

open_audio用于初始化音频流的编解码器,并为后续的音频处理任务如重采样和信号生成进行必要的配置。具体来说,该函数做了以下事情:

  1. 打开音频编解码器。
  2. 初始化信号生成器,这可能会用于生成音频信号。
  3. 根据编解码器的规格确定每帧的样本数量。
  4. 分配内存来存储音频帧的数据。
  5. 将编解码器的参数复制到输出流。
  6. 创建和配置一个重采样器,该重采样器用于在不同的音频格式之间转换。
  • 参数:
  • AVFormatContext *oc:这是输出的格式上下文,代表了整个输出文件或流的结构。
  • AVCodec *codec:要使用的音频编解码器。
  • OutputStream *ost:自定义结构体,包含有关音频流的信息,如编解码器上下文、帧和其他一些参数。
  • AVDictionary *opt_arg:一个字典,可能包含一些为编解码器设置的选项。这个程序没用。
open_audio 复制代码
static void open_audio(AVFormatContext *oc, AVCodec *codec, OutputStream *ost, AVDictionary *opt_arg)
{
    // 编解码器上下文
    AVCodecContext *codec_ctx;
    // 用于存储每帧的样本数量
    int nb_samples;
    int ret;
    AVDictionary *opt = NULL;

    // 使用传入的`OutputStream`结构体中的编码器上下文
    codec_ctx = ost->enc;

    // 从传入的 opt_arg 复制字典到局部的 opt
    av_dict_copy(&opt, opt_arg, 0);
    // 这里尝试打开音频编解码器。如果失败会打印错误并退出。会设置codec_ctx->time_base
    ret = avcodec_open2(codec_ctx, codec, &opt);
    // 调用 av_dict_free 函数释放之前可能已分配给 opt 字典的内存。
    av_dict_free(&opt);
    if (ret < 0)
    {
        fprintf(stderr, "Could not open audio codec: %s\n", av_err2str(ret));
        exit(1);
    }

    // 这部分代码初始化一个简单的信号生成器,产生一个简单的音频信号
    ost->t     = 0;
    // 这是信号的增量
    ost->tincr = 2 * M_PI * 110.0 / codec_ctx->sample_rate; 
    // 这是频率每秒增量
    ost->tincr2 = 2 * M_PI * 110.0 / codec_ctx->sample_rate / codec_ctx->sample_rate;

    // 设置每帧需要的样本数
    nb_samples = codec_ctx->frame_size;

    /*
    为编码器分配帧并申请相应的缓冲区,这个帧是用于保存编解码器即将编码或已经解码的音频数据的
    这里为信号生成和编码器创建两个帧,alloc_audio_frame是自定义函数。
    */
    ost->frame     = alloc_audio_frame(codec_ctx->sample_fmt, codec_ctx->channel_layout, 
                                       codec_ctx->sample_rate, nb_samples);
    // 为生成 PCM 信号分配一个临时帧。这个帧是用于保存原始的、尚未编码的音频数据的
    ost->tmp_frame = alloc_audio_frame(AV_SAMPLE_FMT_S16, codec_ctx->channel_layout,
                                       codec_ctx->sample_rate, nb_samples);

    // 将编码器上下文的参数复制到输出流,这确保了输出流的参数与编解码器使用的参数匹配
    ret = avcodec_parameters_from_context(ost->st->codecpar, codec_ctx);
    if (ret < 0)
    {
        fprintf(stderr, "Could not copy the stream parameters\n");
        exit(1);
    }

    // 创建一个新的重采样器上下文并进行一些设置
    ost->swr_ctx = swr_alloc();  // 为重采样操作分配一个新的上下文
    if (!ost->swr_ctx)
    {
        fprintf(stderr, "Could not allocate resampler context\n");
        exit(1);
    }

    // 为重采样器上下文设置各种参数
    // 输入音频的通道数
    av_opt_set_int       (ost->swr_ctx, "in_channel_count",   codec_ctx->channels,       0);
    // 输入音频的采样率
    av_opt_set_int       (ost->swr_ctx, "in_sample_rate",     codec_ctx->sample_rate,    0);
    // 输入音频的采样格式。这里设置为`AV_SAMPLE_FMT_S16`,即16位有符号整数
    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",  codec_ctx->channels,       0);
    // 输出音频的采样率,这里与输入相同
    av_opt_set_int       (ost->swr_ctx, "out_sample_rate",    codec_ctx->sample_rate,    0);
    // 输出音频的采样格式,取自编解码器上下文
    av_opt_set_sample_fmt(ost->swr_ctx, "out_sample_fmt",     codec_ctx->sample_fmt,     0);

    // 调用 swr_init 函数来初始化重采样上下文。
    // 这一步骤在设置了所有必要参数后执行,以确保重采样上下文准备好进行后续的重采样操作
    if ((ret = swr_init(ost->swr_ctx)) < 0)
    {
        fprintf(stderr, "Failed to initialize the resampling context\n");
        exit(1);
    }
}

open_audio函数的目的是准备音频流的编解码器并进行重采样,包括一个简单的信号生成。这是一种典型的为音频编码准备工作的方式,在许多FFmpeg的例子和应用中都可以看到这样的设置。

write_audio_frame的主要目标是从给定的音频输出流中捕获音频帧,处理和编码这些帧,然后将编码后的数据写入输出媒体容器中。具体来说,该函数做了以下事情:

  1. 从输出流中获取音频帧。
  2. 如果有必要,使用重采样器将音频帧从其原始格式转换为目标编码器需要的格式。
  3. 对已处理的音频帧进行编码。
  4. 如果编码成功,并且生成了编码后的数据包,则将此数据包写入输出容器。
write_audio_frame 复制代码
static int write_audio_frame(AVFormatContext *oc, OutputStream *ost)
{
    AVCodecContext *codec_ctx;     // 用于音频流编码的上下文
    AVPacket pkt = { 0 };          // 初始化一个`AVPacket`结构体,它用于存储编码后的数据
    AVFrame *frame;                // 该帧用于存储原始的音频数据
    int ret;
    int got_packet;                // 用于标记是否成功获取到一个编码后的数据包
    int dst_nb_samples;            // 用于存储目标样本数。

    av_init_packet(&pkt);          // 初始化数据包
    codec_ctx = ost->enc;          // 获取 OutputStream 结构体中的编码器上下文

    frame = get_audio_frame(ost);  // 从ost中获取下一个要编码的音频帧。get_audio_frame是自定义函数

    //这段代码主要处理音频帧的重采样和格式转换,并对时间基进行调整。
    if (frame)
    {
        /*
        计算转换后的目标样本数。
        函数 swr_get_delay 获取重采样器上下文中的延迟,然后与当前帧的样本数相加,得到总样本数。
        这个总数随后被重新缩放到目标采样率。
        */
        dst_nb_samples = av_rescale_rnd(swr_get_delay(ost->swr_ctx, codec_ctx->sample_rate) + frame->nb_samples,
                                        codec_ctx->sample_rate, codec_ctx->sample_rate, AV_ROUND_UP);
        assert(dst_nb_samples == frame->nb_samples);

        /*
        在传递帧给编码器进行编码之前,确保该帧是可写的。
        编码器可能会在内部保持对帧的引用,所以在修改它之前需要进行此检查。
         */
        ret = av_frame_make_writable(ost->frame);
        if (ret < 0)
            exit(1);

        /* 
        使用 swr_convert 函数进行重采样和格式转换。
        源帧的数据(frame->data)被转换到目标帧(ost->frame->data)
        从`frame->data`中读取`frame->nb_samples`个样本,
        然后将这些样本转换并放入`ost->frame->data`中,其中转换后的样本数量为`dst_nb_samples`
        */
        ret = swr_convert(ost->swr_ctx,
                          ost->frame->data, dst_nb_samples,
                          (const uint8_t **)frame->data, frame->nb_samples);
        if (ret < 0)
        {
            fprintf(stderr, "Error while converting\n");
            exit(1);
        }
        // 将 frame 指向转换后的帧
        frame = ost->frame;
        /*
        将帧的时间戳(pts)从一个时间基转换到另一个时间基。
        这里它将样本计数从一个固定的时间基转换为编解码器的时间基。
        */
        frame->pts = av_rescale_q(ost->samples_count, (AVRational){1, codec_ctx->sample_rate},
                                  codec_ctx->time_base);
        // 更新样本计数器
        ost->samples_count += dst_nb_samples;
    }

    // 将音频帧进行编码
    ret = avcodec_encode_audio2(codec_ctx, &pkt, frame, &got_packet);
    if (ret < 0)
    {
        fprintf(stderr, "Error encoding audio frame: %s\n", av_err2str(ret));
        exit(1);
    }
    
    // 如果成功编码并获取到一个数据包,将编码后的音频帧写入输出媒体容器
    if (got_packet)
    {
        // 调用自定义的 write_frame 函数,将已经编码的音频包或视频包写入输出格式上下文中
        ret = write_frame(oc, &codec_ctx->time_base, ost->st, &pkt);
        if (ret < 0)
        {
            fprintf(stderr, "Error while writing audio frame: %s\n",
                    av_err2str(ret));
            exit(1);
        }
    }
    // frame == NULL 读取不到frame(比如读完了5秒的frame); got_packet == 0 没有帧了
    // 如果函数仍然有帧需要处理或已获取到数据包,则返回0,否则返回1
    return (frame || got_packet) ? 0 : 1;
}

重点解释语句dst_nb_samples = av_rescale_rnd(swr_get_delay(ost->swr_ctx, codec_ctx->sample_rate)+ frame->nb_samples,codec_ctx->sample_rate, codec_ctx->sample_rate, AV_ROUND_UP);

  • 第一个参数:swr_get_delay(ost->swr_ctx, codec_ctx->sample_rate) + frame->nb_samples 是我们想要重新缩放的值(即当前缓冲的样本数 + 当前帧的样本数)。
  • 第二和第三个参数:codec_ctx->sample_rate 是输入和输出的采样率,它们都设置为编解码器的采样率,这意味着实际上没有采样率转换。
  • 第四个参数:AV_ROUND_UP 是一个舍入模式,它确保结果是向上舍入的。

swr_get_delay函数返回从输入到输出的样本数中,尚未被 swr_convert(来自FFmpeg的软件重采样库) 转换的样本数。换句话说,它告诉我们当前在进行重采样转换后,多少样本仍留在内部缓冲区中尚未输出。这些延迟样本加上当前帧的样本数frame->nb_samples,得出了所有待转换的样本的总数。所以dst_nb_samples最终表示的是,在目标采样率下需要处理的总样本数。这个值应该与frame->nb_samples相等,因为实际上没有采样率转换(由于输入和输出采样率相同)。这就是为什么后面有一个断言assert(dst_nb_samples == frame->nb_samples)来验证这两个值是否相等。

write_video_frame: 用于从给定的输出流获取、编码一个视频帧,并将编码后的数据包写入输出文件。具体做了这样几件事:

  1. 函数从输出流中获取一个待编码的视频帧。
  2. 尝试编码这个帧。
  3. 如果编码成功,进一步尝试将编码后的数据包写入输出文件。
  4. 函数的返回值提供了关于是否还有更多帧需要处理的信息。
  • 参数:
  • AVFormatContext *oc: 这是一个指向输出文件格式的上下文的指针。它包含了关于输出文件的各种信息,如媒体流的数量、流的类型(音频、视频等)以及与输出文件格式有关的其他细节。
  • OutputStream *ost: 这是一个指向输出流的指针,输出流表示输出文件中的一个媒体流。在这个函数中,它特别指的是一个视频流,包含视频编解码器的上下文、当前的时间戳以及其他与视频流有关的数据。
write_video_frame 复制代码
static int write_video_frame(AVFormatContext *oc, OutputStream *ost)
{
    // 与write_audio_frame一样初始化
    int ret;
    AVCodecContext *codec_ctx;
    AVFrame *frame;
    int got_packet = 0;
    AVPacket pkt = { 0 };

    codec_ctx = ost->enc;

    // 调用自定义的 get_video_frame 函数从输出流获取一个视频帧
    frame = get_video_frame(ost);
    
    // 初始化`pkt`,为其分配内存和设置默认值
    av_init_packet(&pkt);

    /* 
    使用 avcodec_encode_video2 函数尝试编码视频帧。
    如果成功编码,got_packet 将设置为1,表示编解码器生成了一个有效的输出数据包
    */
    ret = avcodec_encode_video2(codec_ctx, &pkt, frame, &got_packet);
    if (ret < 0)
    {
        fprintf(stderr, "Error encoding video frame: %s\n", av_err2str(ret));
        exit(1);
    }

    if (got_packet)
    {
        /*
        这里 &codec_ctx->time_base 将当前流(在这种情况下是视频流)的时间基传递给函数。
        此时间基用于正确地调整时间戳,确保它们与输出文件的其他流同步。
        */
        ret = write_frame(oc, &codec_ctx->time_base, ost->st, &pkt);
    }
    else
    {
        ret = 0;
    }

    if (ret < 0)
    {
        fprintf(stderr, "Error while writing video frame: %s\n", av_err2str(ret));
        exit(1);
    }

    // 这里之所以有两个判断条件
    // frame非NULL: 表示还在产生YUV数据帧
    // got_packet为1: 编码器还有缓存的帧
    return (frame || got_packet) ? 0 : 1;
}

上述代码又使用了alloc_picturealloc_audio_frameget_audio_frameget_video_framewrite_frame五个函数,用于分配图像和音频内存、获取图像和音频帧、写帧,下面依次进行介绍。

alloc_picture用于为视频帧分配内存,它接收三个参数:一个像素格式(pix_fmt)、一个宽度(width)和一个高度(height)

alloc_picture 复制代码
static AVFrame *alloc_picture(enum AVPixelFormat pix_fmt, int width, int height)
{
    AVFrame *picture;
    int ret;

    // 使用FFmpeg库函数 av_frame_alloc() 为 AVFrame 结构体分配内存。
    picture = av_frame_alloc();
    if (!picture)
        return NULL;

    // 设置 picture 的格式、宽度和高度为函数的输入参数值。
    picture->format = pix_fmt;
    picture->width  = width;
    picture->height = height;

    // 为帧数据分配内存。这里,32 是对齐参数,用于内存对齐
    ret = av_frame_get_buffer(picture, 32);
    if (ret < 0)
    {
        fprintf(stderr, "Could not allocate frame data.\n");
        exit(1);
    }
    // 返回已分配并初始化的 picture 
    return picture;
}

alloc_audio_frame:用于创建并初始化一个用于音频的AVFrame,还会为其分配内存。

  • 参数:
  • sample_fmt:音频样本的格式,例如AV_SAMPLE_FMT_FLTP代表浮点型。
  • channel_layout:音频通道的布局,例如AV_CH_LAYOUT_STEREO代表立体声。
  • sample_rate:音频的采样率,例如44100。
  • nb_samples:在音频帧中的样本数量。
alloc_audio_frame 复制代码
static AVFrame *alloc_audio_frame(enum AVSampleFormat sample_fmt,
                                  uint64_t channel_layout,
                                  int sample_rate, int nb_samples)
{
    AVFrame *frame = av_frame_alloc();
    int ret;

    if (!frame)
    {
        fprintf(stderr, "Error allocating an audio frame\n");
        exit(1);
    }

    // 将函数的参数值赋给相应的 AVFrame 属性
    frame->format = sample_fmt;
    frame->channel_layout = channel_layout;
    frame->sample_rate = sample_rate;
    frame->nb_samples = nb_samples;

    if (nb_samples)
    {
        ret = av_frame_get_buffer(frame, 0);
        if (ret < 0)
        {
            fprintf(stderr, "Error allocating an audio buffer\n");
            exit(1);
        }
    }

    return frame;
}

get_audio_frame:用于生成一个音频帧,这里生成的音频是一个简单的正弦波信号。接受一个OutputStream类型的参数,并返回一个AVFrame

get_audio_frame 复制代码
static AVFrame *get_audio_frame(OutputStream *ost)
{
    // 从 OutputStream 结构体中获取临时音频帧。
    AVFrame *frame = ost->tmp_frame;
    int j, i, v;
    
    // 初始化指针 q 来指向帧的数据。这里假定数据是 int16_t 类型的,所以进行了类型转换
    int16_t *q = (int16_t*)frame->data[0];

    /*
    这里使用 av_compare_ts 函数来比较时间戳(PTS)。
    如果 next_pts 已经大于等于预定的流持续时间 STREAM_DURATION ,则不再生成更多帧。
    */
    if (av_compare_ts(ost->next_pts, ost->enc->time_base,
                      STREAM_DURATION, (AVRational){ 1, 1 }) >= 0)
        return NULL;

    /*
    外部循环遍历帧的所有样本。内部循环遍历每个通道,并为每个通道的样本分配一个正弦波的值。
    ost->t 是正弦函数的角度,每次循环都增加。 tincr 是角度的增量,tincr2 用于微调 tincr。
    */
    for (j = 0; j <frame->nb_samples; j++)
    {
        v = (int)(sin(ost->t) * 10000);
        for (i = 0; i < ost->enc->channels; i++)
            *q++ = v;
        ost->t     += ost->tincr;
        ost->tincr += ost->tincr2;
    }

    frame->pts = ost->next_pts; // 设置帧的PTS(时间戳),这是用于同步和计时的重要参数
    ost->next_pts  += frame->nb_samples;    // 音频PTS使用采样samples叠加

    return frame;
}

get_video_frame:用于生成一个视频帧,会转换其像素格式以适应编解码器,并适当地更新时间戳。

rust 复制代码
static AVFrame *get_video_frame(OutputStream *ost)
{
    // 从`OutputStream`中获取编解码器上下文
    AVCodecContext *codec_ctx = ost->enc;

    /* 
    与音频类似,这里使用 av_compare_ts 函数来判断是否已经生成了足够的帧。
    如果 next_pts 已经大于等于流的持续时间,函数返回NULL,表示不再生成帧。
    */
    // 我们测试时只产生STREAM_DURATION(这里是5.0秒)的视频数据
    if (av_compare_ts(ost->next_pts, codec_ctx->time_base,
                      STREAM_DURATION, (AVRational){ 1, 1 }) >= 0)
        return NULL;

    // 在给编解码器传递帧之前,确保这个帧是可写的,这是因为编解码器可能在内部对它修改。
    if (av_frame_make_writable(ost->frame) < 0)
        exit(1);
    
    // 检查编解码器所需的像素格式是否为YUV420P
    if (codec_ctx->pix_fmt != AV_PIX_FMT_YUV420P)
    {
        /*
         如果需要的像素格式不是YUV420P,我们需要一个图像转换上下文来转换图像数据。
         如果没有现有的转换上下文,就会创建一个。
         codec_ctx->width, codec_ctx->height: 源图像的宽度和高度。
         AV_PIX_FMT_YUV420P: 源图像的像素格式,这里是YUV420P。
         codec_ctx->width, codec_ctx->height`: 目标图像的宽度和高度,这里与源图像相同。
         codec_ctx->pix_fmt: 目标图像的像素格式。
         SCALE_FLAGS: 是图像转换的标志,通常用于指定转换算法,例如双线性、双三次等。
         后面的 NULL, NULL, NULL: 是为高级选项预留的空间,例如源/目标范围、色彩空间等。
         在这个例子中,它们都是NULL,表示使用默认值。
        */
        if (!ost->sws_ctx)
        {
            ost->sws_ctx = sws_getContext(codec_ctx->width, codec_ctx->height,
                                          AV_PIX_FMT_YUV420P,
                                          codec_ctx->width, codec_ctx->height,
                                          codec_ctx->pix_fmt,
                                          SCALE_FLAGS, NULL, NULL, NULL);
            if (!ost->sws_ctx) {
                fprintf(stderr
                        ,
                        "Could not initialize the conversion context\n");
                exit(1);
            }
        }
        // 使用YUV数据填充临时帧
        fill_yuv_image(ost->tmp_frame, ost->next_pts, codec_ctx->width, codec_ctx->height);
        // 使用 sws_scale 函数将临时帧的数据转换为目标帧的数据
        sws_scale(ost->sws_ctx, (const uint8_t * const *) ost->tmp_frame->data,
                  ost->tmp_frame->linesize, 0, codec_ctx->height, ost->frame->data,
                  ost->frame->linesize);
    } else {
        fill_yuv_image(ost->frame, ost->next_pts, codec_ctx->width, codec_ctx->height);
    }
    
    /*
    为生成的帧设置PTS,并递增 next_pts。
    这里的 ++ 表示每次增加1,与视频的帧率有关,表示时间的递增。
    */
    ost->frame->pts = ost->next_pts++;  
    return ost->frame;
}

write_frame:用于确保编码后的帧(包含在AVPacket结构体中)具有正确的时间戳和流索引,然后将其写入输出文件中

  • 参数:
  • AVFormatContext *fmt_ctx: 这是一个指向AVFormatContext结构体的指针,该结构体代表了输出的媒体文件的上下文。
  • const AVRational *time_base: 这是编解码器的时间基。时间基用于转换时间戳,确保时间戳在不同的上下文中保持一致。
  • AVStream *st: 这是一个指向AVStream结构体的指针,表示输出文件中的一个流(一个视频流或音频流)。
  • AVPacket *pkt: 这是一个指向AVPacket结构体的指针,表示编码后的帧。
scss 复制代码
static int write_frame(AVFormatContext *fmt_ctx, const AVRational *time_base,
                       AVStream *st, AVPacket *pkt)
{
    // 重新缩放包时间戳,将它从编解码器的时间基转换为流的时间基
    av_packet_rescale_ts(pkt, *time_base, st->time_base);
    // 设置数据包的流索引,告诉FFmpeg这个数据包属于哪个流。st->index 提供了输出文件中流的索引
    pkt->stream_index = st->index;
    
    log_packet(fmt_ctx, pkt);
    /*
    写入编码后的帧到媒体文件。
    函数 av_interleaved_write_frame 确保帧以交错的方式写入
    这对于多个流(例如视频和音频)的文件很重要,以确保播放时的同步
    */
    return av_interleaved_write_frame(fmt_ctx, pkt);
}

附1:生成YUV图像的代码

fill_yuv_image 复制代码
// 根据帧索引为YUV图像的每一个像素填充亮度和色度分量。这样,随着时间的推移,将会产生动画效果
static 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 and 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;
        }
    }
}

附2:关闭流代码

close_stream 复制代码
static void close_stream(AVFormatContext *oc, OutputStream *ost)
{
    avcodec_free_context(&ost->enc);
    av_frame_free(&ost->frame);
    av_frame_free(&ost->tmp_frame);
    sws_freeContext(ost->sws_ctx);
    swr_free(&ost->swr_ctx);
}

3. 时间同步原理

时间基(time base) :一个用于表示时间单位的分数。在多媒体处理中,时间基是用来精确表示时间戳(timestamps)、持续时间(duration)和帧率(frame rate)等概念的工具。它通常由两个整数表示:一个分子和一个分母,通常写作 num/den。例如,常见的视频时间基是 1/251/30。这意味着每帧的持续时间是 1/25 秒或 1/30 秒,对应 25fps(帧每秒)或 30fps 的帧率。

时间戳(Timestamp)PTS:一个标记或指示某一特定时刻或时间点的标识。在多媒体编程中,时间戳经常用于表示音频样本、视频帧或其他数据块的具体播放时刻。

  1. 时间戳的表示单位
    时间戳的数值是基于时间基的。例如,如果一个视频流的时间基是1/30(代表30帧/秒),那么第2帧的时间戳是2(即该帧的播放时刻是第2个时间单位,等同于1/15秒)。
  2. 时间戳的转换
    当需要将数据从一个流转移到另一个流时,或者当两个流需要同步时,可能需要对时间戳进行转换。这涉及到将时间戳从一个时间基转换为另一个时间基。例如,如果一个视频帧从一个时间基为1/30的流复制到一个时间基为1/60的流,那么其时间戳需要乘以2。
  3. 时间戳的应用
    在多媒体播放或处理时,时间戳用于确定何时播放或处理特定的数据块。例如,如果一个视频帧的时间戳是60(在时间基1/30下),那么它应该在第2秒时播放。
  4. 精确同步
    在处理同时包含音频和视频的媒体时,时间戳用于确保音频和视频同步。每个音频样本或视频帧都有其自己的时间戳,这使得播放器知道何时播放每一个样本或帧,从而确保音频和视频保持同步。

简而言之,时间戳是表示数据块播放或处理时刻的标识,而时间基是时间戳的表示单位。

4. 区分编码和显示时间戳

DTS(Decoding Time Stamp)是一个标识在解码过程中应当处理该帧的时间戳。简而言之,DTS告诉解码器何时开始解码某一帧。

为了理解DTS的重要性,需要了解B帧的概念。在视频编码中,B帧(双向预测帧)是一种使用前一帧和后一帧的信息来预测当前帧的帧类型。由于B帧的这种特性,它们必须在其他参考帧之后解码但在显示时插入正确的位置。这样,即使一个B帧在某些I帧和P帧之后被编码,但它可能需要在它们之前被显示。

在这种情况下,DTS和PTS(Presentation Time Stamp)之间就存在差异:

  • DTS:告诉我们何时应该解码帧。
  • PTS:告诉我们何时应该显示帧。

考虑这样一个简化的示例:帧的顺序为I1, B1, B2, P1,但由于B帧的依赖关系,它们可能被编码为I1, P1, B1, B2。在这种情况下:

  • I1的DTS和PTS都是1。
  • P1的DTS是2,但其PTS是4(因为在显示之前,B1和B2需要先被插入)。
  • B1的DTS是3,但其PTS是2。
  • B2的DTS是4,但其PTS是3。

总之,DTS确保帧按正确的顺序被解码,而PTS确保帧按正确的顺序被显示。

相关推荐
在狂风暴雨中奔跑9 天前
Android+FFmpeg+x264重编码压缩你的视频
音视频开发
音视频牛哥14 天前
[2015~2024]SmartMediaKit音视频直播技术演进之路
音视频开发·视频编码·直播
音视频牛哥16 天前
Windows平台Unity3D下RTMP播放器低延迟设计探讨
音视频开发·视频编码·直播
音视频牛哥16 天前
Windows平台Unity3D下如何低延迟低资源占用播放RTMP或RTSP流?
音视频开发·视频编码·直播
音视频牛哥16 天前
Android平台GB28181设备接入模块动态文字图片水印技术探究
音视频开发·视频编码·直播
陈年17 天前
纯前端视频剪辑
音视频开发
声知视界18 天前
音视频基础能力之 Android 音频篇 (三):高性能音频采集
android·音视频开发
音视频牛哥21 天前
RTSP摄像头8K超高清使用场景探究和播放器要求
音视频开发·视频编码·直播
音视频牛哥21 天前
RTMP如何实现毫秒级延迟体验?
音视频开发·视频编码·直播
哔哩哔哩技术23 天前
WASM 助力 WebCodecs:填补解封装能力的空白
音视频开发