【iOS音视频学习】VideoToolBox H264视频硬编码

1,176 阅读6分钟

「这是我参与2022首次更文挑战的第3天,活动详情查看:2022首次更文挑战」。

VideoToolBox

  • 2014年的WWDC大会,苹果发布iOS 8.0,开放了硬编解码的API,也就是VideoToolBox
  • VideoToolbox 一个底层视频硬编解码框架,纯C语言API,可直接访问硬件的编解码器。提供性能极高的硬编硬解,同时也提供存储在 CoreVideo 像素缓冲区的图像进行格式的转换。
  • 这篇文章记录基于VideoToolBox封装一个H264硬编码视频的工具,其实单纯的调用videoToolBox API如果对C稍熟悉的话完全没什么难度,会在文章里记录这里为什么这么调用,如果用到实际项目中也更容易修改。

硬编码优点

  • 大幅提高编码性能,提高编码效率,降低编码时间
  • 大幅降低CPU使用率,倾向使用GPU)
  • 功耗大幅降低

VideoToolBox编码的输入输出分别是什么?

  • 输入CVPixelBuffer, 输出包含CMBlockBufferCMSampleBufferRef

  • CVImageBufferRefCVPixelBuffer的另外一个定义

  • AVCaptureVideoDataOutput获得的数据是一个存储了CVPixelBufferCMSampleBufferRef,我们通过(CVImageBufferRef)CMSampleBufferGetImageBuffer(sampleBuffer)拿到CVImageBufferRef,经过VideoToolBox编码输出存储了CMBlockBufferCMSampleBuffer

  • Apple Developer CVPixelBuffer 官方文档介绍

硬编码步骤

思路

  • 1 初始化编码会话
  • 2 公开函数接收包含CVPixelBufferCMSampleBufferRef
  • 3 输入到编码器
  • 4 在编码回调函数里将spspps以及数据回调,外界拿到回调可写入成视频文件
  • 5 销毁编码会话

用到的四个核心函数

  • 创建解码会话  VTCompressionSessionCreate
  • 编码(将CVImageBufferRef转成存储了CMBlockBuffer的CMSampleBuffer)     VTCompressionSessionEncodeFrame
  • 销毁解码会话 VTCompressionSessionInvalidate,销毁前需要先调用VTCompressionSessionCompleteFrames

一 创建编码会话

// 1 创建session(VideoToolBox中的session)*

    /**
     参数1: 分配器,一般NULL 默认也是NULL
     参数2: 分辨率的width,像素为单位,如果此数据非法,编码会改为合理的值
     参数3: 分辨率的height,像素为单位,如果此数据非法,编码会改为合理的值
     参数4: 编码类型-H264:KCMVideoCodecType_H264
     参数5: 编码规范 NULL
     参数6: 原像素缓冲区,NULL,由VideoToolBox默认创建
     参数7: 压缩数据分配器 NULL
     参数8: 回调,编码完成后的回调,需要给个回调方法,也可以NULL,函数指针,指向函数名,这里填写C函数名,不知道参数点进去copy
     参数9: self 桥接过去,因为C语言函数如果想要调用OC方法,需要对象,就把self传过去,
     参数10:compressionSession
     */
    OSStatus status = VTCompressionSessionCreate(kCFAllocatorDefault, (int32_t)_config.width, (int32_t)_config.height, kCMVideoCodecType_H264, NULL, NULL, NULL, videoEncoderCallBack, (__bridge  void *_Nullable)self, &_encodeSession);
    if (status != noErr) {
        NSLog(@"CQVideoEncoder-VTCompressionSessionCreate create failed. status = %d", (int)status);
        return;
    }

二 设置相关属性

  • 这里可根据实际需求进行设置
  • 根据B帧的特性,抛弃B帧,可减少由B帧带来的延时
    // 设置编码器属性
    // 设置实时编码
    status = VTSessionSetProperty(_encodeSession, kVTCompressionPropertyKey_RealTime, kCFBooleanTrue);
    NSLog(@"CQVideoEncoder-VTSessionSetProperty set RealTime. return status = %d", (int)status);
    // 指定编码比特流的配置文件和级别。直播一般使用baseline,抛弃B帧,可减少由B帧带来的延时
    status = VTSessionSetProperty(_encodeSession, kVTCompressionPropertyKey_ProfileLevel, kVTProfileLevel_H264_Baseline_AutoLevel);
    NSLog(@"CQVideoEncoder-VTSessionSetProperty set ProfileLevel. return status = %d", (int)status);
    // 设置码率均值(比特率可以高于此。默认比特率为0,表示视频编码器。应该确定压缩数据的大小。注意,比特率设置只在定时时有效)
    CFNumberRef bit = (__bridge CFNumberRef)@(_config.bitrate);
    status = VTSessionSetProperty(_encodeSession, kVTCompressionPropertyKey_AverageBitRate, bit);
    NSLog(@"CQVideoEncoder-VTSessionSetProperty set AverageBitRate. return status = %d", (int)status);
    // 码率限制
    CFArrayRef limits = (__bridge CFArrayRef)@[@(_config.bitrate / 4), @(_config.bitrate * 4)];
    status = VTSessionSetProperty(_encodeSession, kVTCompressionPropertyKey_DataRateLimits,limits);
    NSLog(@"CQVideoEncoder-VTSessionSetProperty set DataRateLimits. return status = %d", (int)status);
    //设置关键帧间隔(GOPSize)GOP太大图像会模糊
    CFNumberRef maxKeyFrameInterval = (__bridge CFNumberRef)@(_config.fps * 2);
    status = VTSessionSetProperty(_encodeSession, kVTCompressionPropertyKey_MaxKeyFrameInterval, maxKeyFrameInterval);
    NSLog(@"CQVideoEncoder-VTSessionSetProperty set MaxKeyFrameInterval. return status = %d", (int)status);
    //设置fps(预期)
    CFNumberRef expectedFrameRate = (__bridge CFNumberRef)@(_config.fps);
    status = VTSessionSetProperty(_encodeSession, kVTCompressionPropertyKey_ExpectedFrameRate, expectedFrameRate);
    NSLog(@"CQVideoEncoder-VTSessionSetProperty set ExpectedFrameRate. return status = %d", (int)status);

三 准备编码

status = VTCompressionSessionPrepareToEncodeFrames(_encodeSession);
    if (status != noErr) {
        NSLog(@"CQVideoEncoder-VTCompressionSessionPrepareToEncodeFrames failed. status = %d", (int)status);
        return;
    }

四 开始编码

  • 这里注意需要异步到子线程
  • 这个函数输入的sampleBuffer是编码前的包含了CVImageBufferRef的,通过AVCaptureVideoDataOutput获取的
- (void)videoEncodeWithSampleBuffer:(CMSampleBufferRef)sampleBuffer {
    CFRetain(sampleBuffer);
    dispatch_async(_encodeQueue, ^{
        // 帧数据 未编码的数据
        CVImageBufferRef imageBuffer = (CVImageBufferRef)CMSampleBufferGetImageBuffer(sampleBuffer);
        // 该帧的时间戳
        _frameID ++;
        CMTime timeStamp = CMTimeMake(_frameID, 1000);
        // 持续时间
        CMTime duration = kCMTimeInvalid;
        // 编码
        VTEncodeInfoFlags flags;
        OSStatus status = VTCompressionSessionEncodeFrame(_encodeSession, imageBuffer, timeStamp, duration, NULL, NULL, &flags);
        if (status != noErr) {
        NSLog(@"CQVideoEncoder-VTCompressionSessionEncodeFrame failed. status = %d", (int)status);
        }
        CFRelease(sampleBuffer);
    });
}

五 在编码完成的回调里采集数据,写入H264文件

这里需要理解一下几点

  • 为什么要判断关键帧?因为VideoToolBox编码器在每一个关键帧前面都会输出SPS/PPS信息.所以如果本帧是关键帧,则可以取出对应的SPS/PPS信息,sps/pps获取一次即可
  • sps pps
  • 大端小端字节序
  • 通过移动下标的方式,循环读取数据
#pragma mark *- 编码完成回调*

// startCode 长度 4
const Byte startCode[] = "\x00\x00\x00\x01";
void videoEncoderCallBack(void *outputCallbackRefCon, void *sourceFrameRefCon, OSStatus status, VTEncodeInfoFlags infoFlags, CMSampleBufferRef sampleBuffer) {
    if (status != noErr) {
        // 有错误
        NSLog(@"CQVideoEncoder-VideoEncodeCallback: encode error, status = %d", (int)status);
        return;
    }
    if (!CMSampleBufferDataIsReady(sampleBuffer)) {
        // 数据没有准备好
        NSLog(@"CQVideoEncoder-VideoEncodeCallback: data is not ready");
        return;
    }
    // 拿到self
    CQVideoEncoder** *encoder = (__bridge CQVideoEncoder *)outputCallbackRefCon;
    // 判断是否是关键帧
    BOOL isKeyFrame = NO;
    CFArrayRef attachArr = CMSampleBufferGetSampleAttachmentsArray(sampleBuffer, true);
    isKeyFrame = !CFDictionaryContainsKey(CFArrayGetValueAtIndex(attachArr, 0), kCMSampleAttachmentKey_NotSync);
    // 获取sps pps数据,只需要获取一次,保存在h264文件头即可
    if (isKeyFrame && !encoder->_isHasSpsPps) {
        size_t spsSize, spsCount;
        size_t ppsSize, ppsCount;
        const uint8_t *spsData, *ppsData;
        // 获取图像源像素格式
        CMFormatDescriptionRef formatDesc = CMSampleBufferGetFormatDescription(sampleBuffer);
        // 获取sps
        OSStatus status1 = CMVideoFormatDescriptionGetH264ParameterSetAtIndex(formatDesc, 0, &spsData, &spsSize, &spsCount, 0);
        // 获取pps
        OSStatus status2 = CMVideoFormatDescriptionGetH264ParameterSetAtIndex(formatDesc, 1, &ppsData, &ppsSize, &ppsCount, 0);
        // 判断sps/pps获取成功
        if (status1 == noErr && status2 == noErr) {
            encoder->_isHasSpsPps = YES;
            NSLog(@"CQVideoEncoder-videoEncoderCallBack:Get sps、pps success");
            // sps 转NSData
            NSMutableData *sps = [**NSMutableData** dataWithCapacity:4 + spsSize];
            [sps appendBytes:startCode length:4]; // 注意加入起始位
            [sps appendBytes:spsData length:spsSize];
            // pps 转NSData*
            NSMutableData *pps = [**NSMutableData** dataWithCapacity:4 + ppsSize];
            [pps appendBytes:startCode length:4]; // 注意加入起始位
            [pps appendBytes:ppsData length:ppsSize];
            dispatch_async(encoder.callBackQueue, ^{
                // 回调
                if (encoder.delegate && [encoder.delegate respondsToSelector:@selector(videoEncoder:didEncodeWithSps:pps:)]) {
                    [encoder.delegate videoEncoder:encoder didEncodeWithSps:sps pps:pps];
                }
            });
        } else {
            NSLog(@"CQVideoEncoder-videoEncodeCallback: Get sps/pps failed spsStatus=%d, ppsStatus=%d", (int)status1, (int)status2);
        }
    }

    // 获取NALU数据
    size_t lengthAtOffset, totalLength;
    char *dataPoint;
    // 获取blockBuffer sampleBuffer 转CMBlockBufferRef
    CMBlockBufferRef blockBuffer = CMSampleBufferGetDataBuffer(sampleBuffer);
    // 获取单个长度 总长度 首地址
    /**
     *参数1  数据*
     *参数2  偏移量0*
     *参数3  获取单个数据长度*
     *参数4  获取总数据长度*
     *参数5  指针指向*
     *获取数据块总大小,单个数据大小,数据块首地址,---理解数组*
     */
    OSStatus error = CMBlockBufferGetDataPointer(blockBuffer, 0, &lengthAtOffset, &totalLength, &dataPoint);
    if (error != kCMBlockBufferNoErr) {
        NSLog(@"CQVideoEncoder-videoEncodeCallback: get datapoint failed, status = %d", (int)error);
        return;
    }
    size_t offet = 0;
    // 返回的nalu数据前四个字节不是0001的startcode(不是系统端的0001),而是大端模式的帧长度length
    const int lengthInfoSize = 4;
    // 循环获取nalu数据 (通过移动下标的方式,循环读取数据)
    while (offet < totalLength - lengthInfoSize) {
        uint32_t naluLength = 0;
        // 获取nalu 数据长度
        memcpy(&naluLength, dataPoint + offet, lengthInfoSize);
        // 大端转系统端
        naluLength = CFSwapInt32BigToHost(naluLength);
        // 获取到编码好的视频数据
        NSMutableData** *data = [**NSMutableData** dataWithCapacity:4 + naluLength];
        [data appendBytes:startCode length:4];
        [data appendBytes:dataPoint + offet + lengthInfoSize length:naluLength];
        // 将NALU数据回调到代理中
        dispatch_async(encoder.callBackQueue, ^{
            if (encoder.delegate && [encoder.delegate respondsToSelector:@selector(videoEncoder:didEncodeSuccessWithH264Data:)]) {
                [encoder.delegate videoEncoder:encoder didEncodeSuccessWithH264Data:data];
            }
        });
        // 移动下标,继续读取下一个数据
        offet += lengthInfoSize + naluLength;
    }
}

六 在封装工具类的dealloc里释放编码会话

- (void)dealloc {
    if (self.encodeSession) {
        VTCompressionSessionCompleteFrames(self.encodeSession, kCMTimeInvalid);
        VTCompressionSessionInvalidate(self.encodeSession);
        CFRelease(self.encodeSession);
        self.encodeSession = NULL;
    }
}