从QQ音乐开发,探讨如何利用腾讯云SDK在直播中加入视频动画

2018-10-11 16:47:25 浏览数 (1)

看着精彩的德甲赛事,突然裁判一声口哨,球赛断掉了,屏幕开始自动播放“吃麦趣鸡盒,看德甲比赛”的视频广告

那么问题来了,如何在直播流中,无缝的插入点播视频文件呢?

本文介绍了QQ音乐基于腾讯云AVSDK,实现互动直播插播动画的方案以及踩过的坑。

01

从产品经理给的需求说起

“开场动画?插播广告?”

不久之前,产品同学说我们要在音视频直播中,加一个开场动画。

要播放插播动画,怎么做呢?对于视频直播来说,当前直播画面流怎么处理?对于音频来说,又怎么输入一路流呢?

02

梳理技术方案

互动直播的方式,是把主播的画面推送到观众面前,而主播端的画面,既可以来自摄像头采集的数据,也可以来自其它的输入流。那么如果腾讯云的AVSDK能支持到播放输入流,就能通过在主播端本地解码一个视频文件,然后把这路流的数据推到观众端的方式,让所有的角色都能播放插播动画了。幸运的是,腾讯云AVSDK可以支持到这个特性,具体的方法有下面两种:

第一种:替换视频画面

代码语言:javascript复制
/*!
 @abstract      对本地采集视频进行预处理的回调。
 @discussion    主线程回调,方面直接在回调中实现视频渲染。
 @param         frameData       本地采集的视频帧,对其中data数据的美颜、滤镜、特效等图像处理,会回传给SDK编码、发送,在远端收到的视频中生效。
 @see           QAVVideoFrame
 */
- (void)OnLocalVideoPreProcess:(QAVVideoFrame *)frameData;

主播侧本地在采集到摄像头的数据后,在编码上行到服务器之前,会提供一个接口给予业务侧做预处理的回调,所以,对于视频直播,我们可以利用这个接口,把上行输入的视频画面修改为要插播进来动画的视频帧,这样,从观众角度看,被插播了视频动画。

第二种:使用外部输入流

代码语言:javascript复制
/*!
 @abstract      开启外部视频采集功能时,向SDK传入外部采集的视频帧。
 @return        QAV_OK 成功。
                QAV_ERR_ROOM_NOT_EXIST 房间不存在,进房后调用才生效。
                QAV_ERR_DEVICE_NOT_EXIST 视频设备不存在。
                QAV_ERR_FAIL 失败。

 @see           QAVVideoFrame
 */
- (int)fillExternalCaptureFrame:(QAVVideoFrame *)frame;

最开始时,我错误的认为,仅仅使用第二种方式就能够满足同时在音视频两种直播中插播动画的需求,但是实际实践的时候发现,如果要播放外部输入流,必须要先关闭摄像头画面。这个操作会引起腾讯云后台的视频位切换,并通过下面这个函数通知到观众端:

代码语言:javascript复制
/*!
 @abstract      房间成员状态变化通知的函数。
 @discussion    当房间成员发生状态变化(如是否发音频、是否发视频等)时,会通过该函数通知业务侧。
 @param         eventID         状态变化id,详见QAVUpdateEvent的定义。
 @param         endpoints       发生状态变化的成员id列表。
 */
- (void)OnEndpointsUpdateInfo:(QAVUpdateEvent)eventID endpointlist:(NSArray *)endpoints;

视频位短时间内的切换,会导致一些时序上的问题,跟SDK侧讨论也认为不建议这样做。最终,QQ音乐采用了两个方案共存的方式。

03

视频格式选型

对于插播动画的视频文件,如果考虑到如果需要支持流式播放,码率低,高画质,可以使用H264裸流 VideoToolBox硬解的方式。如果说只播放本地文件,可以采用H264编码的mp4 AVURLAsset解码的方式。因为目前还没有流式播放的需求,而设计同学直接给到的是一个mp4文件,所以后者则看起来更合理。笔者出于个人兴趣,对两种方案的实现都做了尝试,但是也遇到了下面的一些坑,总结一下,希望能让其它同学少走点弯路:

1.分辨率与帧率的配置

视频的分辨率需要与腾讯云后台的SPEAR引擎配置中的上行分辨率一致,QQ音乐选择的视频上行配置是960x540,帧率是15帧。但是实际的播放中,发现效果并不理想,所以需要播放更高分辨率的数据,这一步可以通过更换AVSDK的角色RoleName来实现,这里不做延伸。

另外一个问题是从摄像头采集上来的数据,是下图的角度为1的图像,在渲染的时候,会默认被旋转90度,在更改视频画面时,需要保持两者的一致性。摄像头采集的数据格式是NV12,而本地填充画面的格式可以是I420。在绘制时,可以根据数据格式来判断是否需要旋转图像展示。

2.ffmpeg 转h264裸流解码问题

从iOS8开始,苹果开放了VideoToolBox,使得应用程序拥有了硬解码h264格式的能力。具体的实现与分析,可以参考《iOS-H264 硬解码》这篇文章。因为设计同学给到的是一个mp4文件,所以首先需要先把mp4转为H264的裸码流,再做解码。这里我使用ffmpeg来做转换:

代码语言:javascript复制
ffmpeg -i test.mp4 -codec copy -bsf: h264_mp4toannexb -s 960*540 -f h264 output.264

其中,annexb就是h264裸码流Elementary Stream的格式。对于Elementary Stream,sps跟pps并没有单独的包,而是附加在I帧前面,一般长这样:

代码语言:javascript复制
00 00 00 01 sps 00 00 00 01 pps 00 00 00 01 I 帧

VideoToolBox的硬解码一般通过以下几个步骤:

代码语言:javascript复制
1. 读取视频流
2. 找出sps,pps的信息,创建CMVideoFormatDescriptionRef,传入下一步作为参数
3. VTDecompressionSessionCreate:创建解码会话
4. VTDecompressionSessionDecodeFrame:解码一个视频帧
5. VTDecompressionSessionInvalidate:释放解码会话

但是对上面转换后的裸码流解码,发现总是会遇到解不出来数据的问题。分析转换后的文件发现,转换后的格式并不是纯码流,而被ffmpeg加入了一些无关的信息:

但是也不是没有办法,可以使用这个工具H264Naked来找出二进制文件中的这一段数据一并删掉。再尝试,发现依然播放不了,原因是在上面的第3步解码会话创建失败了,错误码OSStatus = -5。很坑的是,这个错误码在OSStatus.com中无法查到对应的错误信息,通过对比好坏两个文件的差异发现,解码失败的文件中,pps 前面的 startcode并不是3个0开头的,而是这样子

代码语言:javascript复制
00 00 00 01 sps 00 00 01 pps 00 00 00 01 I 帧

但是实际上,通过查看h264的官方文档,发现两种形式都是正确的

而我只考虑了第一种情况,却忽略了第二种,导致解出来的pps数据错了。通过手动插入一个00,或者解码器兼容这种情况,都可以解决这个问题。但是同时也看出,这种方式很不直观。所以也就引入了下面的第二种方法。

3. AVAssetReader 解码视频

使用AVAssetReader解码出yuv比较简单,下面直接贴出代码:

代码语言:javascript复制
    AVURLAsset *asset = [AVURLAsset URLAssetWithURL:[[NSURL alloc] initFileURLWithPath:path] options:nil];
    NSError *error;
    AVAssetReader* reader = [[AVAssetReader alloc] initWithAsset:asset error:&error];
    NSArray* videoTracks = [asset tracksWithMediaType:AVMediaTypeVideo];
    AVAssetTrack* videoTrack = [videoTracks objectAtIndex:0];

    int m_pixelFormatType = kCVPixelFormatType_420YpCbCr8Planar;
    NSDictionary* options = [NSDictionary dictionaryWithObject:[NSNumber numberWithInt: (int)m_pixelFormatType] forKey:(id)kCVPixelBufferPixelFormatTypeKey];
    AVAssetReaderTrackOutput* videoReaderOutput = [[AVAssetReaderTrackOutput alloc] initWithTrack:videoTrack outputSettings:options];
    [reader addOutput:videoReaderOutput];
    [reader startReading];

    // 读取视频每一个buffer转换成CGImageRef
    dispatch_async(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0), ^{ 
   while ([reader status] == AVAssetReaderStatusReading && videoTrack.nominalFrameRate > 0) {

      CMSampleBufferRef sampleBuff = [videoReaderOutput copyNextSampleBuffer];
      // 对sampleBuff 做点什么
    });

这里只说遇到的坑,有的mp4视频解码后绘制时会有一个迷之绿条,就像下面这个图

这是为什么,代码实现如下所示,我们先取出y分量的数据,再取出uv分量的数据,看起来没有问题,但是这实际上却不是我们的视频格式对应的数据存储方式。

代码语言:javascript复制
// 首先把Samplebuff转成cvBufferRef, cvBufferRef中存储了像素缓冲区的数据
CVImageBufferRef cvBufferRef = CMSampleBufferGetImageBuffer(sampleBuff);
// 锁定地址,这样才能之后从主存访问到数据
CVPixelBufferLockBaseAddress(cvBufferRef, kCVPixelBufferLock_ReadOnly);
// 获取y分量的数据
unsigned char *y_frame = (unsigned char*)CVPixelBufferGetBaseAddressOfPlane(cvBufferRef, 0);
// 获取uv分量的数据
unsigned char *uv_frame = (unsigned char*)CVPixelBufferGetBaseAddressOfPlane(cvBufferRef, 1);

这份代码cvBufferRef中存储数据格式应该是:

代码语言:javascript复制
typedef struct CVPlanarPixelBufferInfo_YCbCrPlanar   CVPlanarPixelBufferInfo_YCbCrPlanar;
struct CVPlanarPixelBufferInfo_YCbCrBiPlanar {
  CVPlanarComponentInfo  componentInfoY;
  CVPlanarComponentInfo  componentInfoCbCr;
};

然而第一份代码中,使用的pixelFormatType是kCVPixelFormatType_420YpCbCr8Planar,存储的数据格式却是:

代码语言:javascript复制
typedef struct CVPlanarPixelBufferInfo         CVPlanarPixelBufferInfo;
struct CVPlanarPixelBufferInfo_YCbCrPlanar {
  CVPlanarComponentInfo  componentInfoY;
  CVPlanarComponentInfo  componentInfoCb;
  CVPlanarComponentInfo  componentInfoCr;
};

也就是说,这里应该把yuv按照三个分量来解码,而不是两个分量。 实现正确的解码方式,成功消除了绿条。

至此,遇到的坑就都踩完了,效果也不错。

最后,希望这篇文章能够对你有所帮助,在直播开发上,少走点弯路

0 人点赞