你好,我是展晓凯。今天我们来一起学习视频录制器的最后一部分,让它跑起来。
[上一节课]我们一起实现了视频录制器中的底层模块,现在音频模块已经把音频编码为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这个模块所承担的职责,来看一下初始化方法的定义:
1 | int init(char* videoOutputURI, int videoWidth, |
初始化方法的参数比较多。第一部分就是输出的文件路径;第二部分是视频流的参数,包括视频宽、高、帧率以及比特率、视频编码格式(默认为H264格式);第三部分就是音频流的参数,包括音频的采样率、声道数、比特率,以及音频编码器的名称。
这个初始化的方法内部核心流程是,构造一个Container(对应FFmpeg中的结构体类型是AVFormatContext),根据上述的视频参数配置好一路视频流(AVStream)添加到这个Container中,然后根据上述的音频参数再配置一路音频流(AVStream)添加到Container中。
下面来看一下具体实现,第一步先注册FFmpeg里面的所有封装格式、编解码器以及网络配置开关(如果需要将视频流推送到网络上的话)。
1 | avcodec_register_all(); |
然后根据输出目录来构造一个Container,即构造一个AVFormatContext类型的结构体,其实这个结构体就是FFmpeg中使用libavformat模块的入口。
1 | AVFormatContext* oc; |
接下来构造一路视频流,并加入这个Container里。先使用常量AV_CODEC_ID_H264找出H264的编码器,然后在Container里增加一路H264编码的视频流,并找出这路流的编码器上下文,对这个上下文的属性依次赋值,就构造好了这路视频流。代码如下:
1 | AVCodec *video_codec = avcodec_find_encoder(AV_CODEC_ID_H264); |
接下来构造一路音频流,整个过程和视频流的构建非常类似,不同的是,编码器是通过传递进来的编码器名称寻找的,代码如下:
1 | AVCodec *audio_codec = avcodec_find_encoder_by_name(codec_name); |
完成音频流的添加之后,这里需要给音频编码器上下文填充extradata属性,这也是前面我们讲解的AAC封装格式的实际运用了。还记得之前编码AAC的时候要在编码出来的数据前面加上ADTS的头吗?其实在ADTS头部信息可以提取出编码器的Profile、采样率以及声道数的信息。在MP4文件中AAC是ADIF格式的,而在FFmpeg的实现中是在全局的extradata中配置这些信息。那么,我们来看一下如何为FFmpeg的音频编码器上下文来设置这个extradata。
1 | int profile = 2; //AAC LC |
你可能会有疑问,视频流的这个extradata变量该设置什么呢?视频的编码器中的变量设置稍后会讲解,因为在视频流中的这个变量存放的是SPS和PPS的信息,是由编码器在编码过程的第一步输出的,所以放在封装和输出部分来讲解。音频这里我们也要配置一个音频格式转换的滤波器,就是ADTS到ADIF格式的转换器。
1 | bsfc = av_bitstream_filter_init("aac_adtstoasc"); |
上述步骤完成之后,说明我们的Container(封装格式)已经初始化好了,然后就是打开文件的连接通道,可调用FFmpeg的Protocol层来完成操作,代码如下:
1 | AVIOInterruptCB int_cb = { interrupt_cb, this }; |
如果avio_open2函数的返回值大于等于0,就设置isConnected变量为true,代表已经成功地打开了文件输出通道。上述代码中需要注意的是,我们需要配置一个超时回调函数进去,这个回调函数主要是给FFmpeg的协议层用的,返回1则代表结束I/O操作,返回0则代表继续I/O操作,超时回调函数实现如下:
1 | static int interrupt_cb(void* ctx) { |
上述代码表示如果当前时间超过了封装上一帧的时间(15s),就终止协议层的I/O操作,当然,每次封装一帧之后就要更新latestFrameTime这个变量。这个回调函数的配置是非常重要的,特别是在我们和网络打交道的时候。
初始化方法到这里就配置结束了,接下来看实际的封装和输出。
封装和输出
构建好了这个Container之后,再不断地将音频帧和视频帧交错地封装进来,然后通过输出通道写出到文件或者网络中。先来看一下主体的流程:
1 | int ret = 0; |
由于音视频一般是交错存储的,也就是存储一帧视频后,再存储一段时间的音频(不一定是一帧音频,这要看视频的fps是多少,因为代码中是按照时间比较来决定写入的),之后再存储一帧视频,所以在某一个时间点是要封装音频还是封装视频,是由当前两路流上已经封装的时间戳来决定的。代码显示先获取两路流上当前的时间戳信息,然后进行比较,封装和输出时间戳比较小的那一路流,并且更新latestFrameTime这个变量来辅助前面配置的超时回调函数判断。
接下来分别看一下封装和输出音频以及视频流(write_audio/video_frame)的实现,先来看音频流部分:
1 | int ret = AUDIO_QUEUE_ABORT_ERR_CODE; |
封装音频流时,先从AAC的音频队列中取出一帧音频帧,然后取出这个AAC音频帧的时间戳信息,存储到全局变量中的audioStreamTimeInsecs中,作为我们要写入音频这路流的时间戳信息,以便编码之前取出音频流中编码到的时间信息。然后将这个AAC的Packet转换成一个AVPacket类型的结构体,代码如下:
1 | AVPacket pkt = { 0 }; |
接着将这个pkt作为调用bitStreamFilter转换的输入Packet,经过转换之后,这个ADTS封装格式的AAC就变成了一个ADIF封装格式的AAC了,之后就可以通过输出通道输出了,具体代码如下:
1 | AVPacket newpacket; |
到这里,我们就把从队列中取得的一帧ADTS封装格式的AAC写到Container中的音频轨道中了。
接下来看一下视频流是怎么封装和输出的,首先填充视频编码器上下文中的extradata,这一点非常重要,否则等这个视频进行播放的时候,解码器无法正确初始化就不能够正确解码视频。先来看取出H264队列中的视频帧,部分代码:
1 | VideoPacket *h264Packet = NULL; |
接下来看一下如何正确填充extradata,[第20节课]我们已经把SPS和PPS的信息拼接起来封装为一帧H264数据放到视频帧队列中了,所以这里在拿出了H264帧之后首先判定是否是SPS类型的帧,判断规则是取出这一帧H264数据的index为4的下标,按位与上0x1F得到NALU Type,然后和H264中预定义的类型进行比较,代码如下:
1 | uint8_t* outputData = (uint8_t *) ((h264Packet)->buffer); |
至于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中,代码如下:
1 | AVCodecContext *c = videoStream->codec; |
上述拼接规则是我在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写出到文件或者网络流中,代码如下:
1 | int ret = avformat_write_header(oc, NULL); |
当write header成功的时候,将变量isWriteHeaderSuccess设置为true,方便后续在实现销毁操作时用来判断是否需要执行write trailer的操作。
接下来就是真正地将视频帧封装并且输出了,而这里最重要的就是视频帧封装格式的转换,在音频的封装过程中,我们将ADTS格式的转换为ADIF格式的,使用了ADTS到ASC的转换过滤器,而在这里是没有这样的转换过滤器可供我们直接使用的,所以需要手动转换,这个转换过程也很简单,把H264视频帧起始的StartCode部分替换为这一帧视频帧的大小即可,代码如下:
1 | pkt.data = outputData; |
如上面代码所示,代表帧大小的这个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,因为解码器要按照是否是关键帧来构造解码过程中的参考队列。至此我们的封装工作就结束了。接下来就是输出部分,其实输出和音频流的输出是一样的,代码如下:
1 | av_interleaved_write_frame(oc, &pkt); |
封装和输出视频帧结束后,就可以再回到Mux模块的主体流程了,主体流程会不断地进行循环,直到音频或者视频的封装和输出函数返回小于0的值则结束,但何时返回小于0的值呢?
其实就是在获取AAC队列和H264队列的时候,如果这个队列被abort掉了,那么就返回小于0的值,那这两个队列又是何时被abort掉的呢?其实就是在停止整个Mux流程的时候。停止Mux模块如下,首先会abort掉这两个队列,然后等待主体Mux流程的线程停止,之后调用销毁资源方法。
销毁资源
对于销毁资源,首先要做的是判断输出通道是否打开,并且确定它是否做了writeHeader操作,如果做了的话,就要执行write_trailer操作,然后设置好duration,代码如下:
1 | if (isConnected && isWriteHeaderSuccess) { |
这里有一点比较重要,如果我们没有write header而又在销毁的时候调用了write trailer,那么FFmpeg程序会直接崩溃,所以这里使用了一个布尔变量来保证write header和write trailer的成对出现。由于Mux模块不做编码工作,所以没有打开过任何编码器,也就无需关闭编码器,但是对于音频来讲,还使用了一个bitStreamFilter,所以需要关闭它。
1 | av_bitstream_filter_close(bsfc); |
最后关闭掉输出通道,释放掉整个AVFormatContext。
1 | if (isConnected) { |
这样我们就完成了整个Muxer模块,接下来我再讲解一下中控系统,将各个模块串联起来,完成我们整个视频录制器项目。
中控系统
最后我们需要写一个控制器来把所有的模块串联起来,从而完成整个项目。在进入录制视频阶段之前,就已经有了视频的预览界面,即已经启动了视频采集模块,只不过不会启动编码线程进行编码,然后,在控制器中初始化H264视频队列和PCM的音频队列,并调用上面的Muxer模块的初始化方法。
如果初始化成功的话,就应该在启动音频的采集和编码模块,最后再启动视频的采集和编码模块(因为有可能出现输出通道建立不成功的情况,所以先初始化Muxer模块,再初始化编码模块)。但是如果初始化失败的话,就把最H264视频队列和PCM音频队列销毁掉。代码如下:
1 | PacketPool* packetPool = PacketPool::GetInstance(); |
如果初始化成功,就启动音频采集模块,然后启动音频编码线程,接着启动视频编码模块,这样整个系统就运行起来了。来看一下具体的实现代码:
1 | audioRecorder->start(); |
此时我们就可以看到:
- 音频采集线程不断地将声音采集到了PCM队列中,然后音频编码线程不断地从PCM队列中取出数据并进行编码,将编码之后的AAC数据送到AAC队列中;
- 视频采集线程不断地将预览画面采集下来,然后丢给了视频编码线程进行编码,最终编码为H264数据并送到H264的队列中;
- 而最开始启动的Mux模块,会不断地从这两个队列(AAC队列与H264队列)中取出AAC的音频帧和H264的视频帧,然后封装到MP4的Container中,最终输出到本地磁盘的文件中。
当停止录制的时候,首先会停掉生产者部分,也就是停掉视频的编码,然后停掉音频的编码,接下来停掉音频的采集、视频的采集,最后停掉Mux模块,这样就可以结束整个录制过程了。
1 | videoScheduler->stopEncoding(); |
最后我们终于完成了整个项目。启动这个程序之后,点击录制按钮,进入预览界面,我们可以找一个合适的画面,选择开始录制,你可以先做个自我介绍,然后选择一个伴奏,唱一首歌曲,然后点击停止录制,最终把我们生成的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文件,但是细心的你可能会发现几个问题。
- 我的声音听起来特别干,能不能给修饰一下呢?
- 我脸上的痘好明显啊,能不能做个美颜呢?
你可以思考一下,如何在我们的录制器项目中完成这些功能?欢迎在评论区中告诉我你的答案,也欢迎你把这节课分享给更多对音视频感兴趣的朋友,我们一起交流、共同进步。下节课再见!