乐趣区

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

欢迎大家前往腾讯云 + 社区,获取更多腾讯海量技术实践干货哦~
本文由腾讯游戏云发表于云 + 社区专栏

看着精彩的德甲赛事,突然裁判一声口哨,球赛断掉了,屏幕开始自动播放“吃麦趣鸡盒,看德甲比赛”的视频广告
那么问题来了,如何在直播流中,无缝的插入点播视频文件呢?
本文介绍了 QQ 音乐基于腾讯云 AVSDK,实现互动直播插播动画的方案以及踩过的坑。
01
从产品经理给的需求说起
“开场动画?插播广告?”
不久之前,产品同学说我们要在音视频直播中,加一个开场动画。

要播放插播动画,怎么做呢?对于视频直播来说,当前直播画面流怎么处理?对于音频来说,又怎么输入一路流呢?
02
梳理技术方案
互动直播的方式,是把主播的画面推送到观众面前,而主播端的画面,既可以来自摄像头采集的数据,也可以来自其它的输入流。那么如果腾讯云的 AVSDK 能支持到播放输入流,就能通过在主播端本地解码一个视频文件,然后把这路流的数据推到观众端的方式,让所有的角色都能播放插播动画了。幸运的是,腾讯云 AVSDK 可以支持到这个特性,具体的方法有下面两种:
第一种:替换视频画面
/*!
@abstract 对本地采集视频进行预处理的回调。
@discussion 主线程回调,方面直接在回调中实现视频渲染。
@param frameData 本地采集的视频帧,对其中 data 数据的美颜、滤镜、特效等图像处理,会回传给 SDK 编码、发送,在远端收到的视频中生效。
@see QAVVideoFrame
*/
– (void)OnLocalVideoPreProcess:(QAVVideoFrame *)frameData;
主播侧本地在采集到摄像头的数据后,在编码上行到服务器之前,会提供一个接口给予业务侧做预处理的回调,所以,对于视频直播,我们可以利用这个接口,把上行输入的视频画面修改为要插播进来动画的视频帧,这样,从观众角度看,被插播了视频动画。
第二种:使用外部输入流
/*!
@abstract 开启外部视频采集功能时,向 SDK 传入外部采集的视频帧。
@return QAV_OK 成功。
QAV_ERR_ROOM_NOT_EXIST 房间不存在,进房后调用才生效。
QAV_ERR_DEVICE_NOT_EXIST 视频设备不存在。
QAV_ERR_FAIL 失败。

@see QAVVideoFrame
*/
– (int)fillExternalCaptureFrame:(QAVVideoFrame *)frame;
最开始时,我错误的认为,仅仅使用第二种方式就能够满足同时在音视频两种直播中插播动画的需求,但是实际实践的时候发现,如果要播放外部输入流,必须要先关闭摄像头画面。这个操作会引起腾讯云后台的视频位切换,并通过下面这个函数通知到观众端:
/*!
@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 音乐选择的视频上行配置是 960×540,帧率是 15 帧。但是实际的播放中,发现效果并不理想,所以需要播放更高分辨率的数据,这一步可以通过更换 AVSDK 的角色 RoleName 来实现,这里不做延伸。
另外一个问题是从摄像头采集上来的数据,是下图的角度为 1 的图像,在渲染的时候,会默认被旋转 90 度,在更改视频画面时,需要保持两者的一致性。摄像头采集的数据格式是 NV12,而本地填充画面的格式可以是 I420。在绘制时,可以根据数据格式来判断是否需要旋转图像展示。

2.ffmpeg 转 h264 裸流解码问题
从 iOS8 开始,苹果开放了 VideoToolBox,使得应用程序拥有了硬解码 h264 格式的能力。具体的实现与分析,可以参考《iOS-H264 硬解码》这篇文章。因为设计同学给到的是一个 mp4 文件,所以首先需要先把 mp4 转为 H264 的裸码流,再做解码。这里我使用 ffmpeg 来做转换:
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 帧前面,一般长这样:
00 00 00 01 sps 00 00 00 01 pps 00 00 00 01 I 帧
VideoToolBox 的硬解码一般通过以下几个步骤:
1. 读取视频流
2. 找出 sps,pps 的信息,创建 CMVideoFormatDescriptionRef,传入下一步作为参数
3. VTDecompressionSessionCreate:创建解码会话
4. VTDecompressionSessionDecodeFrame:解码一个视频帧
5. VTDecompressionSessionInvalidate:释放解码会话
但是对上面转换后的裸码流解码,发现总是会遇到解不出来数据的问题。分析转换后的文件发现,转换后的格式并不是纯码流,而被 ffmpeg 加入了一些无关的信息:

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

而我只考虑了第一种情况,却忽略了第二种,导致解出来的 pps 数据错了。通过手动插入一个 00,或者解码器兼容这种情况,都可以解决这个问题。但是同时也看出,这种方式很不直观。所以也就引入了下面的第二种方法。
3. AVAssetReader 解码视频
使用 AVAssetReader 解码出 yuv 比较简单,下面直接贴出代码:
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 分量的数据,看起来没有问题,但是这实际上却不是我们的视频格式对应的数据存储方式。
// 首先把 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 中存储数据格式应该是:
typedef struct CVPlanarPixelBufferInfo_YCbCrPlanar CVPlanarPixelBufferInfo_YCbCrPlanar;
struct CVPlanarPixelBufferInfo_YCbCrBiPlanar {
CVPlanarComponentInfo componentInfoY;
CVPlanarComponentInfo componentInfoCbCr;
};
然而第一份代码中,使用的 pixelFormatType 是 kCVPixelFormatType_420YpCbCr8Planar,存储的数据格式却是:
typedef struct CVPlanarPixelBufferInfo CVPlanarPixelBufferInfo;
struct CVPlanarPixelBufferInfo_YCbCrPlanar {
CVPlanarComponentInfo componentInfoY;
CVPlanarComponentInfo componentInfoCb;
CVPlanarComponentInfo componentInfoCr;
};
也就是说,这里应该把 yuv 按照三个分量来解码,而不是两个分量。实现正确的解码方式,成功消除了绿条。

至此,遇到的坑就都踩完了,效果也不错。
最后,希望这篇文章能够对你有所帮助,在直播开发上,少走点弯路

相关阅读欲练 JS,必先攻 CSS 交互微动效设计指南【每日课程推荐】机器学习实战!快速入门在线广告业务及 CTR 相应知识

退出移动版