了解VideoToolBox 硬編碼

Apple Developer VideoToolBox 官方文檔

在iOS4.0蘋果開始支持硬編解碼,不過硬編解碼在當(dāng)時還屬于私有API,不提供給開發(fā)者使用。
在2014年的WWDC大會上,也就是iOS8.0之后,蘋果才放開了硬編解碼的API。VideoToolbox.framework是一套純C語言的API,其中包含了很多C語言函數(shù),同時VideoToolbox.framework是基于Core Foundation庫函數(shù),基于C語言VideoToolbox實際上屬于低級框架,它是可以直接訪問硬件編碼器與解碼器,它存在與視頻壓縮與解壓以及存儲在像素緩存區(qū)中的數(shù)據(jù)轉(zhuǎn)換提供服務(wù)。

硬編碼的優(yōu)點

  • 提高編碼性能(使用CPU的使用率大大降低,傾向使用CPU)
  • 增加編碼效率(將編碼一幀的時間縮短)
  • 延長電量使用(耗電量大大降低)

這個框架在音視頻項目開發(fā)中,會頻繁使用到。

VideoToolbox框架的流程

  • 創(chuàng)建session
  • 設(shè)置編碼相關(guān)參數(shù)
  • 循環(huán)獲取采集數(shù)據(jù)
  • 獲取編碼后數(shù)據(jù)
  • 將數(shù)據(jù)寫入H264文件

1、編碼的輸入與輸出

在我們開始進(jìn)行編碼的工作之前,需了解VideoToolbox進(jìn)行編碼的輸入輸出分別是什么?只有了解了這個,我們才能清楚知道如何去向VideoToolbox添加數(shù)據(jù),并且如何獲取數(shù)據(jù)。

截屏2020-12-08 下午3.22.08.png

如圖所示,左邊的三幀視頻幀是發(fā)送給編碼器之前的數(shù)據(jù),開發(fā)者必須將原始圖像數(shù)據(jù)封裝為CVPixelBuffer的數(shù)據(jù)結(jié)構(gòu),該數(shù)據(jù)結(jié)構(gòu)是使用VideoToolbox的核心。關(guān)于CVPixelBuffer的介紹可以去官方文檔的了解。
Apple Developer CVPixelBuffer 官方文檔

2、CVPixelBuffer 解析

在這個官方文檔的介紹中,CVPixelBuffer的官方解釋:是其主內(nèi)存存儲所有像素點數(shù)據(jù)的一個對象,那么什么是主內(nèi)存?
其實它并不是我們平常所要操作的內(nèi)存,它指的是存儲區(qū)域存在于緩存之中,我們在訪問這個塊內(nèi)存區(qū)域,需要先鎖定這塊的內(nèi)存區(qū)域。

// 1.鎖定內(nèi)存區(qū)域:
CVPixelBufferLockBaseAddress(pixel_buffer, 0);
// 2.讀取該內(nèi)存區(qū)域數(shù)據(jù)到NSData對象中
Void *data = CVPixelBufferGetBaseAddress(pixel_buffer);
// 3.數(shù)據(jù)讀取完畢后需要釋放鎖定區(qū)域
CVPixelBufferRelease(pixel_buffer);

單純從它的使用方式,我們就可以知道這一塊內(nèi)存區(qū)域不是普通內(nèi)存區(qū)域,它需要加鎖、解鎖等一系列操作。作為視頻開發(fā),盡量減少進(jìn)行顯存和內(nèi)存的交換,所以在iOS開發(fā)過程中也要盡量減少對它的內(nèi)存區(qū)域訪問。建議使用iOS平臺提供的對應(yīng)的API來完成相應(yīng)的一系列操作。在AVFoundation回調(diào)方法中,它有提供我們的數(shù)據(jù)其實就是CVPixelBuffer,只不過當(dāng)時使用的是引用類型CVImageBufferRef,其實就是CVPixelBuffer的另外一個定義。Camera返回的CVImageBuffer中存儲的數(shù)據(jù)是一個CVPixelBuffer,而經(jīng)過VideoToolbox編碼輸出的CMSampleBuffer中存儲的數(shù)據(jù)是一個CMBlockBuffer的引用。

截屏2020-12-08 下午4.07.30.png

在iOS中經(jīng)常會使用到session的方式,比如我們使用任何硬件設(shè)備都要使用對應(yīng)的session,麥克風(fēng)就要使用到AudioSession,使用Camera就要使用AVCaptureSession,使用編碼則需要使用VTCompressionSession。解碼時,需要使用VTDecompressionSessionRef。

3、視頻編碼步驟分解

第一步:使用VTCompressionSession方法,創(chuàng)建編碼會話:
/*
參數(shù)1:NULL 分配器,設(shè)置NULL為默認(rèn)分配
參數(shù)2:width
參數(shù)3:height
參數(shù)4:編碼類型,如kCMVideoCodecType_H264
參數(shù)5:NULL encoderSpecification: 編碼規(guī)范,設(shè)置NULL由VideoToolbox自己選擇
參數(shù)6:NULL sourceImageBufferAttributes: 源像素緩沖區(qū)屬性,設(shè)置NULL不讓VideoToolbox創(chuàng)建,而是自己創(chuàng)建
參數(shù)7:NULL compressedDataAllocator: 壓縮數(shù)據(jù)分配器,設(shè)置NULL為默認(rèn)分配
參數(shù)8:回調(diào) 當(dāng)VTCompressionSessionEncoderFrame被調(diào)用壓縮一次后會被異步調(diào)用。注:當(dāng)你設(shè)置NULL的時候,你需要調(diào)用VTCompressionSessionEncodeFrameWithOutputHandler方法進(jìn)行壓縮幀處理,支持iOS9.0以上
參數(shù)9:outputCallbackRefCon: 回調(diào)客戶定義的參考值
參數(shù)10:compressionSessionOut: 編碼會話變量
*/
OSStatus status = VTCompressionSessionCreate(NULL, width, height, kCMVideoCodecType
_H264,NULL, NULL, NULL, didCompressH264, 
(__bridge void *) (self), &cEncodeingSession);
第二步:設(shè)置相關(guān)參數(shù)
/*
session:會話
propertykey::屬性名稱
propertyValue:屬性值
*/
VT_EXPORT OSStatus
VTSessionSetProperty(
      CM_NONNULL VTSessionRef                 session,
      CM_NONNULL CFStringRef                    propertyKey,
      CM_NONNULL CFTypeRef                      propertyValue  ) API_AVAILABLE(macosx(10.8), ios(8.0), tvos(10.2) );
  • kVTCompressionPropertyKey_RealTime:設(shè)置是否實時編碼
  • kVTProfileLevel_H264_Baseline_AutoLevel:表示使用H264Profile規(guī)格,可以設(shè)置HightAutoLevel規(guī)格
  • kVTCompressionPropertyKey_AllowFrameReordering:表示是否使用產(chǎn)生B幀數(shù)據(jù)。因為B幀在解碼是非必要數(shù)據(jù),所以開發(fā)過程中也可以拋棄B幀數(shù)據(jù)。
  • kVTCompressionPropertyKey_MaxKeyFrameInterval:表示關(guān)鍵幀的間隔,也就是我們常說的gop size
  • kVTCompressionPropertyKey_ExpectedFrameRate:表示設(shè)置幀率
  • kVTCompressionPropertyKey_AverageBitRate/kVTCompressionPropertyKey_DataRateLimits設(shè)置編碼輸出的碼率
第三步:準(zhǔn)備編碼
// 開始編碼
VTCompressionSessionPrepareToEncoderFrames(cEncodeingSession);
第四步:捕獲編碼數(shù)據(jù)
  • 通過AVFoundation 捕獲的視頻,這個時候我們會走到AVFoundation捕獲結(jié)果代理方法
#pragma mark - AVCaptureVideoDataOutputSampleBufferDelegate
// 獲取視頻流
-(void)captureOutput:(AVCaptureOutput *)captureOutput didOutputSampleBuffer:(CMSampleBufferRef)sampleBuffer fromConnection:(AVCaptureConnection *)connection {
    // 開始視頻錄制,獲取到攝像頭的視頻幀,傳入encode方法中
    dispatch_sync(cEncodeQueue, ^{
        [self encode:sampleBuffer];
    });
}
第五步:數(shù)據(jù)編碼
  • 將獲取的視頻數(shù)據(jù)編碼
// 編碼
- (void) encode:(CMSampleBufferRef )sampleBuffer
{
    // 拿到每一幀為編碼數(shù)據(jù)
    CVImageBufferRef imageBuffer = (CVImageBufferRef)CMSampleBufferGetImageBuffer(sampleBuffer);
    // 設(shè)置幀時間,如果不設(shè)置會導(dǎo)致時間軸過長,時間戳以ms為單位
    CMTime presentationTimeStamp = CMTimeMake(frameID++, 1000);
    
    VTEncodeInfoFlags flags;
    /*
     參數(shù)1:編碼會話
     參數(shù)2:未編碼數(shù)據(jù)
     參數(shù)3:獲取到的這個sample buffer 數(shù)據(jù)的展示時間戳。每一個傳給這個session的時間戳都要大于前一個展示時間戳
     參數(shù)4:對于獲取到sample buffer數(shù)據(jù),這個幀的展示時間,如果沒有時間信息,可設(shè)置kCMTimeInvalid
     參數(shù)5:frameProperties: 包含這個幀的屬性,幀的改變會影響后邊的編碼幀
     參數(shù)6:ourceFrameRefCon: 回調(diào)函數(shù)會有引用你設(shè)置的這個幀的參考值
     參數(shù)7:infoFlagsOut: 指向一個VTEncodeInfoFlags來接受一個編碼操作。如果使用異步運行,kVTEncodeInfo_Asynchronous被設(shè)置;同步運行,kVTEncdeInfo_FrameDropped被設(shè)置;設(shè)置NULL為不想接受這個信息
     */
    OSStatus statusCode = VTCompressionSessionEncodeFrame(cEncodeingSession, imageBuffer, presentationTimeStamp, kCMTimeInvalid, NULL, NULL, &flags);
    
    if (statusCode != noErr) {
        NSLog(@"H.264:VTCompressionSessionEncodeFrame faild with %d", (int)statusCode);
        VTCompressionSessionInvalidate(cEncodeingSession);
        CFRelease(cEncodeingSession);
        cEncodeingSession = NULL;
        return;
    }
    NSLog(@"H.264:VTCompressionSessionEncodeFrame Success");
}
第六步:編碼數(shù)據(jù)處理-獲取SPS/PPS

當(dāng)編碼成功后, 就會回調(diào)到最開始初始化編碼器會話時傳入的回調(diào)函數(shù),回調(diào)函數(shù)的原型如下:

void didCompressH264(void *outputCallbackRefCon, void *sourceFrameRefCon, OSStatus status, VTEncodeInfoFlags infoFlags, CMSampleBufferRef sampleBuffer)
  • 判斷status,如果成功則返回0(noErr);成功則繼續(xù)處理,不成功則不處理。
  • 判斷是否關(guān)鍵幀
/*
為什么要判斷關(guān)鍵幀?
因為VideoToolbox編碼器在每一個關(guān)鍵幀前面都會輸出SPS/PPS信息,所以如果本幀未關(guān)鍵幀,則可以取出對應(yīng)的SPS/PPS信息。

// 判斷當(dāng)前是否為關(guān)鍵幀
CFArrayRef array = CMSampleBufferGetSampleAttachmentsArray(sampleBuffer, true);
    CFDictionaryRef dic = CFArrayGetValueAtIndex(array, 0);
    bool isKeyFrame = !CFDictionaryContainsKey(dic, kCMSampleAttachmentKey_NotSync);
    
    bool keyFrame = !CFDictionaryContainsKey(CFArrayGetValueAtIndex(CMSampleBufferGetSampleAttachmentsArray(sampleBuffer, true), 0), kCMSampleAttachmentKey_NotSync);
*/
  • 那么如何獲取SPS/PPS信息?
// 判斷當(dāng)前幀是否為關(guān)鍵幀
    // 獲取SPS&PPS數(shù)據(jù),只獲取1次,保存在H264文件開頭的第一幀中
    // SPS(sample per second 采樣次數(shù)/s),是衡量模數(shù)轉(zhuǎn)換(ADC)時采樣速率的單位
    // PPS
    if (keyFrame) {
        // 圖像存儲方式,編碼器等格式描述
        CMFormatDescriptionRef format = CMSampleBufferGetFormatDescription(sampleBuffer);
        
        // SPS
        size_t sparameterSetSize, sparameterSetCount;
        const uint8_t * sparameterSet;
        OSStatus statusCode = CMVideoFormatDescriptionGetH264ParameterSetAtIndex(format, 0, &sparameterSet, &sparameterSetSize, &sparameterSetCount, 0);
        
        if (statusCode == noErr) {
            // 獲取PPS
            size_t pparameterSetSize, pparameterSetCount;
            const uint8_t * pparameterSet;
            
            // 從第一個關(guān)鍵幀獲取SPS & PPS
            OSStatus statusCode  = CMVideoFormatDescriptionGetH264ParameterSetAtIndex(format, 1, &pparameterSet, &pparameterSetSize, &pparameterSetCount, 0);
            
            // 獲取H264參數(shù)集合中的SPS 和 PPS
            if (statusCode == noErr) {
                // found sps & pps
                NSData * sps = [NSData dataWithBytes:sparameterSet length:sparameterSetSize];
                NSData * pps = [NSData dataWithBytes:pparameterSet length:pparameterSetSize];
                
                if (encoder) {
                    [encoder gotSpsPps:sps pps:pps];
                }
        }
    }
第七步:編碼壓縮數(shù)據(jù)并寫入H264文件

當(dāng)我們獲取了SPS/PPS信息之后,我們就獲取實際內(nèi)容來進(jìn)行處理了


    CMBlockBufferRef dataBuffer = CMSampleBufferGetDataBuffer(sampleBuffer);
    size_t length, totalLength;
    char * dataPointer;
    OSStatus statusCodeRet = CMBlockBufferGetDataPointer(dataBuffer, 0, &length, &totalLength, &dataPointer);
    if (statusCodeRet == noErr) {
        size_t bufferOffset = 0;
        // 返回的nalu數(shù)據(jù)前4個字節(jié)不是001的statusCode,而是大端模式的幀長度length
        static const int AVCCHeaderLength = 4;
        
        // 循環(huán)獲取nalu數(shù)據(jù)
        while (bufferOffset < totalLength - AVCCHeaderLength) {
            uint32_t NALUnitLength = 0;
            
            // 讀取 一單元長度的 nalu
            memcpy(&NALUnitLength, dataPointer + bufferOffset, AVCCHeaderLength);
            // 從大端模式轉(zhuǎn)換為系統(tǒng)端模式
            NALUnitLength = CFSwapInt32BigToHost(NALUnitLength);
            // 獲取nalu數(shù)據(jù)
            NSData * data = [[NSData alloc] initWithBytes:(dataPointer + bufferOffset + AVCCHeaderLength) length:NALUnitLength];
            
            // 將nalu數(shù)據(jù)寫入到文件
            [encoder gotEncoderData:data isKeyFrame:keyFrame];
            // 讀取下一個nalu 一次回調(diào)可能包含多個nalu數(shù)控
            bufferOffset += AVCCHeaderLength + NALUnitLength;
        }
    }

第一幀寫入sps & pps

- (void)gotSpsPps:(NSData*)sps pps:(NSData*)pps {
    NSLog(@"gotSpsPps %d %d", (int)[sps length], (int)[pps length]);
    
    const char bytes[] = "\x00\x00\x00\x01";
    size_t length = (sizeof bytes) -1;
    NSData * ByteHeader = [NSData dataWithBytes:bytes length:length];
    
    [fileHandele writeData:ByteHeader];
    [fileHandele writeData:sps];
    [fileHandele writeData:ByteHeader];
    [fileHandele writeData:pps];
}

添加4個字節(jié)的H264協(xié)議 start code分割符
一般來說編碼器編出的首幀數(shù)據(jù)為SPS & PPS

- (void)gotEncodedData:(NSData*)data isKeyFrame:(BOOL)isKeyFrame {
    NSLog(@"gotEncodedData %d", (int)[data length]);
    if (fileHandele != NULL) {
// H264編碼時,在每個NAL前添加起始碼 0x000001,解碼器在碼流中檢測起始碼,當(dāng)前NAL結(jié)束
        /*
            為防止NAL內(nèi)部出現(xiàn)0x000001的數(shù)據(jù),H264又提出“防止競爭 emulation prevention”機(jī)制,在編碼完NAL時,如果檢測出有連續(xù)兩個0x00字節(jié),就在后面插入一個0x03。當(dāng)解碼器在NAL內(nèi)部檢測到0x000003的數(shù)據(jù),就把0x03拋棄?;謴?fù)原始數(shù)據(jù)。
            總的來說H264的碼流的打包方式有兩種,一種為annex-b byte stream format 的格式,這個是絕大部分編碼器富潤默認(rèn)輸出格式,就是每個幀開頭的3~4個字節(jié)是H264的start_code,0x00000001或者0x000001.
            另一種是原始的NAL打包格式,就是開始的若干字節(jié)(1,2,4字節(jié))是NAL的長度,而不是start_code,此時必須借助某個全局的數(shù)據(jù)來獲得編碼器的profile,level, PPS, SPS等信息才可以解碼。
         */
        const char bytes[] = "\x00\x00\x00\x01";
        
        // 長度
        size_t length = (sizeof bytes) -1;
        // 頭字節(jié)
        NSData * ByteHeader = [NSData dataWithBytes:bytes length:length];
        // 寫入頭字節(jié)
        [fileHandele writeData:ByteHeader];
        // 寫入H264數(shù)據(jù)
        [fileHandele writeData:data];
    }
}
第八步:結(jié)束VideoToolBox
-(void)endVideoToolBox {
    VTCompressionSessionCompleteFrames(cEncodeingSession, kCMTimeInvalid);
    VTCompressionSessionInvalidate(cEncodeingSession);
    CFRelease(cEncodeingSession);
    cEncodeingSession = NULL;
}
最后編輯于
?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請聯(lián)系作者
【社區(qū)內(nèi)容提示】社區(qū)部分內(nèi)容疑似由AI輔助生成,瀏覽時請結(jié)合常識與多方信息審慎甄別。
平臺聲明:文章內(nèi)容(如有圖片或視頻亦包括在內(nèi))由作者上傳并發(fā)布,文章內(nèi)容僅代表作者本人觀點,簡書系信息發(fā)布平臺,僅提供信息存儲服務(wù)。

友情鏈接更多精彩內(nèi)容