利用GPUImage錄制直播流合成小視頻

本篇文章是在開發(fā)新功能-觀眾端錄制直播流的小視頻過程中,記錄學(xué)習(xí)到內(nèi)容,踩過的坑,分享一下.

需求

在觀眾端可以錄制正在播放的流的小視頻,同時要將屏幕上的用戶互動,包括:禮物,聊天,彈幕等元素同時錄制下來,與視頻流合在一起.

背景介紹

播放器使用七牛PLPlayerKit.而該框架在播放流時有兩個回調(diào)方法,將解析到的流數(shù)據(jù)暴露出來.

/**
 回調(diào)將要渲染的幀數(shù)據(jù)
 該功能只支持直播

 @param player 調(diào)用該方法的 PLPlayer 對象
 @param frame 將要渲染幀 YUV 數(shù)據(jù)。
 CVPixelBufferGetPixelFormatType 獲取 YUV 的類型。
 軟解為 kCVPixelFormatType_420YpCbCr8Planar.
 硬解為 kCVPixelFormatType_420YpCbCr8BiPlanarVideoRange.
 @param pts 顯示時間戳 單位ms
 @param sarNumerator
 @param sarDenominator
 其中sar 表示 storage aspect ratio
 視頻流的顯示比例 sarNumerator sarDenominator
 @discussion sarNumerator = 0 表示該參數(shù)無效
 
 @since v2.4.3
 */
- (void)player:(nonnull PLPlayer *)player willRenderFrame:(nullable CVPixelBufferRef)frame pts:(int64_t)pts sarNumerator:(int)sarNumerator sarDenominator:(int)sarDenominator;

/**
 回調(diào)音頻數(shù)據(jù)

 @param player 調(diào)用該方法的 PLPlayer 對象
 @param audioBufferList 音頻數(shù)據(jù)
 @param audioStreamDescription 音頻格式信息
 @param pts 顯示時間戳 是解碼器進(jìn)行顯示幀時相對于SCR(系統(tǒng)參考)的時間戳。SCR可以理解為解碼器應(yīng)該開始從磁盤讀取數(shù)據(jù)時的時間
 @param sampleFormat 采樣位數(shù) 枚舉:PLPlayerAVSampleFormat
 @return audioBufferList 音頻數(shù)據(jù)
 
 @since v2.4.3
 */
- (nonnull AudioBufferList *)player:(nonnull PLPlayer *)player willAudioRenderBuffer:(nonnull AudioBufferList *)audioBufferList asbd:(AudioStreamBasicDescription)audioStreamDescription pts:(int64_t)pts sampleFormat:(PLPlayerAVSampleFormat)sampleFormat;

分析

拿到需求時,針對要將用戶互動內(nèi)容一起渲染的需求,首先想到了OpenGL中的多重紋理混合的應(yīng)用,將通過視頻流創(chuàng)建的紋理和通過屏幕元素創(chuàng)建的紋理混合后,輸出我們需要的紋理數(shù)據(jù),轉(zhuǎn)為視頻數(shù)據(jù),通過回調(diào)接口的pts與音頻數(shù)據(jù)同步,錄入視頻.
而這個流程中的合成和寫入視頻,基于 OpenGL ES的GPUImage都有很好的是實現(xiàn),本著不重復(fù)造輪子,合理利用資源,于是就決定基于GPUImage來實現(xiàn).

視頻數(shù)據(jù)

通過可以拿到的視頻數(shù)據(jù)為kCVPixelFormatType_420YpCbCr8Planar(軟解)或者kCVPixelFormatType_420YpCbCr8BiPlanarVideoRange(硬解)的CVPixelBufferRef數(shù)據(jù).

/**
 @abstract 是否使用 video toolbox 硬解碼。
 @discussion 使用 video toolbox Player 將嘗試硬解碼,失敗后,將切換回軟解碼。
 @waring 該參數(shù)僅對 rtmp/flv 直播生效, 默認(rèn)不使用。支持 iOS 8.0 及更高版本。
 @since v2.1.4
 */
extern NSString  * _Nonnull PLPlayerOptionKeyVideoToolbox;

雖然GPUImage對kCVPixelFormatType_420YpCbCr8BiPlanarVideoRange格式的數(shù)據(jù)有著很好的支持和使用過程,但是本著兼容性考慮,必須對使用kCVPixelFormatType_420YpCbCr8Planar格式視頻數(shù)據(jù)作為輸入.

代碼

GPUImagePixelRender繼承GPUImageOutput,作為輸出視頻紋理的類,進(jìn)入GPUImage響應(yīng)鏈.基本仿照了GPUImageMovie類的初始化流程.關(guān)鍵點在修改shader. kCVPixelFormatType_420YpCbCr8Planar是三個planar來分別存儲YUV數(shù)據(jù),在上傳紋理時必然使用是三個紋理采樣.

// DTVRecordVideoFrame:數(shù)據(jù)模型類,記錄視頻數(shù)據(jù)
- (DTVRecordVideoFrame *)creatTextureYUV:(CVPixelBufferRef)pixelBuffer
{
    OSType pixelType = CVPixelBufferGetPixelFormatType(pixelBuffer);
    NSAssert(pixelType == kCVPixelFormatType_420YpCbCr8Planar, @"pixelType error ...");
    int pixelWidth = (int)CVPixelBufferGetWidth(pixelBuffer);
    int pixelHeight = (int)CVPixelBufferGetHeight(pixelBuffer);
    CVPixelBufferLockBaseAddress(pixelBuffer, 0);
    
    DTVRecordVideoFrame *yuv = [[DTVRecordVideoFrame alloc] init];
    // 視頻數(shù)據(jù)的寬高
    yuv.width = pixelWidth;
    yuv.height = pixelHeight;
    // YUV三個分量數(shù)據(jù)
    size_t y_size = pixelWidth * pixelHeight;
    uint8_t *yuv_y_frame = malloc(y_size);
    uint8_t *y_frame = CVPixelBufferGetBaseAddressOfPlane(pixelBuffer, 0);
    memcpy(yuv_y_frame, y_frame, y_size);
    yuv.Y = yuv_y_frame;
    
    size_t u_size = y_size / 4;
    uint8_t *yuv_u_frame = malloc(u_size);
    uint8_t *u_frame = CVPixelBufferGetBaseAddressOfPlane(pixelBuffer, 1);
    memcpy(yuv_u_frame, u_frame, u_size);
    yuv.U = yuv_u_frame;
    
    size_t v_size = y_size / 4;
    uint8_t *yuv_v_frame = malloc(v_size);
    uint8_t *v_frame = CVPixelBufferGetBaseAddressOfPlane(pixelBuffer, 2);
    memcpy(yuv_v_frame, v_frame, v_size);
    yuv.V = yuv_v_frame;
    
    CVPixelBufferUnlockBaseAddress(pixelBuffer, 0);
    return yuv;
}

解析出數(shù)據(jù)后,創(chuàng)建FBO,上傳頂點和紋理數(shù)據(jù)等不做詳解,可參考GPUImageMovie來做.下面是創(chuàng)建紋理對象的代碼

- (void)setupTexture:(DTVRecordVideoFrame *)videoFrame
{
    if (0 == _textures[0]) glGenTextures(3, _textures);
    
    const uint8_t *pixelByte[3] = {videoFrame.Y , videoFrame.U , videoFrame.V};
    const int widths[3]  = { videoFrame.width, videoFrame.width / 2, videoFrame.width / 2 };
    const int heights[3] = { videoFrame.height, videoFrame.height / 2, videoFrame.height / 2 };

    for (int i = 0; i < 3; i++) {
        glBindTexture(GL_TEXTURE_2D, _textures[i]);
        glTexImage2D(GL_TEXTURE_2D,
                     0,
                     GL_LUMINANCE,
                     widths[i],
                     heights[i],
                     0,
                     GL_LUMINANCE,
                     GL_UNSIGNED_BYTE,
                     pixelByte[i]);
        
        glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_LINEAR);
        glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_LINEAR);
        glTexParameterf(GL_TEXTURE_2D, GL_TEXTURE_WRAP_S, GL_CLAMP_TO_EDGE);
        glTexParameterf(GL_TEXTURE_2D, GL_TEXTURE_WRAP_T, GL_CLAMP_TO_EDGE);
        glBindTexture(GL_TEXTURE_2D, 0);
    }
}

再看一下shader,shader的代碼是從kxMoive中學(xué)習(xí)到的,用來渲染YUV三個分量,

NSString *const kGPUImageYUVPlanarFragmentShaderString = SHADER_STRING
(
 varying highp vec2 textureCoordinate;
 
 uniform sampler2D s_texture_y;
 uniform sampler2D s_texture_u;
 uniform sampler2D s_texture_v;
 
 void main()
 {
     highp float y = texture2D(s_texture_y, textureCoordinate).r;
     highp float u = texture2D(s_texture_u, textureCoordinate).r - 0.5;
     highp float v = texture2D(s_texture_v, textureCoordinate).r - 0.5;
     
     highp float r = y +             1.402 * v;
     highp float g = y - 0.344 * u - 0.714 * v;
     highp float b = y + 1.772 * u;
     
     gl_FragColor = vec4(r,g,b,1.0);
 }
);
View數(shù)據(jù)

GPUImageUIElement就是用來根據(jù)view或layer來生成紋理的,按理說可以拿來直接使用,然而我們屏幕上的動畫并不是都用的layer.contents來實現(xiàn)的,有些是基于UIView或者CAAnimation的一些layer動畫,如果直接使用view或layer,一些動畫根本不會顯示出來.如果對CALayer圖層了解的話,肯定知道為什么了.Layer層中modelLayer的屬性是在修改后立刻就變?yōu)榻K值的,而presentationLayer則會經(jīng)歷一個漸變的修改過程.而我們平常view.layer就是modelLayer,直接是終值了.所以我們拿到的紋理動畫上會有些奇怪.
知道了這點,對GPUImageUIElement進(jìn)行修改,每次創(chuàng)建紋理獲取數(shù)據(jù)時,是用presentationLayer來渲染.
同時發(fā)現(xiàn)GPUImageUIElement每次更新紋理時,都創(chuàng)建新的FBO,不會cache回收,對下面這段代碼進(jìn)行了修改,使用完FBO后.

    for (id<GPUImageInput> currentTarget in targets)
     {
        if (currentTarget != self.targetToIgnoreForUpdates)
        {
            NSInteger indexOfObject = [targets indexOfObject:currentTarget];
            NSInteger textureIndexOfTarget = [[targetTextureIndices objectAtIndex:indexOfObject] integerValue];
            [currentTarget setInputSize:layerPixelSize atIndex:textureIndexOfTarget];
            [currentTarget setInputFramebuffer:outputFramebuffer atIndex:textureIndexOfTarget];
            [currentTarget newFrameReadyAtTime:kCMTimeIndefinite atIndex:textureIndexOfTarget];
        }
    }
合成

PlanA:視頻收到一幀合成繪制一幀.通常我們采用的視頻流幀率是24或36,而屏幕刷新是60,經(jīng)測試,以視頻的幀率來刷新會比較節(jié)省CPU,但視頻卡頓時,屏幕元素也會卡住,同時動畫不夠流暢.

PlanB:以CADisplayLink刷新屏幕元素,以接收到的幀數(shù)據(jù)刷新視頻幀.
GPUImageMovieWriter用來寫入視頻數(shù)據(jù),存入本地.

//緩存視頻幀
- (void)addVideoPixelBuffer:(CVPixelBufferRef)pixelBuffer pts:(int64_t)videoPts fps:(int)videoFPS;
{
    // 已緩存足夠的數(shù)據(jù)
    if (_hasFillFrame) {
        return;
    }
    // 記錄開始的pts
    if (!_firstFramePTS) _firstFramePTS = videoPts;
    
    DTVRecordVideoFrame *videoframe = [self creatTextureYUV:pixelBuffer];
    if (videoframe.Y == NULL || videoframe.U == NULL || videoframe.V == NULL ) {
        NSLog(@"無視頻效幀");
        return;
    }
    videoframe.pts = videoPts;
   //幀持續(xù)時長
    videoframe.duration = _previousFrame ? (videoPts - _previousFrame.pts) : (1 / 24.f * 1000);
   //幀在我們錄制視頻中的pts
    videoframe.frameTime = CMTimeMake((videoPts - _firstFramePTS) * 600, 600 * 1000);
  // 緩存
    [self.videoBuffer addObject:videoframe];
    
    _previousFrame = videoframe;
    if (self.videoBuffer.count > 3 && !self.displayLink) {
        //循環(huán)切換視頻幀
        [self tick];
    }
}

在tick中會根據(jù)緩存的數(shù)量,和幀持續(xù)的時長切換當(dāng)前的幀數(shù)據(jù).通過GPUImageMovieWriter寫入視頻中.
https://github.com/BradLarson/GPUImage/issues/1729解答GPUImageMovieWriter寫入AVFileTypeMPEG4時出現(xiàn)問題解決辦法.

- (void)tick
{
    if (self.videoBuffer.count < 1) {
        if (_hasFillFrame) {
            [self stopDisplayLinkTimer];
            if (_movieWriter) {
                [_movieWriter finishRecording];
                [_blendFilter removeTarget:_movieWriter];
                _movieWriter = NULL;
            }
            if (self.completeBlock) self.completeBlock(_coverImage);
        }
        else{
            _renderVideoFrame = NO;
            NSLog(@"卡住...");
        }
    }
    else
    {
        _renderVideoFrame = YES;
        DTVRecordVideoFrame *frameTexture = self.videoBuffer.firstObject;
        
        if (!self.movieWriter) {
            unlink([DefaultFuckVideoPath UTF8String]);
            _movieWriter = [[GPUImageMovieWriter alloc] initWithMovieURL:[NSURL fileURLWithPath:DefaultFuckVideoPath] size:CGSizeMake(540, 960) fileType:AVFileTypeMPEG4 outputSettings:nil];
            _movieWriter.encodingLiveVideo = YES;
            _movieWriter.hasAudioTrack = YES;
            _movieWriter.assetWriter.movieFragmentInterval = kCMTimeInvalid;
            
            [self.pixelRender addTarget:self.blendFilter];
            [self.layerRender addTarget:self.blendFilter];
            [self.blendFilter addTarget:_movieWriter];
            [_movieWriter startRecording];
        }
        
        [self startDisplayLinkTimer];
        
        runAsynchronouslyOnVideoProcessingQueue(^{
            [self.pixelRender processVideoFrame:frameTexture];
        });
        
        dispatch_after(dispatch_time(DISPATCH_TIME_NOW, (int64_t)(frameTexture.duration * NSEC_PER_MSEC)), dispatch_get_main_queue(), ^{
            [self.videoBuffer removeObjectAtIndex:0];
            [self tick];
        });
        //作為封面
        if (CMTimeGetSeconds(_previousFrame.frameTime) > 0.5f && !_coverImage) {
            [self.blendFilter useNextFrameForImageCapture];
            _coverImage = [self.blendFilter imageFromCurrentFramebuffer];
        }
    }
}

CADisplayLink方法writerFrame,負(fù)責(zé)刷新獲取屏幕元素數(shù)據(jù),與當(dāng)前視頻幀_currentFrame通過GPUImageAlphaBlendFilter的濾鏡合成最終一幀.

- (void)writerFrame
{
   [self.layerRender updateWithPresentationLayer:_renderView.layer.presentationLayer];
}
音頻

音頻要和視頻同步,由于我們通過七牛接口拿到的是AudioBufferList數(shù)據(jù),需要轉(zhuǎn)換為GPUImageMovieWriter 需要的CMSampleBufferRef數(shù)據(jù).

 // 根據(jù)視頻的pts重新計算獲取音頻的pts
    CMTime time = CMTimeMake((audioPts - _firstFramePTS) * 600, 600 * 1000);
//轉(zhuǎn)換CMSampleBufferRef
    CMSampleBufferRef audioBuffer = NULL;
    CMFormatDescriptionRef format = NULL;
    CMSampleTimingInfo timing = {CMTimeMake(1, audioStreamDescription.mSampleRate),time, kCMTimeInvalid};
     UInt32 size = audioBufferList->mBuffers->mDataByteSize / sizeof(UInt32);
     UInt32 mNumberChannels = audioBufferList->mBuffers->mNumberChannels;
     CMItemCount numSamples = (CMItemCount)size / mNumberChannels;

     OSStatus status;
     status = CMAudioFormatDescriptionCreate(kCFAllocatorDefault, &audioStreamDescription, 0, NULL, 0, NULL, NULL, &format);
        if (status != noErr) {
            CFRelease(format);
            return;
        }
        
    status = CMSampleBufferCreate(kCFAllocatorDefault,NULL,false,NULL,NULL,format,numSamples, 1, &timing, 0, NULL, &audioBuffer);
        if (status != noErr) {
            CFRelease(format);
            return;
        }
        
    status = CMSampleBufferSetDataBufferFromAudioBufferList(audioBuffer, kCFAllocatorDefault,kCFAllocatorDefault, 0,audioBufferList);
        if (status != noErr) {
            CFRelease(format);
            return;
        }

    if (_movieWriter && audioBuffer) {
            [_movieWriter processAudioBuffer:audioBuffer];
        }
總結(jié)

在做這個功能的過程中學(xué)習(xí)到了很多內(nèi)容,CALayer圖層,視頻數(shù)據(jù)格式,音頻轉(zhuǎn)換,簡單的音視頻同步,加深了GPUImage的理解.個人感覺收獲頗多.

最后編輯于
?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請聯(lián)系作者
平臺聲明:文章內(nèi)容(如有圖片或視頻亦包括在內(nèi))由作者上傳并發(fā)布,文章內(nèi)容僅代表作者本人觀點,簡書系信息發(fā)布平臺,僅提供信息存儲服務(wù)。
  • 序言:七十年代末,一起剝皮案震驚了整個濱河市,隨后出現(xiàn)的幾起案子,更是在濱河造成了極大的恐慌,老刑警劉巖,帶你破解...
    沈念sama閱讀 229,327評論 6 537
  • 序言:濱河連續(xù)發(fā)生了三起死亡事件,死亡現(xiàn)場離奇詭異,居然都是意外死亡,警方通過查閱死者的電腦和手機(jī),發(fā)現(xiàn)死者居然都...
    沈念sama閱讀 98,996評論 3 423
  • 文/潘曉璐 我一進(jìn)店門,熙熙樓的掌柜王于貴愁眉苦臉地迎上來,“玉大人,你說我怎么就攤上這事。” “怎么了?”我有些...
    開封第一講書人閱讀 177,316評論 0 382
  • 文/不壞的土叔 我叫張陵,是天一觀的道長。 經(jīng)常有香客問我,道長,這世上最難降的妖魔是什么? 我笑而不...
    開封第一講書人閱讀 63,406評論 1 316
  • 正文 為了忘掉前任,我火速辦了婚禮,結(jié)果婚禮上,老公的妹妹穿的比我還像新娘。我一直安慰自己,他們只是感情好,可當(dāng)我...
    茶點故事閱讀 72,128評論 6 410
  • 文/花漫 我一把揭開白布。 她就那樣靜靜地躺著,像睡著了一般。 火紅的嫁衣襯著肌膚如雪。 梳的紋絲不亂的頭發(fā)上,一...
    開封第一講書人閱讀 55,524評論 1 324
  • 那天,我揣著相機(jī)與錄音,去河邊找鬼。 笑死,一個胖子當(dāng)著我的面吹牛,可吹牛的內(nèi)容都是我干的。 我是一名探鬼主播,決...
    沈念sama閱讀 43,576評論 3 444
  • 文/蒼蘭香墨 我猛地睜開眼,長吁一口氣:“原來是場噩夢啊……” “哼!你這毒婦竟也來了?” 一聲冷哼從身側(cè)響起,我...
    開封第一講書人閱讀 42,759評論 0 289
  • 序言:老撾萬榮一對情侶失蹤,失蹤者是張志新(化名)和其女友劉穎,沒想到半個月后,有當(dāng)?shù)厝嗽跇淞掷锇l(fā)現(xiàn)了一具尸體,經(jīng)...
    沈念sama閱讀 49,310評論 1 335
  • 正文 獨居荒郊野嶺守林人離奇死亡,尸身上長有42處帶血的膿包…… 初始之章·張勛 以下內(nèi)容為張勛視角 年9月15日...
    茶點故事閱讀 41,065評論 3 356
  • 正文 我和宋清朗相戀三年,在試婚紗的時候發(fā)現(xiàn)自己被綠了。 大學(xué)時的朋友給我發(fā)了我未婚夫和他白月光在一起吃飯的照片。...
    茶點故事閱讀 43,249評論 1 371
  • 序言:一個原本活蹦亂跳的男人離奇死亡,死狀恐怖,靈堂內(nèi)的尸體忽然破棺而出,到底是詐尸還是另有隱情,我是刑警寧澤,帶...
    沈念sama閱讀 38,821評論 5 362
  • 正文 年R本政府宣布,位于F島的核電站,受9級特大地震影響,放射性物質(zhì)發(fā)生泄漏。R本人自食惡果不足惜,卻給世界環(huán)境...
    茶點故事閱讀 44,479評論 3 347
  • 文/蒙蒙 一、第九天 我趴在偏房一處隱蔽的房頂上張望。 院中可真熱鬧,春花似錦、人聲如沸。這莊子的主人今日做“春日...
    開封第一講書人閱讀 34,909評論 0 28
  • 文/蒼蘭香墨 我抬頭看了看天上的太陽。三九已至,卻和暖如春,著一層夾襖步出監(jiān)牢的瞬間,已是汗流浹背。 一陣腳步聲響...
    開封第一講書人閱讀 36,140評論 1 290
  • 我被黑心中介騙來泰國打工, 沒想到剛下飛機(jī)就差點兒被人妖公主榨干…… 1. 我叫王不留,地道東北人。 一個月前我還...
    沈念sama閱讀 51,984評論 3 395
  • 正文 我出身青樓,卻偏偏與公主長得像,于是被迫代替她去往敵國和親。 傳聞我的和親對象是個殘疾皇子,可洞房花燭夜當(dāng)晚...
    茶點故事閱讀 48,228評論 2 375

推薦閱讀更多精彩內(nèi)容

  • 我自我感覺良好,不應(yīng)該是良好,是相當(dāng)好。我覺得我優(yōu)點挺多的,這眾多優(yōu)點當(dāng)中,“好奇心”是我覺得最有價值的。這可能...
    周_星_星閱讀 366評論 0 0
  • 好冷……好冷……有誰在嗎……竹子無助地縮成一團(tuán),慢慢睜開雙眼,那是一雙不符合她年齡的眼睛,沒有任何神采。 她靜靜地...
    懶貓子L閱讀 212評論 1 0
  • 周六、周日連續(xù)跑步,覺得13周的進(jìn)度太慢,要加快速度。 晚飯吃的太油了,以后要喝粥。 跑完沒有做拉伸,要改進(jìn)。 周...
    草上霜閱讀 322評論 0 1