跳转至

22 视频录制项目实战:让你的录制器运行起来

你好,我是展晓凯。今天我们来一起学习视频录制器的最后一部分,让它跑起来。

上一节课我们一起实现了视频录制器中的底层模块,现在音频模块已经把音频编码为AAC数据放到AAC的音频队列里了,视频模块也已经把视频编码为H264的数据放到了H264的队列里。那这节课我们就需要把这两个队列里的压缩数据封装到一个MP4文件里,让整个视频录制器跑起来。

这节课我们也会分成两部分来讲解,第一部分是实现Muxer模块,这部分的职责是把压缩后的音视频数据封装成MP4格式并写入文件中,第二部分学习整个录制器的中控系统,用来管理各个模块的生命周期和数据流转,让整个录制器项目跑起来。下面就先进入第一部分的学习吧。

Muxer模块

音频帧和视频帧都编码完毕之后,接下来就要把它们封装到一个容器里,比如MP4、FLV、RMVB、AVI等,录制器架构是通过一个Muxer模块来完成这个职责的。上一节课中的AAC和H264这两个队列就是Muxer模块的输入,那这个模块的输出又是什么?在我们现在的场景下就是磁盘上的一个MP4文件,当然也可以是网络流媒体服务器,那就成为直播场景的推流器了。

注意,这个模块也需要有一个自己单独的线程,我们叫它Muxer线程。由于不想影响采集以及实时耳返和预览的过程,所以在编码时单独抽取出一个线程。那为什么我们又要为封装和文件流输出(对应于FFmpeg的Muxer层和Protocol层)单独抽取出一个线程来呢?

仔细观察可以发现,编码其实是一个CPU密集型操作(即使是硬件编码,也是要占用CPU的时间片和编码的硬件设备进行内存数据交换的),而我们的封装和文件流输出却不是CPU密集型的,尤其是IO输出到网络时,那么不应该由输出来影响整个编码过程。

拆分开之后每个模块各司其职,统一接口,对整个系统的维护以及扩展会有极大的好处,比如,由软件编码升级为硬件编码,由于接口不变,直接更改编码模块的实现就好了,或者我们的封装格式由MP4转换为FLV的话,也只需要改动封装模块。

整个Muxer模块我们会分为三部分来讲解,第一部分是初始化。

初始化

我们先来看一下如何用FFmpeg实现格式封装与文件流输出,封装和输出其实就是FFmpeg里面的libavformat这个模块所承担的职责,来看一下初始化方法的定义:

int init(char* videoOutputURI, int videoWidth,
        int videoHeight,float videoFrameRate,int videoBitRate,
        int audioSampleRate, int audioChannels, int audioBitRate,
        char* audio_codec_name)

初始化方法的参数比较多。第一部分就是输出的文件路径;第二部分是视频流的参数,包括视频宽、高、帧率以及比特率、视频编码格式(默认为H264格式);第三部分就是音频流的参数,包括音频的采样率、声道数、比特率,以及音频编码器的名称。
这个初始化的方法内部核心流程是,构造一个Container(对应FFmpeg中的结构体类型是AVFormatContext),根据上述的视频参数配置好一路视频流(AVStream)添加到这个Container中,然后根据上述的音频参数再配置一路音频流(AVStream)添加到Container中。

下面来看一下具体实现,第一步先注册FFmpeg里面的所有封装格式、编解码器以及网络配置开关(如果需要将视频流推送到网络上的话)。

avcodec_register_all();
av_register_all();
avformat_network_init();

然后根据输出目录来构造一个Container,即构造一个AVFormatContext类型的结构体,其实这个结构体就是FFmpeg中使用libavformat模块的入口。

AVFormatContext* oc;
avformat_alloc_output_context2(&oc, NULL, "mp4", videoOutputURI);
AVOutputFormat* fmt = oc->oformat;

接下来构造一路视频流,并加入这个Container里。先使用常量AV_CODEC_ID_H264找出H264的编码器,然后在Container里增加一路H264编码的视频流,并找出这路流的编码器上下文,对这个上下文的属性依次赋值,就构造好了这路视频流。代码如下:

AVCodec *video_codec = avcodec_find_encoder(AV_CODEC_ID_H264);
AVStream *st = avformat_new_stream(oc, video_codec);
st->id = oc->nb_streams - 1;
AVCodecContext *c = st->codec;
c->codec_id = AV_CODEC_ID_H264;
c->bit_rate = videoBitRate;
c->width = videoWidth;
c->height = videoHeight;
c->time_base.den = 30000;
c->time_base.num = (int) (30000 / videoFrameRate);
c->gop_size = videoFrameRate;
if (oc->oformat->flags & AVFMT_GLOBALHEADER)
    c->flags |= CODEC_FLAG_GLOBAL_HEADER;

接下来构造一路音频流,整个过程和视频流的构建非常类似,不同的是,编码器是通过传递进来的编码器名称寻找的,代码如下:

AVCodec *audio_codec = avcodec_find_encoder_by_name(codec_name);
AVStream *st = avformat_new_stream(oc, audio_codec);
st->id = oc->nb_streams - 1;
AVCodecContext *c = st->codec;
c->sample_fmt = AV_SAMPLE_FMT_S16;
c->bit_rate = audioBitRate;
c->codec_type = AVMEDIA_TYPE_AUDIO;
c->sample_rate = audioSampleRate;
c->channel_layout = audioChannels == 1 ? AV_CH_LAYOUT_MONO :
AV_CH_LAYOUT_STEREO;
c->channels = av_get_channel_layout_nb_channels(c->channel_layout);
c->flags |= CODEC_FLAG_GLOBAL_HEADER;

完成音频流的添加之后,这里需要给音频编码器上下文填充extradata属性,这也是前面我们讲解的AAC封装格式的实际运用了。还记得之前编码AAC的时候要在编码出来的数据前面加上ADTS的头吗?其实在ADTS头部信息可以提取出编码器的Profile、采样率以及声道数的信息。在MP4文件中AAC是ADIF格式的,而在FFmpeg的实现中是在全局的extradata中配置这些信息。那么,我们来看一下如何为FFmpeg的音频编码器上下文来设置这个extradata。

int profile = 2;  //AAC LC
int freqIdx = 4;  //44.1Khz
int chanCfg = 2;  //Stereo Channel
char dsi[2];
dsi[0] = (profile<<3) | freqIdx>>1);
dsi[1] = ((freqIdx&1)<<7) | (chanCfg<<3);
memcpy(c->extradata, dsi, 2);

你可能会有疑问,视频流的这个extradata变量该设置什么呢?视频的编码器中的变量设置稍后会讲解,因为在视频流中的这个变量存放的是SPS和PPS的信息,是由编码器在编码过程的第一步输出的,所以放在封装和输出部分来讲解。音频这里我们也要配置一个音频格式转换的滤波器,就是ADTS到ADIF格式的转换器。

bsfc = av_bitstream_filter_init("aac_adtstoasc");

上述步骤完成之后,说明我们的Container(封装格式)已经初始化好了,然后就是打开文件的连接通道,可调用FFmpeg的Protocol层来完成操作,代码如下:

AVIOInterruptCB int_cb = { interrupt_cb, this };
oc->interrupt_callback = int_cb;
avio_open2(&oc->pb, videoOutputURI, AVIO_FLAG_WRITE, &oc->interrupt_callback, NULL);

如果avio_open2函数的返回值大于等于0,就设置isConnected变量为true,代表已经成功地打开了文件输出通道。上述代码中需要注意的是,我们需要配置一个超时回调函数进去,这个回调函数主要是给FFmpeg的协议层用的,返回1则代表结束I/O操作,返回0则代表继续I/O操作,超时回调函数实现如下:

static int interrupt_cb(void* ctx) {
    if(getCurrentTimeMills() – latestFrameTime > 15 * 1000)
        return 1;
    return 0;
}

上述代码表示如果当前时间超过了封装上一帧的时间(15s),就终止协议层的I/O操作,当然,每次封装一帧之后就要更新latestFrameTime这个变量。这个回调函数的配置是非常重要的,特别是在我们和网络打交道的时候。
初始化方法到这里就配置结束了,接下来看实际的封装和输出。

封装和输出

构建好了这个Container之后,再不断地将音频帧和视频帧交错地封装进来,然后通过输出通道写出到文件或者网络中。先来看一下主体的流程:

int ret = 0;
double video_time = getVideoStreamTimeInSecs();
double audio_time = getAudioStreamTimeInSecs();
if (audio_time < video_time){
    ret = write_audio_frame(oc, audio_st);
} else {
    ret = write_video_frame(oc, video_st);
}
latestFrameTime = getCurrentTimeMills();
return ret;

由于音视频一般是交错存储的,也就是存储一帧视频后,再存储一段时间的音频(不一定是一帧音频,这要看视频的fps是多少,因为代码中是按照时间比较来决定写入的),之后再存储一帧视频,所以在某一个时间点是要封装音频还是封装视频,是由当前两路流上已经封装的时间戳来决定的。代码显示先获取两路流上当前的时间戳信息,然后进行比较,封装和输出时间戳比较小的那一路流,并且更新latestFrameTime这个变量来辅助前面配置的超时回调函数判断。

接下来分别看一下封装和输出音频以及视频流(write_audio/video_frame)的实现,先来看音频流部分:

int ret = AUDIO_QUEUE_ABORT_ERR_CODE;
AudioPacket* audioPacket = NULL;
fillAACPacketCallback(&audioPacket, fillAACPacketContext);
audioStreamTimeInsecs = audioPacket->position;

封装音频流时,先从AAC的音频队列中取出一帧音频帧,然后取出这个AAC音频帧的时间戳信息,存储到全局变量中的audioStreamTimeInsecs中,作为我们要写入音频这路流的时间戳信息,以便编码之前取出音频流中编码到的时间信息。然后将这个AAC的Packet转换成一个AVPacket类型的结构体,代码如下:

AVPacket pkt = { 0 };
av_init_packet(&pkt);
pkt.data = audioPacket->data;
pkt.size = audioPacket->size;
pkt.dts = pkt.pts = lastAudioPacketPresentationTimeMills / 1000.0f / av_q2d(st->time_base);
pkt.duration = 1024;
pkt.stream_index = st->index;

接着将这个pkt作为调用bitStreamFilter转换的输入Packet,经过转换之后,这个ADTS封装格式的AAC就变成了一个ADIF封装格式的AAC了,之后就可以通过输出通道输出了,具体代码如下:

AVPacket newpacket;
av_init_packet(&newpacket);
ret = av_bitstream_filter_filter(bsfc, st->codec, NULL,
        &newpacket.data, &newpacket.size, pkt.data, pkt.size,
        pkt.flags & AV_PKT_FLAG_KEY);
if (ret >= 0) {
    newpacket.pts = pkt.pts;
    newpacket.dts = pkt.dts;
    newpacket.duration = pkt.duration;
    newpacket.stream_index = pkt.stream_index;
    ret = av_interleaved_write_frame(oc, &newpacket);
}
av_free_packet(&newpacket);

到这里,我们就把从队列中取得的一帧ADTS封装格式的AAC写到Container中的音频轨道中了。

接下来看一下视频流是怎么封装和输出的,首先填充视频编码器上下文中的extradata,这一点非常重要,否则等这个视频进行播放的时候,解码器无法正确初始化就不能够正确解码视频。先来看取出H264队列中的视频帧,部分代码:

VideoPacket *h264Packet = NULL;
fillH264PacketCallback(&h264Packet, fillH264PacketContext);
videoStreamTimeInSecs= h264Packet->timeMills / 1000.0;

接下来看一下如何正确填充extradata,第20节课我们已经把SPS和PPS的信息拼接起来封装为一帧H264数据放到视频帧队列中了,所以这里在拿出了H264帧之后首先判定是否是SPS类型的帧,判断规则是取出这一帧H264数据的index为4的下标,按位与上0x1F得到NALU Type,然后和H264中预定义的类型进行比较,代码如下:

uint8_t* outputData = (uint8_t *) ((h264Packet)->buffer);
int nalu_type = (outputData[4] & 0x1F);

至于NALU Type的类型定义,在第16节课有详细介绍,你可以自己回顾一下。判定帧类型是不是SPS类型,也就是判定nalu_type是不是等于7,如果相等的话,将这一帧H264数据拆分成SPS和PPS信息(第20节课把SPS和PPS拼接成了一帧放入到了视频队列中),拆分过程在这里就不再展示代码了,核心实现就是找出H264的StartCode,即以00 00 00 01开始的部分,第一个就是SPS,第二个就是PPS。

把SPS和PPS分别放到两个uint8_t的数组里,一个是spsFrame,另外一个是ppsFrame,并且这个数组的长度也存放到对应的变量中。最后把spsFrame和ppsFrame封装到视频编码器上下文的extradata中,代码如下:

AVCodecContext *c = videoStream->codec;
int extradata_len = 8 + spsFrameLen - 4 + 1 + 2 + ppsFrameLen - 4;
c->extradata = (uint8_t*) av_mallocz(extradata_len);
c->extradata_size = extradata_len;
c->extradata[0] = 0x01;
c->extradata[1] = spsFrame[4 + 1];
c->extradata[2] = spsFrame[4 + 2];
c->extradata[3] = spsFrame[4 + 3];
c->extradata[4] = 0xFC | 3;
c->extradata[5] = 0xE0 | 1;
int tmp = spsFrameLen - 4;
c->extradata[6] = (tmp >> 8) & 0x00ff;
c->extradata[7] = tmp & 0x00ff;
int i = 0;
for (i = 0; i < tmp; i++)
    c->extradata[8 + i] = spsFrame[4 + i];
c->extradata[8 + tmp] = 0x01;
int tmp2 = ppsFrameLen - 4;
c->extradata[8 + tmp + 1] = (tmp2 >> 8) & 0x00ff;
c->extradata[8 + tmp + 2] = tmp2 & 0x00ff;
for (i = 0; i < tmp2; i++)
    c->extradata[8 + tmp + 3 + i] = ppsFrame[4 + i];

上述拼接规则是我在FFmpeg的源码里提取出来的(源码在libavformat目录中, avc.c这个文件里面的方法ff_isom_write_avcc中),上述代码分为以下几部分:

  • 第一部分是元数据部分,即下标从0到5,代表了version、profile、profile compat、level以及两个保留位;
  • 第二部分是SPS,包括SPS的大小以及SPS的内部信息;
  • 第三部分是PPS,首先是PPS的数目,然后是PPS的大小和PPS的内部信息。

这个拼接规则比较重要,一定要好好理解一下,你在工作中使用硬件解码器加速视频播放器或者离线保存的项目中,会经常用到这个拼接规则,只不过是通过extradata解析出SPS和PPS信息。封装好视频流编码器的extradata之后,才表示这个Container准备好了,在这里要调用write_header方法,把这些MetaData写出到文件或者网络流中,代码如下:

int ret = avformat_write_header(oc, NULL);
if (ret >= 0) {
    isWriteHeaderSuccess = true;
}

当write header成功的时候,将变量isWriteHeaderSuccess设置为true,方便后续在实现销毁操作时用来判断是否需要执行write trailer的操作。

接下来就是真正地将视频帧封装并且输出了,而这里最重要的就是视频帧封装格式的转换,在音频的封装过程中,我们将ADTS格式的转换为ADIF格式的,使用了ADTS到ASC的转换过滤器,而在这里是没有这样的转换过滤器可供我们直接使用的,所以需要手动转换,这个转换过程也很简单,把H264视频帧起始的StartCode部分替换为这一帧视频帧的大小即可,代码如下:

pkt.data = outputData;
if(pkt.data[0] == 0x00 && pkt.data[1] == 0x00 &&
        pkt.data[2] == 0x00 && pkt.data[3] == 0x01){
    bufferSize -= 4;
    pkt.data[0] = ((bufferSize) >> 24) & 0x00ff;
    pkt.data[1] = ((bufferSize) >> 16) & 0x00ff;
    pkt.data[2] = ((bufferSize) >> 8) & 0x00ff;
    pkt.data[3] = ((bufferSize)) & 0x00ff;
}

如上面代码所示,代表帧大小的这个bufferSize的字节顺序是很重要的,必须按照代码中的大尾端(big endian)字节序拼接才可以。

接下来就是将这个AVPacket的size设置为bufferSize,将pts和dts设置为从H264队列中取出来的pts和dts,此外还需要将视频编码器上下文的frame_number加1,代表又增加了一帧视频帧,最后还有一个对于AVPacket来说非常重要的属性—flags,即标识这个视频帧是否是关键帧,那应该如何来确定取出来的H264这一帧视频帧是否是关键帧呢?

还是得回到上面判断NALU Type的地方,如果NALU Type不是SPS,就判断是否是关键帧,即nalu_type是否等于5,如果是关键帧,就把flags设置为1,可以使用FFmpeg中定义的宏AV_PKT_FLAG_KEY;如果不是关键帧就设置为0,因为解码器要按照是否是关键帧来构造解码过程中的参考队列。至此我们的封装工作就结束了。接下来就是输出部分,其实输出和音频流的输出是一样的,代码如下:

av_interleaved_write_frame(oc, &pkt);

封装和输出视频帧结束后,就可以再回到Mux模块的主体流程了,主体流程会不断地进行循环,直到音频或者视频的封装和输出函数返回小于0的值则结束,但何时返回小于0的值呢?

其实就是在获取AAC队列和H264队列的时候,如果这个队列被abort掉了,那么就返回小于0的值,那这两个队列又是何时被abort掉的呢?其实就是在停止整个Mux流程的时候。停止Mux模块如下,首先会abort掉这两个队列,然后等待主体Mux流程的线程停止,之后调用销毁资源方法。

销毁资源

对于销毁资源,首先要做的是判断输出通道是否打开,并且确定它是否做了writeHeader操作,如果做了的话,就要执行write_trailer操作,然后设置好duration,代码如下:

if (isConnected && isWriteHeaderSuccess) {
    av_write_trailer(oc);
    oc->duration = duration * AV_TIME_BASE;
}

这里有一点比较重要,如果我们没有write header而又在销毁的时候调用了write trailer,那么FFmpeg程序会直接崩溃,所以这里使用了一个布尔变量来保证write header和write trailer的成对出现。由于Mux模块不做编码工作,所以没有打开过任何编码器,也就无需关闭编码器,但是对于音频来讲,还使用了一个bitStreamFilter,所以需要关闭它。

av_bitstream_filter_close(bsfc);

最后关闭掉输出通道,释放掉整个AVFormatContext。

if (isConnected) {
    avio_close(oc->pb);
    isConnected = false;
}
avformat_free_context(oc);

这样我们就完成了整个Muxer模块,接下来我再讲解一下中控系统,将各个模块串联起来,完成我们整个视频录制器项目。

中控系统

最后我们需要写一个控制器来把所有的模块串联起来,从而完成整个项目。在进入录制视频阶段之前,就已经有了视频的预览界面,即已经启动了视频采集模块,只不过不会启动编码线程进行编码,然后,在控制器中初始化H264视频队列和PCM的音频队列,并调用上面的Muxer模块的初始化方法。

如果初始化成功的话,就应该在启动音频的采集和编码模块,最后再启动视频的采集和编码模块(因为有可能出现输出通道建立不成功的情况,所以先初始化Muxer模块,再初始化编码模块)。但是如果初始化失败的话,就把最H264视频队列和PCM音频队列销毁掉。代码如下:

PacketPool* packetPool = PacketPool::GetInstance();
packetPool->initRecordingVideoPacketQueue();
packetPool->initAudioPacketQueue(audioSampleRate);
packetPool->initAudioPacketQueue();
videoPacketConsumerThread = new VideoPacketConsumerThread();
int initCode = videoPacketConsumerThread->init(videoPath,
        videoWidth, videoheight, videoFrameRate, videoBitRate,
        audioSampleRate,audioChannels, audioBitRate,
        "libfdk_aac");
if(initCode >= 0){
    videoPacketConsumerThread->startAsync();
    //Start Producer
} else{
    packetPool->destoryRecordingVideoPacketQueue();
    packetPool->destoryAudioPacketQueue();
    packetPool->destoryAudioPacketQueue();
}

如果初始化成功,就启动音频采集模块,然后启动音频编码线程,接着启动视频编码模块,这样整个系统就运行起来了。来看一下具体的实现代码:

audioRecorder->start();
audioEncoder->start();
videoScheduler->startEncoding();

此时我们就可以看到:

  • 音频采集线程不断地将声音采集到了PCM队列中,然后音频编码线程不断地从PCM队列中取出数据并进行编码,将编码之后的AAC数据送到AAC队列中;
  • 视频采集线程不断地将预览画面采集下来,然后丢给了视频编码线程进行编码,最终编码为H264数据并送到H264的队列中;
  • 而最开始启动的Mux模块,会不断地从这两个队列(AAC队列与H264队列)中取出AAC的音频帧和H264的视频帧,然后封装到MP4的Container中,最终输出到本地磁盘的文件中。

当停止录制的时候,首先会停掉生产者部分,也就是停掉视频的编码,然后停掉音频的编码,接下来停掉音频的采集、视频的采集,最后停掉Mux模块,这样就可以结束整个录制过程了。

videoScheduler->stopEncoding();
audioEncoder->stop();
audioRecorder->stop();
videoPacketConsumerThread->stop();

最后我们终于完成了整个项目。启动这个程序之后,点击录制按钮,进入预览界面,我们可以找一个合适的画面,选择开始录制,你可以先做个自我介绍,然后选择一个伴奏,唱一首歌曲,然后点击停止录制,最终把我们生成的MP4文件导出,播放这个MP4文件,就可以看到我们刚才的表演了。

小结

最后,我们可以一起来回顾一下,本节课我们重点讲解了Muxer模块与中控系统,其中Muxer模块需要单独开辟一个线程来完成封装与IO的操作,这里面的重点是转换音频的AAC和视频的H264的封装格式。

  • 对于音频,需要将ADTS格式转换为SDIF的封装格式,直接使用FFmpeg提供的adtsToASC的bitstreamFilter即可,并且需要利用ADTS的头把音频编码器AVCodecContext的extradata部分填充好。
  • 对于视频,需要将Annex-B格式的转换为AVCC的封装格式,直接手动将startCode部分替换为bufferSize,并且需要利用sps和pps的信息把视频编码器AVCodecContext的extrandata部分填充好。

我们还讲解了中控系统,在中控系统中,我们需要把音频采集、视频采集、音频编码、视频编码、Muxer模块以及伴奏解码与播放这些组件组合起来,控制好它们的生命周期,让整个视频录制器有机地运转起来。

思考题

经过这节课的学习,你也终于收获了自己的学习成果,看到了一个录制出来的MP4文件,但是细心的你可能会发现几个问题。

  • 我的声音听起来特别干,能不能给修饰一下呢?
  • 我脸上的痘好明显啊,能不能做个美颜呢?

你可以思考一下,如何在我们的录制器项目中完成这些功能?欢迎在评论区中告诉我你的答案,也欢迎你把这节课分享给更多对音视频感兴趣的朋友,我们一起交流、共同进步。下节课再见!

精选留言(2)
  • peter 👍(1) 💬(1)

    请教老师几个问题啊: Q1:init方法的参数中没有“视频编码格式”。 文中对于init方法的解释中提到“包括视频宽、高、帧率以及比特率、视频编码格式(默认为 H264 格式”。 但是,init方法原型中并没有编码格式的参数。笔误吗? Q2:视频流的上下文中,time_base.den和time_base_num是什么意思? Q3:音频流的上下文中,为什么没有关于时间的设置? Q4:音频流的codec_name是怎么确定的? avcodec_find_encoder_by_name(codec_name); 其中的codec_name是怎么确定的? Q5:前面曾经请教过老师“混音”的问题。这个词是我自己想的,是不是这个术语用得不对?其实,我说的“混音”,就是指将两个音频叠加在一起,播放出来后能同时听到两个声音。这种情况,通用的术语叫什么? “合成”吗?FFmpeg有这个功能吗?

    2022-09-12

  • xuyong 👍(0) 💬(1)

    请教老师一个问题,实际产品中。Android硬编硬解多,还是软编软解多。你们的产品是怎么做的?

    2022-09-16