VideoToolbox 硬编码 h.264

2,903 阅读5分钟

前言

VideoToolboxAppleiOS 8 之后推出的用于视频硬编码、解码的工具库。 平时所说的软编解码是指使用 ffmpeg 这个第三方库去做编码解码。

1. 原始裸流 CMSampleBuffer 获取

一般在做音视频应用开发的时候,我们都是用 AVFoundation 去做原始数据采集的,使用前置摄像头或者后置摄像头采集视频数据,使用麦克风采集音频数据。

AVCaptureVideoDataOutputSampleBufferDelegate这个代理的

- (void)captureOutput:(AVCaptureOutput *)output didOutputSampleBuffer:(CMSampleBufferRef)sampleBuffer fromConnection:(AVCaptureConnection *)connection

回调方法里面可以获取采集的视频裸流信息。注意AVCaptureAudioDataOutputSampleBufferDelegate音频输出的代理方法也是这个,那么我们如何区分到底是音频数据还是视频数据呢?这里有两种方案:

  • 判断 outputAVCaptureAudioDataOutput(音频) 还是 AVCaptureVideoDataOutput(视频)
  • 判断 connectionaudioConnection(音频) 还是 videoConnection(视频), 我自己在代码里使用属性声明了 audioConnectionvideoConnection

2. H.264 硬编码

2.1 初始化编码会话

  • 创建编码会话

    创建编码会话的时候注意传入了我们的编码回调函数 VideoEncodeCallback,这个函数会多次调用。

    //创建编码会话
    OSStatus status = VTCompressionSessionCreate(kCFAllocatorDefault, (int32_t)_config.width, (int32_t)_config.height, kCMVideoCodecType_H264, NULL, NULL, NULL, VideoEncodeCallback, (__bridge void * _Nullable)(self), &_encodeSession);
    if (status != noErr) {
        NSLog(@"VTCompressionSession create failed. status=%d", (int)status);
        return self;
    } else {
        NSLog(@"VTCompressionSession create success");
    }
    
  • 设置编码会话属性

    //设置实时编码
    VTSessionSetProperty(_encodeSession, kVTCompressionPropertyKey_RealTime, kCFBooleanTrue);
    //指定编码比特流的配置文件和级别。直播一般使用baseline,可减少由于b帧带来的延时
    VTSessionSetProperty(_encodeSession, kVTCompressionPropertyKey_ProfileLevel, kVTProfileLevel_H264_Baseline_AutoLevel); 
    
    //设置码率均值(比特率可以高于此。默认比特率为零,表示视频编码器。应该确定压缩数据的大小。注意,比特率设置只在定时时有效)
    CFNumberRef bit = (__bridge CFNumberRef)@(_config.bitrate);
    VTSessionSetProperty(_encodeSession, kVTCompressionPropertyKey_AverageBitRate, bit); 
    
    //设置码率上限
    CFArrayRef limits = (__bridge CFArrayRef)@[@(_config.bitrate / 4), @(_config.bitrate * 4)];
    VTSessionSetProperty(_encodeSession, kVTCompressionPropertyKey_DataRateLimits,limits);
    
    //设置关键帧间隔(GOPSize)GOP太大图像会模糊,越小图像质量越高,当然数据量也会随之变大
    CFNumberRef maxKeyFrameInterval = (__bridge CFNumberRef)@(_config.fps * 2);
    VTSessionSetProperty(_encodeSession, kVTCompressionPropertyKey_MaxKeyFrameInterval, maxKeyFrameInterval); 
    
    //设置fps(预期)
    CFNumberRef expectedFrameRate = (__bridge CFNumberRef)@(_config.fps);
    VTSessionSetProperty(_encodeSession, kVTCompressionPropertyKey_ExpectedFrameRate, expectedFrameRate); 
    
  • 准备编码

    OSStatus status = VTCompressionSessionPrepareToEncodeFrames(_encodeSession);
    

2.2 拿到 CMSampleBuffer 开始编码

  1. 先从原始裸流 CMSampleBuffer 获取原始图像信息 CVImageBuffer
  2. 生成 PTS
    • PTS:Presentation Time Stamp,PTS 主要用于度量解码后的视频帧什么时候被显示出来
    • DTS:Decode Time Stamp,DTS 主要是标识读入内存中的比特流在什么时候开始送入解码器中进行解码
  3. 设置持续时间 durationkCMTimeInvalid,表示会一直进行解码
  4. 声明 VTEncodeInfoFlags 来记录编码信息
  5. 调用 VTCompressionSessionEncodeFrame() 开始解码
- (void)encodeVideoSampleBuffer:(CMSampleBufferRef)sampleBuffer {
    CFRetain(sampleBuffer);
    dispatch_async(_encodeQueue, ^{
        // 帧数据
        CVImageBufferRef imageBuffer = (CVImageBufferRef)CMSampleBufferGetImageBuffer(sampleBuffer);
        // 该帧的显示时间戳 (PTS: 用于视频显示的时间戳)
        CMTime presentationTimeStamp = CMTimeMake(frameID++, 1000);
        //持续时间
        CMTime duration = kCMTimeInvalid;
        //编码
        VTEncodeInfoFlags flags;
        OSStatus status = VTCompressionSessionEncodeFrame(self.encodeSesion, imageBuffer, presentationTimeStamp, duration, NULL, NULL, &flags);
        if (status != noErr) {
            NSLog(@"VTCompression: encode failed: status=%d",(int)status);
        }
        CFRelease(sampleBuffer);
    });
}

这里的 frameID 是我们声明的图像帧的递增序标识,每次编码自增就可以了。

2.3 编码

  1. 容错处理,先判断当前状态是否正常,数据是否准备好
  2. 判断是否为关键帧 keyFrame
  3. 如果是关键帧,先获取图像源格式 CMFormatDescriptionRef, 处理 SPS 数据和 PPS 数据
    • 先处理 SPS 数据
    • 再处理 PPS 数据
    • 将获取的 SPS 数据和 PPS 数据写入 h.264 文件或者交给解码器去处理
  4. 如果不是关键帧,处理正常的 NALU 数据
    • 先从 CMSampleBuffer 获取 CMBlockBufferRef 数据
    • 读取数据内容,记录数据长度和总长度
    • 定义一个数据偏移量 bufferOffset, 然后 while 循环读取 NALU 数据,注意手动添加起始码 "\x00\x00\x00\x01"
    • 将获取的 NALU 数据写入 h.264 文件或者交给解码器去处理
// startCode 长度 4
const Byte startCode[] = "\x00\x00\x00\x01";
//编码成功回调
void VideoEncodeCallback(void * CM_NULLABLE outputCallbackRefCon, void * CM_NULLABLE sourceFrameRefCon,OSStatus status, VTEncodeInfoFlags infoFlags,  CMSampleBufferRef sampleBuffer ) {
    
    if (status != noErr) {
        NSLog(@"VideoEncodeCallback: encode error, status = %d", (int)status);
        return;
    }
    if (!CMSampleBufferDataIsReady(sampleBuffer)) {
        NSLog(@"VideoEncodeCallback: data is not ready");
        return;
    }
    CCVideoEncoder *encoder = (__bridge CCVideoEncoder *)(outputCallbackRefCon);
    
    //判断是否为关键帧
    CFArrayRef attachArray = CMSampleBufferGetSampleAttachmentsArray(sampleBuffer, true);
    BOOL keyFrame = !CFDictionaryContainsKey(CFArrayGetValueAtIndex(attachArray, 0), kCMSampleAttachmentKey_NotSync);//(注意取反符号)
    
    //获取 sps & pps 数据 ,只需获取一次,保存在h264文件开头即可
    if (keyFrame) {
        //获取图像源格式
        CMFormatDescriptionRef formatDesc = CMSampleBufferGetFormatDescription(sampleBuffer);
        // 声明 sps 数据大小, 个数 以及 数据内容, 先获取 sps 数据
        size_t spsSize, spsCount;
        const uint8_t *spsData;
        OSStatus spsStatus = CMVideoFormatDescriptionGetH264ParameterSetAtIndex(formatDesc, 0, &spsData, &spsSize, &spsCount, 0);
        if (spsStatus == noErr) {
            // 声明 pps 数据大小, 个数 以及 数据内容, 后获取 pps 数据
            size_t ppsSize, ppsCount;
            const uint8_t *ppsData;
            OSStatus ppsStatus = CMVideoFormatDescriptionGetH264ParameterSetAtIndex(formatDesc, 1, &ppsData, &ppsSize, &ppsCount, 0);
            if (ppsStatus == noErr) {
                NSLog(@"VideoEncodeCallback:got both sps and pps successfully");
                encoder->hasSpsPps = true;
                //sps data
                NSMutableData *sps = [NSMutableData dataWithCapacity:4 + spsSize];
                [sps appendBytes:startCode length:4];
                [sps appendBytes:spsData length:spsSize];
                //pps data
                NSMutableData *pps = [NSMutableData dataWithCapacity:4 + ppsSize];
                [pps appendBytes:startCode length:4];
                [pps appendBytes:ppsData length:ppsSize];
                
                dispatch_async(encoder.callbackQueue, ^{
                    //回调方法传递sps/pps
                    [encoder.delegate videoEncodeCallbacksps:sps pps:pps];
                });
            } else {
                NSLog(@"VideoEncodeCallback: get pps failed ppsStatus=%d", (int)ppsStatus);
            }
        } else {
            NSLog(@"VideoEncodeCallback: get sps failed spsStatus=%d", (int)spsStatus);
        }
    }
    
    //获取NALU数据
    size_t lengthAtOffset, totalLength;
    char *dataPoint;
    
    // 从 CMSampleBuffer 获取 DataBuffer, 将数据复制到 dataPoint
    CMBlockBufferRef blockBuffer = CMSampleBufferGetDataBuffer(sampleBuffer);
    OSStatus dataBufferStatus = CMBlockBufferGetDataPointer(blockBuffer, 0, &lengthAtOffset, &totalLength, &dataPoint);
    if (dataBufferStatus == noErr) {
        // 循环获取 nalu 数据
        size_t bufferOffset = 0;
        // 这个常量 4 是大端模式的帧长度length, 而不是 nalu 数据前四个字节(0001 的 startcode)
        static const int headerLength = 4;
        
        while (bufferOffset < totalLength - headerLength) {
            uint32_t nalUnitLength = 0;
            // 读取 nalu 长度的数据
            memcpy(&nalUnitLength, dataPoint + bufferOffset, headerLength);
            // 大端转系统端(iOS 的系统端是小端模式)
            nalUnitLength = CFSwapInt32BigToHost(nalUnitLength);
            // 获取到编码好的视频数据
            NSMutableData *data = [NSMutableData dataWithCapacity:4 + nalUnitLength];
            // 先添加 startcode
            [data appendBytes:startCode length:4];
            // 再拼接 nalu 数据
            [data appendBytes:dataPoint + bufferOffset + headerLength length:nalUnitLength];
            
            //将NALU数据回调到代理中
            dispatch_async(encoder.callbackQueue, ^{
                [encoder.delegate videoEncodeCallback:data];
            });
            
            // 移动偏移量,继续读取下一个 NALU 数据
            bufferOffset += headerLength + nalUnitLength;
        }
    } else {
        NSLog(@"VideoEncodeCallback: get datapoint failed, status = %d", (int)dataBufferStatus);
    }
}

2.4 释放资源

编码结束了以后,在合理的地方要释放资源

- (void)releaseEncodeSession
{
    if (_encodeSession) {
        VTCompressionSessionCompleteFrames(_encodeSession, kCMTimeInvalid);
        VTCompressionSessionInvalidate(_encodeSession);
        
        CFRelease(_encodeSession);
        _encodeSession = NULL;
    }
}

备注

此文章是自己学习音视频的笔记记录,也参考了网上很多的资料和文章,在这里推荐一下:

落影loyinglin

CC老师_HelloCoder

小东邪