從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音樂選擇的視訊上行配置是960x540,幀率是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按照三個分量來解碼,而不是兩個分量。 實現正確的解碼方式,成功消除了綠條。
至此,遇到的坑就都踩完了,效果也不錯。
最後,希望這篇文章能夠對你有所幫助,在直播開發上,少走點彎路
此文已由作者授權騰訊雲+社群釋出,更多原文請點選
搜尋關注公眾號「雲加社群」,第一時間獲取技術乾貨,關注後回覆1024 送你一份技術課程大禮包!
海量技術實踐經驗,盡在雲加社群!