XXXLIVE系统开发小纪

来源:互联网 发布:js获取单选框选中属性 编辑:程序博客网 时间:2024/05/21 13:58

从0独自写完整个录制系统,连写带学,正则统计2000行,看来还是有一点代码量,记录一下遇到的坑。

大坑::FFMPEG

首先整个录制系统的核心就是音视频处理。目前市面上所有的音视频处理基本都用到了开源库FFMPEG。我们直接使用这个库入手看起来也是比较明智的。
FFMPEG的大多数知识都来源于已经去世的雷博士的博客http://blog.csdn.net/leixiaohua1020?viewmode=contents。不得不说,雷博在这个方向上为太多的人做了入门介绍、翻译的工作。万分感谢!
基本上所有的例程都是一个文件写到尾,一个函数括到头。但对于我们需要N(N>3)路1080P实时编码、录制、预览,还包括一些小的功能需求,例如旋转、缩放、水印。显然这样的代码不能满足要求。于是我找了一些开源代码,最终锁定现在最火的OBS开源串流工具,OBS中也附带了录制的功能,但不能完全满足我们的需求。OBS中也使用了FFMPEG作为编解码的工具,其中分离分解了FFMPEG的数据结构,方便多线程编码,这样的思想帮我实现了最终的功能。


从一开始了解音视频后,并没有直接去了解这些基础性的东西,而是直接对例程更改,实现了一些简单的功能。但最后发现要想完全实现这个系统,还是得对这些东西进行一些系统的了解。

视频像素格式
从原来的基础认知中,以为.MP4 | .MKV | .RMVB这些就是视频的格式。其实并不是,准确的来说,这些只是容器。用于存放一些不同通道的数据,例如视频、音频、字幕等等。而现在我理解的真正的视频是以一定规范存储的编码的流数据。例如H.264码流。对于视频原始码流,是以YUV或者RGB像素格式存放的原始的视频帧,但很无奈的是,这样的数据十分庞大,相当于每秒1920*1080*32(RGBA)*30(帧) = 1.9G字节的数据。这样庞大的数据是根本无法存储的。所以在此颜色之上,出现了根据一些压缩手段。这就是我们一般说的编码Encoder
对于从摄像头获取到的数据,我们先通过解码器将其解码为特定的原始码流,然后送入编码器进行编码,最后存储在文件中。在这里,我遇到了一些问题。
一开始并不能理解像素格式的含义还有跟编码器的关系。对于H.264编码器,他只接受特定的某几个像素格式,我们这里用了比较大众化的YUV420P,并非我所直观了解到的以RGB颜色通道存储的原始码流,这给我开始的想法带来了很多困扰。
YUV格式是原来用于兼容彩色和黑白电视机的通用像素格式,他把亮度信息Y和两个色差信号U\V分开储存。节省了带宽,而且对于黑白电视机来说,只需要播放Y分量,放弃掉U\V分量即可播放。对于YUV420P,每4个像素点的亮度信息共享一堆UV信息,这样也减少了数据量。

一开始理所当然的将解码后的原始码流直接送入编码器,发现并不能编码。就是这样像素格式的问题。理解以后,利用FFMPEG自带的转换接口即可实现完整编码。这是我在入坑后解决的第一个重大问题。

音频采样格式
在解决了这个问题后,随之而来的就是音频编码的问题。同样的,原始音频码流也有特定的采样格式。我大学中接触到的数电模电还有信号分析中的一些记忆片段帮助我迅速解决了这个问题。音频采样格式有

    AV_SAMPLE_FMT_U8,          ///< unsigned 8 bits    AV_SAMPLE_FMT_S16,         ///< signed 16 bits    AV_SAMPLE_FMT_S32,         ///< signed 32 bits    AV_SAMPLE_FMT_FLT,         ///< float    AV_SAMPLE_FMT_DBL,         ///< double    AV_SAMPLE_FMT_U8P,         ///< unsigned 8 bits, planar    AV_SAMPLE_FMT_S16P,        ///< signed 16 bits, planar    AV_SAMPLE_FMT_S32P,        ///< signed 32 bits, planar    AV_SAMPLE_FMT_FLTP,        ///< float, planar    AV_SAMPLE_FMT_DBLP,        ///< double, planar    AV_SAMPLE_FMT_S64,         ///< signed 64 bits    AV_SAMPLE_FMT_S64P,        ///< signed 64 bits, planar

这样一些,代表的意义也在注释中比较清楚了。当时唯一不能理解的就和视频一样,对于带P的格式没有思路。其实P就是planar,平面数据。他把不同的颜色信号,或者声道分离,单独储存。例如左右声道:当不是平面数据时,在内存中是以LRLRLRLRLRL这样的形式储存。而平面数据是以LLLLLLRRRRRR这样的形式储存。也就不难理解了。于是我天真的认为,只要像视频一样,把原始音频码流PCM通过FFMPEG自带的转换接口转换一下就可以完成音频的编码。在这里我又遇到了一个困扰我比较久的问题。

[音频编码]
当我把转换后的原始码流送入解码器后,又报错了
nb_samples (22050) != frame_size (1152) (avcodec_encode_audio2)
在这个问题上,我花了很多的时间。百度基本不管用,只能硬啃谷歌。根本问题在于没有理解音频帧的含义。理所当然的把FFMPEG送过来的音频数据当成了一帧音频帧去处理。
现在我们知道FFMPEG接口获取到的音频PACKET可能是多帧音频帧,所以在解码之后,他的数据可能是多帧音频帧连在一起的一块缓冲区。所以在将其送入解码器之前,我们需要进行切割。例如,当我们用44.1KHZ采样率时,那么一秒的数据量则为44.1个采样点。那么对于一帧AAC,他为1024个采样点。那么一帧音频的时间为 1000ms / 44100 * 1024 = 23.2毫秒。所以音频帧并非和视频帧一样的时长,故在后面我们会说pts/dts对于音视频流同步播放的问题。
那么我们理解了音频帧,就可以很方便的使用FFMPEG中提供的fifo队列来保存送过来的多帧原始音频码流,然后每次取出其中的编码器需要大小的包,组成frame送入编码器,即可得到编码后的音频帧。(这里编码器可能返回失败,返回码表达的意思是需要下一帧音频,要注意检测返回值)

pts/dts
理解了以上,就引出了一个大问题。那么在将视频帧和音频帧送入最后的输出流时,需要的pts/dts是什么玩意儿?
FFMPEG里的时间戳的概念一开始看的确比较晦涩。他时间戳的定义为两个64位整数。其实是以分子分母的表达形式来表达精确地时间。例如30帧的原始视频,给出的time_base为 {33333,10000000}。那么 其实他是说 一帧的时间为 33333/10000000 * 1000ms = 33.3毫秒 所以这个视频为30帧(1000ms / 33.33 = 30)。那之前又说一帧AAC为23.2ms,那输出流怎么去同步他们的播放进度呢?
pts/dts就是这样的一个时间戳。而且比较难以捉摸的是,FFMPEG在从解码到编码到写入的过程中,他们的时基并不完全相同,我们自己编码需要在各种时基中转换来转换去。ffmpeg提供了相关函数,可以方便的处理时基转换,而且解决溢出问题(av_rescale_q)。例如:

            p->packet->pts = av_rescale_q(m_VideoFrame++, mEnCoderCtx->time_base, { 1, AV_TIME_BASE });            p->packet->dts = p->packet->pts;            p->packet->duration = av_rescale_q(1, mEnCoderCtx->time_base, { 1, AV_TIME_BASE });

对于这样一个编码后的包,我们将其的pts和dts由解码后的帧数来决定。将帧数由解码器的时基转化为FFMPEG的基础时基,最后写入时再进行转化操作。


说完这些发现其实整个编解码流程已经说完了。其实目前整个项目中用到的比较核心的我凭记忆来说也就这么一点东西。但是其中的坑坑洼洼也只有自己上手了才能一个一个填平。

在写完多路多线程编解码后,随之而来的一个问题是,如何将我们的实时视频帧和麦克风声音大小,画到GUI上进行显示。由于最多支持16路1080P的视频,这样的绘制工作无疑是相当繁重的。在一开始,我尝试了使用QT自带的绘制RGB直接绘制到QLABLE上,但很遗憾,不知道是我使用的问题还是QT本身的问题,这样的方式在测试4路1080P的时候,CPU已经不堪重负(I7 6700K OC 4.5GHZ,CPU光实时预览吃了50%)。如果再加上繁重的H.264实时编码线程,就会出现漏帧掉帧。所以,我决定利用显卡强大的绘图性能完成视频帧的绘制。

在选择上,我们可以使用跨平台的opengl和win专属的dx来绘图。因为原来玩游戏的原因或者是说对微软的喜爱,我毫不犹豫的选择了dx(暂时跟跨平台说再见)。而且在黄博的博客中有这样的例子,发现dx基本上CPU处于空载状态,也就是说在WIN平台上我看来dx更有效率。在dx绘制中就有一个小问题需要注意:
dx原生支持yuv格式的显示,但他是使用YV12格式,这样的格式按道理来说与YUV420P相当相似。除了他的分布中,UV的顺序颠倒了一下,YV12格式的顺序是 YVU。但这里我目前还没有彻底弄明白为什么当我旋转FFMPEG的YUV420P格式以后,要将W加上8个字节。

    if (W > H)    {        for (int i = 0;i < H;i++)            memcpy(pDest + i * stride, pSrc + i * W, W); //Y分量        for (int i = 0;i < H / 2;i++)            memcpy(pDest + H * stride + i * stride / 2, p2 + i * W / 2, W / 2);        for (int i = 0;i < H / 2;i++)            memcpy(pDest + H * stride + H / 4 * stride + i * stride / 2, p1 + i * W / 2, W / 2);    }    else    {        //dont fuck fix!        for (int i = 0;i < H;i++)            memcpy(pDest + i * stride, pSrc + i * (W + 8), W); //Y分量        for (int i = 0;i < H / 2;i++)            memcpy(pDest + H * stride + i * stride / 2, p2 + i * (W + 8) / 2, W / 2);        for (int i = 0;i < H / 2;i++)            memcpy(pDest + H * stride + H / 4 * stride + i * stride / 2, p1 + i * (W + 8) / 2, W / 2);    }

留给以后再去研究吧。这样的话,实时预览也算是解决啦。后来又加了等比拉伸等一些小的细节让显示更加舒服。


然后我们添加了两个ffmpeg 已有的Filter,其中之一用来把视频打上我们自己的logo,第二个用来旋转视频,比如摄像头是横的,那么我们在软件中就可以操作将其旋转90度来方便观看。

\\movie=Logo.png[wm];[in][wm]overlay=15:15[out]\\[in]rotate=%s:bilinear=0:ow=%s:oh=%s[out]

最后,我想说一点多线程的事情。
在整个系统中,目前的设计和实现是这样的,每一个摄像头 或者麦克风有

  1. 虚拟设备线程,该线程是所有处理的起点,负责从设备中读出packet,并做适当的处理(解码)。而且将视频帧或多帧音频帧放入我们的数据结构中,并填写后续需要用到的数据。对于视频,我们还识别了其像素格式,若不符合则转为YUV420P以便后期显示及编码。
  2. filter线程,该线程判断由虚拟设备线程送入的数据,并进行相应的处理。如旋转和水印。因为某些filter也是一个非常消耗资源的动作,我们把他放入单独的线程中解耦。
  3. Dxshow线程,该线程接受由filter处理过后的原始码流,并将其投射到所有注册到摄像头的的dx窗口中显示。这样的设计能减少GUI界面与处理逻辑的耦合性。
  4. 编码线程,与Dxshow线程一样,接受由filter处理过后的原始码流,并根据当前系统状态判断是否需要编码。编码在整个录制过程中是非常重要的一部。
  5. 写入线程,该线程接受编码后的码流,并根据用户指定的信息来写入文件。
  6. Mux线程,该线程将我们实时录下来的数据进行一些混合,例如音视频,音频之间等等。

由于线程间的控制和同步也是一件非常头疼的事情,这里就不多详述了。等以后再回来看这样的设计是好是坏吧哈哈哈哈,现在回想起来整个由我独立完成的系统还是消耗的大量的时间和精力的,但这个过程中得到的自我感觉还是比较多的。特别是在多线程和音视频这一块儿。

偷偷的开源:
GIT:

0 0
原创粉丝点击