了解VideoToolBox 硬编码

Apple Developer VideoToolBox 官方文档

在iOS4.0苹果开始支持硬编解码,不过硬编解码在当时还属于私有API,不提供给开发者使用。
在2014年的WWDC大会上,也就是iOS8.0之后,苹果才放开了硬编解码的API。VideoToolbox.framework是一套纯C语言的API,其中包含了很多C语言函数,同时VideoToolbox.framework是基于Core Foundation库函数,基于C语言VideoToolbox实际上属于低级框架,它是可以直接访问硬件编码器与解码器,它存在与视频压缩与解压以及存储在像素缓存区中的数据转换提供服务。

硬编码的优点

  • 提高编码性能(使用CPU的使用率大大降低,倾向使用CPU)
  • 增加编码效率(将编码一帧的时间缩短)
  • 延长电量使用(耗电量大大降低)

这个框架在音视频项目开发中,会频繁使用到。

VideoToolbox框架的流程

  • 创建session
  • 设置编码相关参数
  • 循环获取采集数据
  • 获取编码后数据
  • 将数据写入H264文件

1、编码的输入与输出

在我们开始进行编码的工作之前,需了解VideoToolbox进行编码的输入输出分别是什么?只有了解了这个,我们才能清楚知道如何去向VideoToolbox添加数据,并且如何获取数据。

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

如图所示,左边的三帧视频帧是发送给编码器之前的数据,开发者必须将原始图像数据封装为CVPixelBuffer的数据结构,该数据结构是使用VideoToolbox的核心。关于CVPixelBuffer的介绍可以去官方文档的了解。
Apple Developer CVPixelBuffer 官方文档

2、CVPixelBuffer 解析

在这个官方文档的介绍中,CVPixelBuffer的官方解释:是其主内存存储所有像素点数据的一个对象,那么什么是主内存?
其实它并不是我们平常所要操作的内存,它指的是存储区域存在于缓存之中,我们在访问这个块内存区域,需要先锁定这块的内存区域。

// 1.锁定内存区域:
CVPixelBufferLockBaseAddress(pixel_buffer, 0);
// 2.读取该内存区域数据到NSData对象中
Void *data = CVPixelBufferGetBaseAddress(pixel_buffer);
// 3.数据读取完毕后需要释放锁定区域
CVPixelBufferRelease(pixel_buffer);

单纯从它的使用方式,我们就可以知道这一块内存区域不是普通内存区域,它需要加锁、解锁等一系列操作。作为视频开发,尽量减少进行显存和内存的交换,所以在iOS开发过程中也要尽量减少对它的内存区域访问。建议使用iOS平台提供的对应的API来完成相应的一系列操作。在AVFoundation回调方法中,它有提供我们的数据其实就是CVPixelBuffer,只不过当时使用的是引用类型CVImageBufferRef,其实就是CVPixelBuffer的另外一个定义。Camera返回的CVImageBuffer中存储的数据是一个CVPixelBuffer,而经过VideoToolbox编码输出的CMSampleBuffer中存储的数据是一个CMBlockBuffer的引用。

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

在iOS中经常会使用到session的方式,比如我们使用任何硬件设备都要使用对应的session,麦克风就要使用到AudioSession,使用Camera就要使用AVCaptureSession,使用编码则需要使用VTCompressionSession。解码时,需要使用VTDecompressionSessionRef

3、视频编码步骤分解

第一步:使用VTCompressionSession方法,创建编码会话:
/*
参数1:NULL 分配器,设置NULL为默认分配
参数2:width
参数3:height
参数4:编码类型,如kCMVideoCodecType_H264
参数5:NULL encoderSpecification: 编码规范,设置NULL由VideoToolbox自己选择
参数6:NULL sourceImageBufferAttributes: 源像素缓冲区属性,设置NULL不让VideoToolbox创建,而是自己创建
参数7:NULL compressedDataAllocator: 压缩数据分配器,设置NULL为默认分配
参数8:回调 当VTCompressionSessionEncoderFrame被调用压缩一次后会被异步调用。注:当你设置NULL的时候,你需要调用VTCompressionSessionEncodeFrameWithOutputHandler方法进行压缩帧处理,支持iOS9.0以上
参数9:outputCallbackRefCon: 回调客户定义的参考值
参数10:compressionSessionOut: 编码会话变量
*/
OSStatus status = VTCompressionSessionCreate(NULL, width, height, kCMVideoCodecType
_H264,NULL, NULL, NULL, didCompressH264, 
(__bridge void *) (self), &cEncodeingSession);
第二步:设置相关参数
/*
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:设置是否实时编码
  • kVTProfileLevel_H264_Baseline_AutoLevel:表示使用H264Profile规格,可以设置HightAutoLevel规格
  • kVTCompressionPropertyKey_AllowFrameReordering:表示是否使用产生B帧数据。因为B帧在解码是非必要数据,所以开发过程中也可以抛弃B帧数据。
  • kVTCompressionPropertyKey_MaxKeyFrameInterval:表示关键帧的间隔,也就是我们常说的gop size
  • kVTCompressionPropertyKey_ExpectedFrameRate:表示设置帧率
  • kVTCompressionPropertyKey_AverageBitRate/kVTCompressionPropertyKey_DataRateLimits设置编码输出的码率
第三步:准备编码
// 开始编码
VTCompressionSessionPrepareToEncoderFrames(cEncodeingSession);
第四步:捕获编码数据
  • 通过AVFoundation 捕获的视频,这个时候我们会走到AVFoundation捕获结果代理方法
#pragma mark - AVCaptureVideoDataOutputSampleBufferDelegate
// 获取视频流
-(void)captureOutput:(AVCaptureOutput *)captureOutput didOutputSampleBuffer:(CMSampleBufferRef)sampleBuffer fromConnection:(AVCaptureConnection *)connection {
    // 开始视频录制,获取到摄像头的视频帧,传入encode方法中
    dispatch_sync(cEncodeQueue, ^{
        [self encode:sampleBuffer];
    });
}
第五步:数据编码
  • 将获取的视频数据编码
// 编码
- (void) encode:(CMSampleBufferRef )sampleBuffer
{
    // 拿到每一帧为编码数据
    CVImageBufferRef imageBuffer = (CVImageBufferRef)CMSampleBufferGetImageBuffer(sampleBuffer);
    // 设置帧时间,如果不设置会导致时间轴过长,时间戳以ms为单位
    CMTime presentationTimeStamp = CMTimeMake(frameID++, 1000);
    
    VTEncodeInfoFlags flags;
    /*
     参数1:编码会话
     参数2:未编码数据
     参数3:获取到的这个sample buffer 数据的展示时间戳。每一个传给这个session的时间戳都要大于前一个展示时间戳
     参数4:对于获取到sample buffer数据,这个帧的展示时间,如果没有时间信息,可设置kCMTimeInvalid
     参数5:frameProperties: 包含这个帧的属性,帧的改变会影响后边的编码帧
     参数6:ourceFrameRefCon: 回调函数会有引用你设置的这个帧的参考值
     参数7:infoFlagsOut: 指向一个VTEncodeInfoFlags来接受一个编码操作。如果使用异步运行,kVTEncodeInfo_Asynchronous被设置;同步运行,kVTEncdeInfo_FrameDropped被设置;设置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");
}
第六步:编码数据处理-获取SPS/PPS

当编码成功后, 就会回调到最开始初始化编码器会话时传入的回调函数,回调函数的原型如下:

void didCompressH264(void *outputCallbackRefCon, void *sourceFrameRefCon, OSStatus status, VTEncodeInfoFlags infoFlags, CMSampleBufferRef sampleBuffer)
  • 判断status,如果成功则返回0(noErr);成功则继续处理,不成功则不处理。
  • 判断是否关键帧
/*
为什么要判断关键帧?
因为VideoToolbox编码器在每一个关键帧前面都会输出SPS/PPS信息,所以如果本帧未关键帧,则可以取出对应的SPS/PPS信息。

// 判断当前是否为关键帧
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信息?
// 判断当前帧是否为关键帧
    // 获取SPS&PPS数据,只获取1次,保存在H264文件开头的第一帧中
    // SPS(sample per second 采样次数/s),是衡量模数转换(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;
            
            // 从第一个关键帧获取SPS & PPS
            OSStatus statusCode  = CMVideoFormatDescriptionGetH264ParameterSetAtIndex(format, 1, &pparameterSet, &pparameterSetSize, &pparameterSetCount, 0);
            
            // 获取H264参数集合中的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];
                }
        }
    }
第七步:编码压缩数据并写入H264文件

当我们获取了SPS/PPS信息之后,我们就获取实际内容来进行处理了


    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数据前4个字节不是001的statusCode,而是大端模式的帧长度length
        static const int AVCCHeaderLength = 4;
        
        // 循环获取nalu数据
        while (bufferOffset < totalLength - AVCCHeaderLength) {
            uint32_t NALUnitLength = 0;
            
            // 读取 一单元长度的 nalu
            memcpy(&NALUnitLength, dataPointer + bufferOffset, AVCCHeaderLength);
            // 从大端模式转换为系统端模式
            NALUnitLength = CFSwapInt32BigToHost(NALUnitLength);
            // 获取nalu数据
            NSData * data = [[NSData alloc] initWithBytes:(dataPointer + bufferOffset + AVCCHeaderLength) length:NALUnitLength];
            
            // 将nalu数据写入到文件
            [encoder gotEncoderData:data isKeyFrame:keyFrame];
            // 读取下一个nalu 一次回调可能包含多个nalu数控
            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个字节的H264协议 start code分割符
一般来说编码器编出的首帧数据为SPS & PPS

- (void)gotEncodedData:(NSData*)data isKeyFrame:(BOOL)isKeyFrame {
    NSLog(@"gotEncodedData %d", (int)[data length]);
    if (fileHandele != NULL) {
// H264编码时,在每个NAL前添加起始码 0x000001,解码器在码流中检测起始码,当前NAL结束
        /*
            为防止NAL内部出现0x000001的数据,H264又提出“防止竞争 emulation prevention”机制,在编码完NAL时,如果检测出有连续两个0x00字节,就在后面插入一个0x03。当解码器在NAL内部检测到0x000003的数据,就把0x03抛弃。恢复原始数据。
            总的来说H264的码流的打包方式有两种,一种为annex-b byte stream format 的格式,这个是绝大部分编码器富润默认输出格式,就是每个帧开头的3~4个字节是H264的start_code,0x00000001或者0x000001.
            另一种是原始的NAL打包格式,就是开始的若干字节(1,2,4字节)是NAL的长度,而不是start_code,此时必须借助某个全局的数据来获得编码器的profile,level, PPS, SPS等信息才可以解码。
         */
        const char bytes[] = "\x00\x00\x00\x01";
        
        // 长度
        size_t length = (sizeof bytes) -1;
        // 头字节
        NSData * ByteHeader = [NSData dataWithBytes:bytes length:length];
        // 写入头字节
        [fileHandele writeData:ByteHeader];
        // 写入H264数据
        [fileHandele writeData:data];
    }
}
第八步:结束VideoToolBox
-(void)endVideoToolBox {
    VTCompressionSessionCompleteFrames(cEncodeingSession, kCMTimeInvalid);
    VTCompressionSessionInvalidate(cEncodeingSession);
    CFRelease(cEncodeingSession);
    cEncodeingSession = NULL;
}
最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 213,186评论 6 492
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 90,858评论 3 387
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 158,620评论 0 348
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 56,888评论 1 285
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 66,009评论 6 385
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 50,149评论 1 291
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 39,204评论 3 412
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 37,956评论 0 268
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 44,385评论 1 303
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 36,698评论 2 327
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 38,863评论 1 341
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 34,544评论 4 335
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 40,185评论 3 317
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 30,899评论 0 21
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 32,141评论 1 267
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 46,684评论 2 362
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 43,750评论 2 351