iOSビデオツールボックスビデオハードコーデック

前書き

ビデオのエンコードとデコードは、オーディオとビデオのテクノロジーの重要な部分です。Appleは、WWDC2014開発者会議で、ビデオのハードエンコードとデコードをサポートするフレームワークを公開しましたVideo Toolbox。この記事は、フレームワークの基本的な知識、使用法の詳細、および一般的な問題を紹介することを目的としています。

ビデオツールボックスの基本

ソフトコーデックとハードコーデック

ビデオエンコーディングは、ビデオデータを圧縮し、ビデオ送信またはストレージのオーバーヘッドを削減することです。一方、ビデオデコーディングは、エンコードされたビデオを再生するための逆の操作を実行することです。ビデオコーデックは、ソフトコーデックとハードコーデックに分けられます。

コーデックタイプ コーデックハードウェア アドバンテージ 欠点
ソフトコーデック CPU 優れた互換性、アップグレードが簡単、すべてのビデオ形式をサポート、鮮明な画質。 パフォーマンスの低いモデルは、熱くなったりフリーズしたりする可能性があります。
ハードコーデック 非CPU:GPUまたは専用DSP、FPGA、ASICチップなど。 CPU使用率が低く、電話熱が発生しません。 一部のデバイスはハードウェアでサポートされておらず、互換性が低くなっています。

上記の比較に基づくと、ほとんどのビジネスシナリオの現在のエンコードおよびデコード戦略は次のとおりです。モバイル端末はハードコーディングを使用してビデオファイルを生成し、サーバーに送信します。サーバーはソフトエンコードとトランスコーディングを実行して、より多くの形式またはビットレートをサポートします。 。ビデオ、そして視聴端末に配信されます。一部のデバイスがハードコーデックをサポートしていないことを考慮すると、通常、収益としてソフトコーデックが必要になります。

Annex-BおよびAVCC/HVCC

当社の一般的なビデオコーディング形式は、 H.264/AVCInternational Standards Organization ()およびInternational Telex Union Telecommunication Standardization Organization(ISO)の下でMoving Picture Experts Group()によって開発された一連のコーディング標準です。まだサポートされていません。この記事で説明されている機能はHです。 264およびH.265形式MPEGITU-TH.265/HEVCH.266/VVCH.266Video Toolbox

高いビデオ圧縮率とネットワークアフィニティを実現するために(つまり、さまざまな伝送ネットワークに適しています)、H.264システムアーキテクチャは、ビデオコーディング層VCL(ビデオコーディング層)とネットワーク抽象化層(またはネットワーク適応層)に分割されます。 NAL(ネットワーク抽象化レイヤー)、後続のH.265およびH.266もこのレイヤードアーキテクチャに従います。

  • 视频编码层(VCL):用于独立于网络进行视频编码,编码完成后输出SODB(String Of Data Bits)数据。
  • 网络抽象层(NAL):该层的作用是将视频编码数据根据内容的不同划分成不同类型的NALU,以适配到各种各样的网络和多元环境中。对VCL输出的SODB数据后添加结尾比特,一个比特 1 和若干个比特 0,用于字节对齐,称为RBAP,然后再在 RBSP 头部加上 NAL Header 来组成一个一个的NAL单元(unit)即NALU

H.264和H.265的NAL Header结构:

NAL Header包含当前NALU的类型(nal_unit_type)信息,H.264的NAL Header为一个字节,H.265为两个字节,所以获取类型的方法不同: H.264为:int type = (frame[4] & 0x1F),5代表I帧,7、8分别代表SPS、PPS。 H.265为:int type = int type = (code & 0x7E)>>1,19代表I帧,32、33、34分别代表VPS、SPS、PPS。

因为NALU长度不一,要写到一个文件中需要标识符来分割码流以区分独立的NALU,解决这一问题的两种方案,产生了两种不同的码流格式:

  • Annex-B:在每个NALU前加上0 0 0 1或者0 0 1,称作start code(起始码),如果原始码流中含有起始码,则起用防竞争字节:如将0 0 0 1处理为0 0 0 3 1。
  • AVCC/HVCC:在NALU前面加上几个字节,用于表示整个NALU的长度(大端序,读取时调用CFSwapInt32BigToHost()转为小端),在读取的时候先将长度读取出来,再读取整个NALU。

除了NALU前添加的字节表示的含义不同之外,AVCC/HVCC和Annex-B在处理序列参数集SPS(Sequence Parameter Set)、图像参数集PPS(Picture Parameter Set)和视频参数集VPS(Video Parameter Set)(H.265才有)上也不同(不是必须要对参数集的具体内容做详细了解,我们只要知道这些参数集是解码所必需的数据,在解码前需要拿到这些数据即可)。

H.264可以通过CMVideoFormatDescriptionGetH264ParameterSetAtIndex获取,0、1分别对应SPS和PPS。H.265可以通过CMVideoFormatDescriptionGetHEVCParameterSetAtIndex获取,0、1、2分别对应VPS、SPS和PPS。后面会有完整代码示例。

Annex-B和AVCC/HVCC对参数集的不同处理方式:

  • Annex-B:参数集当成普通的NALU处理,每个I帧前都需要添加(VPS/)SPS/PPS。
  • AVCC/HVCC:参数集特殊处理,放在头部被称为extradata的数据中。

为什么不统一为一种格式?

我们知道视频分为本地视频文件和网络直播流,对于直播流,AVCC/HVCC 格式只在头部添加了参数集,如果是中途进入观看会获取不到参数集,也就无法初始化解码器进行解码,而 Annex-B 在每个I帧前都添加了参数集,可以从最近的I帧初始化解码器解码观看。而 AVCC/HVCC 只在头部添加参数集很适合用于本地文件,解码本地文件只需要获取一次参数集进行解码就能播放,所以不需要像Annex-B一样重复地存储多份参数集。

为什么要了解这两种格式?

因为Video Toolbox编码和解码只支持 AVCC/HVCC 的码流格式,而Android的 MediaCodec 只支持 Annex-B 的码流格式。因此在流媒体场景下,对于iOS开发而言,需要在采集编码之后转为Annex-B格式再进行推流,拉流解码时则需要转为AVCC/HVCC格式才能用Video Toolbox进行解码播放。

如果在编码后想直接存储为本地文件,可以使用AVFoundation框架中的AVAssetWriter。

Video Toolbox框架概览

Video Toolbox 最早是在OS X上运行,现在看苹果的官方文档的说明,Video Toolbox 的系统支持为iOS 6.0+,实际上苹果在WWDC2014大会上才开放了Video Toolbox框架,即iOS 8.0以后开发者才可以使用。

官方文档介绍:Video Toolbox是一个底层框架,提供对硬件编码器和解码器的直接访问。它提供了视频编码和解码服务,以及存储在CoreVideo像素缓冲区的光栅图像格式之间的转换。这些服务以session(编码、解码和像素转换)的形式提供,并以Core Foundation (CF)类型提供。不需要直接访问硬件编码器和解码器的应用程序不应该直接使用VideoToolbox。

Video Toolbox框架目前分为了编解码和像素转换两个模块,iOS9.0之后支持了多通道编解码,本文要使用的是Compression的前两个类:VTCompressionSession(编码)和VTDecompressionSession(解码)。

Video Toolbox的输入和输出

在使用Video Toolbox前我们先了解Video Toolbox的输入和输出。iOS开发通常使用AVFoundation框架进行视频录制,AVFoundation框架流通的数据类型为CMSampleBuffer,使用AVCapture模块录制视频的回调方法为- (void)captureOutput:(AVCaptureOutput *)output didOutputSampleBuffer:(CMSampleBufferRef)sampleBuffer fromConnection:(AVCaptureConnection *)connection,编码需要的输入即原始视频帧CVImageBuffer(或CVPixelBuffer)就包裹在CMSampleBuffer中,经过编码后输出的仍为CMSampleBuffer类型,其中的CMBlockBuffer为编码后数据。相反,解码以CMSampleBuffer类型的CMSampleBuffer作为输入,解码完输出CVImageBuffer(CVPixelBuffer),CMSampleBuffer可以通过AVAssetReader读取文件得到,或者从流媒体中读取NSData使用CMSampleBufferCreateReady手动创建。

编码

VTCompressionSession的文档介绍了使用VTCompressionSession进行视频硬编码的工作流程:

1. Create a compression session using VTCompressionSessionCreate.
   // 使用VTCompressionSessionCreate创建一个编码会话。
2. Optionally, configure the session with your desired Compression Properties by calling VTSessionSetProperty or VTSessionSetProperties.
   // 可选地,通过调用VTSessionSetProperty或VTSessionSetProperties来配置编码器属性。
3. Encode video frames using VTCompressionSessionEncodeFrame and receive the compressed video frames in the session’s VTCompressionOutputCallback.
   // 使用VTCompressionSessionEncodeFrame编码视频帧,并在会话的VTCompressionOutputCallback中接收编码后的视频帧。
4. To force the completion of some or all pending frames, call VTCompressionSessionCompleteFrames.
   // 要强制完成部分或所有挂起的帧,调用VTCompressionSessionCompleteFrames。
5. When you finish with the compression session, call VTCompressionSessionInvalidate to invalidate it and CFRelease to free its memory.
   // 当您完成编码会话时,调用VTCompressionSessionInvalidate来使其无效,并调用CFRelease来释放它的内存。

结合实际的编码和后续处理,逐步解析相关API:

1. 创建编码会话VTCompressionSessionRef

/* 参数解析
allocator: session的内存分配器,传NULL表示默认的分配器。
width,height: 指定编码器的像素的宽高,与捕捉到的视频分辨率保持一致,如果视频编码器不能支持提供的宽度和高度,它可能会改变它们。
codecType: 编码类型,如H.264:kCMVideoCodecType_H264、H.265:kCMVideoCodecType_HEVC,最好先调用VTIsHardwareDecodeSupported(codecType) 判断是否支持该编码类型。
encoderSpecification: 指定必须使用特定的编码器,传NULL的话Video Toolbox会自己选择一个编码器。
sourceImageBufferAttributes: 原始视频数据需要的属性,主要用于创建CVPixelBufferPool,如果不需要Video Toolbox创建,可以传NULL,但是使用自己创建的CVPixelBufferPool会增加需要拷贝图像数据的几率。
compressedDataAllocator: 编码数据的内存分配器,传NULL表示使用默认的分配器.
outputCallback: 接收编码数据的回调,这个回调可以选择使用同步或异步方式接收。如果用同步则与VTCompressionSessionEncodeFrame函数线程保持一致,如果用异步会新建一条线程接收。该参数也可传NULL不过当且仅当我们使用VTCompressionSessionEncodeFrameWithOutputHandler函数作编码时。
outputCallbackRefCon: 可以传入用户自定义数据,主要用于回调函数与主类之间的交互。
compressionSessionOut: 传入要创建的session的内存地址,注意,session不能为NULL。
*/
VTCompressionSessionCreate(
	CM_NULLABLE CFAllocatorRef							allocator,
	int32_t												width,
	int32_t												height,
	CMVideoCodecType									codecType,
	CM_NULLABLE CFDictionaryRef							encoderSpecification,
	CM_NULLABLE CFDictionaryRef							sourceImageBufferAttributes,
	CM_NULLABLE CFAllocatorRef							compressedDataAllocator,
	CM_NULLABLE VTCompressionOutputCallback				outputCallback,
	void * CM_NULLABLE									outputCallbackRefCon,
	CM_RETURNS_RETAINED_PARAMETER CM_NULLABLE VTCompressionSessionRef * CM_NONNULL compressionSessionOut) API_AVAILABLE(macosx(10.8), ios(8.0), tvos(10.2));

其中,视频解码输出的回调参数outputCallback类型为typedef void (*VTCompressionOutputCallback)(void *outputCallbackRefCon, void *sourceFrameRefCon, OSStatus status, VTEncodeInfoFlags infoFlags, CMSampleBufferRef sampleBuffer),"sampleBuffer"即为编码后的数据。

2. 配置编码器属性

/*
session: 会话
propertyKey: 属性名称
propertyValue: 属性值,设置为NULL将恢复默认值。
*/
  VT_EXPORT OSStatus
  VTSessionSetProperty(
  CM_NONNULL VTSessionRef       session,
  CM_NONNULL CFStringRef        propertyKey,
  CM_NULLABLE CFTypeRef         propertyValue ) API_AVAILABLE(macosx(10.8), ios(8.0), tvos(10.2));

WWDC2022之后即iOS16.0之后新加了3个key,加上之前支持的53个,总共支持56个不同的key值配置编码参数。下表列举了常用的一些key值,简单感受一下可以配置的内容:

key 含义
kVTCompressionPropertyKey_RealTime 是否为实时编码,离线编码设置为kCFBooleanFalse,实时编码设置为kCFBooleanTrue。
kVTCompressionPropertyKey_ProfileLevel 设置session的profile和level,一般设置为kVTProfileLevel_H264_Baseline_AutoLevel, kVTProfileLevel_H264_Main_AutoLevel, kVTProfileLevel_H264_High_AutoLevel, kVTProfileLevel_HEVC_Main_AutoLevel等。
kVTCompressionPropertyKey_AllowFrameReordering 是否支持B帧。为了对B帧进行编码,视频编码器必须对帧进行重新排序,即解码与显示的顺序会不同。
kVTCompressionPropertyKey_AverageBitRate 平均码率
kVTCompressionPropertyKey_DataRateLimits 码率上限
kVTCompressionPropertyKey_ExpectedFrameRate 期望帧率
kVTCompressionPropertyKey_MaxKeyFrameInterval 最大关键帧间隔帧数,即GOP 帧数
kVTCompressionPropertyKey_MaxKeyFrameIntervalDuration 最大关键帧间隔时长
kVTEncodeFrameOptionKey_ForceKeyFrame 是否强制为I帧
kVTCompressionPropertyKey_NumberOfPendingFrames 设置缓冲队列的大小,设置为1,则每一帧都实时输出
kVTCompressionPropertyKey_AllowTemporalCompression 默认为true,设置为false则只编码关键帧。

3. 准备编码

// session: 编码会话
VT_EXPORT OSStatus
VTCompressionSessionPrepareToEncodeFrames( CM_NONNULL VTCompressionSessionRef session ) API_AVAILABLE(macosx(10.9), ios(8.0), tvos(10.2));

4. 开始编码

/*
session: 编码会话
imageBuffer: 一个CVImageBuffer,包含一个要压缩的视频帧,引用计数不能为0。
presentationTimeStamp: PTS,此帧的显示时间戳,会添加到CMSampleBuffer中。传递给session的每个PTS必须大于前一帧的。
duration: 此帧的显示持续时间,会添加到CMSampleBuffer中。如果没有持续时间信息,则传递kCMTimeInvalid。
frameProperties: 指定此帧编码的属性的键/值对。注意,一些会话属性也可能在帧之间改变,这样的变化会对随后编码的帧产生影响。
sourceFrameRefCon: 帧的参考值,它将被传递给输出回调函数。
infoFlagsOut: 指向VTEncodeFlags指针,用于接收有关编码操作的信息。如果编码正在进行,可以设置kVTEncodeInfo_Asynchronous,如果帧被删除;可以设置kVTEncodeInfo_FrameDropped;如果不想接收次信息可以传NULL。
*/
VT_EXPORT OSStatus
VTCompressionSessionEncodeFrame(
	CM_NONNULL VTCompressionSessionRef	session,
	CM_NONNULL CVImageBufferRef			imageBuffer,
	CMTime								presentationTimeStamp,
	CMTime								duration, // may be kCMTimeInvalid
	CM_NULLABLE CFDictionaryRef			frameProperties,
	void * CM_NULLABLE					sourceFrameRefcon,
	VTEncodeInfoFlags * CM_NULLABLE		infoFlagsOut ) API_AVAILABLE(macosx(10.8), ios(8.0), tvos(10.2));

5. 处理编码后的数据

在回调outputCallback中处理编码后的数据,即CMSamplaBuffer。要将编码后的CMSamplaBuffer写入mp4、MOV等容器文件,可以使用AVFoundation框架AVAssetWriter

我们着重讲解流媒体场景下如何将AVCC/HVCC转为Annex-B格式:

  1. 从关键帧中获取extradata,获取参数集
// 判断是否是关键帧
bool isKeyFrame = !CFDictionaryContainsKey((CFDictionaryRef)CFArrayGetValueAtIndex(CMSampleBufferGetSampleAttachmentsArray(sampleBuffer, true), 0), (const void *)kCMSampleAttachmentKey_NotSync);

CMSampleBufferGetSampleAttachmentsArray用于获取样本的附加信息数据,苹果文档对kCMSampleAttachmentKey_NotSync的解释为:一个同步样本,也被称为关键帧或IDR(瞬时解码刷新),可以在不需要任何之前的样本被解码的情况下被解码。同步样本之后的样本也不需要在同步样本之前的样本被解码。所以样本的附加信息字典不包含该key即为I帧。

    // 获取编码类型
    CMVideoCodecType codecType = CMVideoFormatDescriptionGetCodecType(CMSampleBufferGetFormatDescription(sampleBuffer));
    CMFormatDescriptionRef format = CMSampleBufferGetFormatDescription(sampleBuffer);
    if (codecType == kCMVideoCodecType_H264) {
        // H.264/AVC 取出formatDescription中index 0、1对应的SPS、PPS
        size_t sparameterSetSize, sparameterSetCount, pparameterSetSize, pparameterSetCount;
        const uint8_t *sparameterSet, *pparameterSet;
        OSStatus statusCode_sps = CMVideoFormatDescriptionGetH264ParameterSetAtIndex(format, 0, &sparameterSet, &sparameterSetSize, &sparameterSetCount, 0);
        if (statusCode_sps == noErr) { // SPS
            NSData *sps = [NSData dataWithBytes:sparameterSet length:sparameterSetSize];
        }
        OSStatus statusCode_pps = CMVideoFormatDescriptionGetH264ParameterSetAtIndex(format, 1, &pparameterSet, &pparameterSetSize, &pparameterSetCount, 0);
        if (statusCode_pps == noErr) { // PPS
            NSData *pps = [NSData dataWithBytes:pparameterSet length:pparameterSetSize];
        }
    } else if (codecType == kCMVideoCodecType_HEVC) {
        // H.265/HEVC 取出formatDescription中index 0、1、2对应的VPS、SPS、PPS
        size_t vparameterSetSize, vparameterSetCount, sparameterSetSize, sparameterSetCount, pparameterSetSize, pparameterSetCount;
        const uint8_t *vparameterSet, *sparameterSet, *pparameterSet;
        if (@available(iOS 11.0, *)) { // H.265/HEVC 要求iOS11以上
            OSStatus statusCode_vps = CMVideoFormatDescriptionGetHEVCParameterSetAtIndex(format, 0, &vparameterSet, &vparameterSetSize, &vparameterSetCount, 0);
            if (statusCode_vps == noErr) { // VPS
                NSData *vps = [NSData dataWithBytes:vparameterSet length:vparameterSetSize];
            }
            OSStatus statusCode_sps = CMVideoFormatDescriptionGetHEVCParameterSetAtIndex(format, 1, &sparameterSet, &sparameterSetSize, &sparameterSetCount, 0);
            if (statusCode_sps == noErr) { // SPS
                NSData *sps = [NSData dataWithBytes:sparameterSet length:sparameterSetSize];
            }
            OSStatus statusCode_pps = CMVideoFormatDescriptionGetHEVCParameterSetAtIndex(format, 2, &pparameterSet, &pparameterSetSize, &pparameterSetCount, 0);
            if (statusCode_pps == noErr) { // PPS
                NSData *pps = [NSData dataWithBytes:pparameterSet length:pparameterSetSize];
            }
    }
  1. 将参数集分别组装成单个NALU
    // 添加start code(后续都以H.264为例)
    NSMutableData *annexBData = [NSMutableData new];
    uint8_t startcode[] = {0x00, 0x00, 0x00, 0x01};
    [annexBData appendBytes:nalPartition length:4];
    [annexBData appendData:sps];
    [annexBData appendBytes:nalPartition length:4];
    [annexBData appendData:pps];
  1. 将AVCC/HVCC格式中表示长度的4字节替换为start code
    // 获取编码数据。这里的数据是 AVCC/HVCC 格式的。
    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;
        static const int NALULengthHeaderLength = 4;
        // 拷贝编码数据。
        while (bufferOffset < totalLength - NALULengthHeaderLength) {
            // 通过 length 字段获取当前这个 NALU 的长度。
            uint32_t NALUnitLength = 0;
            memcpy(&NALUnitLength, dataPointer + bufferOffset, NALULengthHeaderLength);
            NALUnitLength = CFSwapInt32BigToHost(NALUnitLength);
            // 添加start code    
            [annexBData appendData:[NSData dataWithBytes:startcode length:4]];
            [annexBData appendData:[NSData dataWithBytes:(dataPointer + bufferOffset + NALULengthHeaderLength) length:NALUnitLength]];                
            bufferOffset += NALULengthHeaderLength + NALUnitLength;
        }
    }

之后就可以进行上传推流或者直接将H.264/H.265写入文件了。

6. 结束编码

    // 强制完成部分或所有挂起的帧
    VTCompressionSessionCompleteFrames(_compressionSession, kCMTimeInvalid);
    // 销毁编码器
    VTCompressionSessionInvalidate(_compressionSession);
    // 释放内存
    CFRelease(_compressionSession);

按是否需要转化格式来分,iOS硬编码流程图如下:

解码

iOS视频硬解码是个从CMSampleBuffer中获取视频帧CVImageBuffer和用于播放所必须的显示时间戳presentationTimeStamp和显示持续时间presentationDuration的过程,Video Toolbox用于解码的类是VTDecompressionSession,文档也介绍了使用VTDecompressionSession进行视频硬解码的工作流程:

1. Create a decompression session by calling VTDecompressionSessionCreate.
   // 调用VTDecompressionSessionCreate创建一个解码会话
2. Optionally, configure the session with your desired Decompression Properties by calling VTSessionSetProperty or VTSessionSetProperties.
   // 可选地,通过调用VTSessionSetProperty或VTSessionSetProperties来配置解码器属性。
3. Decode video frames using VTDecompressionSessionDecodeFrame.
   // 解码视频帧使用VTDecompressionSessionDecodeFrame,并在解码会话的VTDecompressionOutputCallbackRecord回调中处理解码后的视频帧、显示时间戳、显示持续时间等信息。
4. When you finish with the decompression session, call VTDecompressionSessionInvalidate to tear it down, and call CFRelease to free its memory.
   // 完成解码会话时,调用VTDecompressionSessionInvalidate来删除它,并调用CFRelease来释放它的内存。

结合实际的解码和后续处理,逐步解析相关API:

1. 创建解码会话VTDecompressionSessionRef

/*参数解析
allocator: session的内存分配器,传NULL表示默认的分配器。
videoFormatDescription: 源视频帧的描述信息。
videoDecoderSpecification: 指定必须使用的特定视频解码器。传NULL则Video Toolbox会自动选择一个解码器。
destinationImageBufferAttributes: 目标图像的属性要求。传NULL则不作要求。
outputCallback: 解码的回调函数。只能在调用VTDecompressionSessionDecodeFrameWithOutputHandler解码帧时传递NULL。
decompressionSessionOut: 指向一个变量以接收新的解码会话。
*/
VT_EXPORT OSStatus 
VTDecompressionSessionCreate(
	CM_NULLABLE CFAllocatorRef                              allocator,
	CM_NONNULL CMVideoFormatDescriptionRef					videoFormatDescription,
	CM_NULLABLE CFDictionaryRef								videoDecoderSpecification,
	CM_NULLABLE CFDictionaryRef                             destinationImageBufferAttributes,
	const VTDecompressionOutputCallbackRecord * CM_NULLABLE outputCallback,
	CM_RETURNS_RETAINED_PARAMETER CM_NULLABLE VTDecompressionSessionRef * CM_NONNULL decompressionSessionOut) API_AVAILABLE(macosx(10.8), ios(8.0), tvos(10.2));

其中destinationImageBufferAttributes可以设置图像显示的分辨率(kCVPixelBufferWidthKey/kCVPixelBufferHeightKey)、像素格式(kCVPixelBufferPixelFormatTypeKey)、是兼容OpenGL(kCVPixelBufferOpenGLCompatibilityKey)等等,例如:

    NSDictionary *destinationImageBufferAttributes = @{
        (id)kCVPixelBufferPixelFormatTypeKey: [NSNumber numberWithInt:kCVPixelFormatType_420YpCbCr8BiPlanarFullRange],
        (id)kCVPixelBufferWidthKey: [NSNumber numberWithInteger:_config.width],
        (id)kCVPixelBufferHeightKey: [NSNumber numberWithInteger:_config.height],
        (id)kCVPixelBufferOpenGLCompatibilityKey: [NSNumber numberWithBool:true]
      };

videoFormatDescription需要分情况处理: 如果是通过AVAssetReader或者其他方式可以直接获取到CMSampleBuffer,我们可以直接调用CMSampleBufferGetFormatDescription(sampleBuffer)获取。 如果是从流媒体读取等无法直接获取CMSampleBuffer的情况,我们需要从Annex-B格式转为AVCC/HVCC,并且手动创建videoFormatDescription和CMSampleBuffer。

格式转换过程与编码正好相反:将start code 转为4字节的NALU长度;判断数据类型,如果是参数集则存储用于初始化解码器,如果是帧数据则进行解码。

    // 从流媒体读取NSData
    NSData *h264Data = ...;
    uint8_t *nalu = (uint8_t *)h264Data.bytes;
    // 获取NALU类型,以H.264为例
    int type = (naluData[4] & 0x1F);
    // 将start code 转为4字节的NALU长度
    uint32_t naluSize = frameSize - 4;
    uint8_t *pNaluSize = (uint8_t *)(&naluSize);    
    naluData[0] = *(pNaluSize + 3);
    naluData[1] = *(pNaluSize + 2);
    naluData[2] = *(pNaluSize + 1);
    naluData[3] = *(pNaluSize);
    // 处理不同类型数据
    switch (type) {
        case 0x05:
         // I帧,去解码(解码前先确保解码器会话存在,否则就创建)
            break;
        case 0x06:
            // SEI信息,不处理,H.265也有一些不用处理的信息,详情可以去了解一下H.265的type表
            break;
        case 0x07:
            // sps
            _spsSize = naluSize;
            _sps = malloc(_spsSize);
            // 从下标4(也就是第五个元素)开始复制数据
            memcpy(_sps, &naluData[4], _spsSize);
            break;
        case 0x08:
            // pps
            _ppsSize = naluSize;
            _pps = malloc(_ppsSize);
            // 从下标4(也就是第五个元素)开始复制数据
            memcpy(_pps, &naluData[4], _ppsSize);
            break;
        default:
            // 其他帧(1-5),去解码(解码前先确保解码器会话存在,否则就创建)
            break;
    }

使用参数集创建CMVideoFormatDescriptionRef:

    CMVideoFormatDescriptionRef videoDesc;
    const uint8_t * const parameterSetPointers[2] = {_sps, _pps};
    const size_t parameterSetSizes[2] = {_spsSize, _ppsSize};
    int naluHeaderLen = 4;  // 大端模式起始位长度
    OSStatus status = CMVideoFormatDescriptionCreateFromH264ParameterSets(kCFAllocatorDefault, 2, parameterSetPointers, parameterSetSizes, naluHeaderLen, &videoDesc);

2. 配置解码器属性

/*
session: 会话
propertyKey: 属性名称
propertyValue: 属性值,设置为NULL将恢复默认值。
*/
VT_EXPORT OSStatus 
VTSessionSetProperty(
  CM_NONNULL VTSessionRef       session,
  CM_NONNULL CFStringRef        propertyKey,
  CM_NULLABLE CFTypeRef         propertyValue ) API_AVAILABLE(macosx(10.8), ios(8.0), tvos(10.2));

苹果官方文档列举了可配置的key目前有24个,不再赘述,可查阅Decompression Properties

3. 开始解码

/* 
session: 解码器会话
sampleBuffer: 包含一个或多个视频帧的CMSampleBuffer对象
decodeFlags: kVTDecodeFrame EnableAsynchronousDecompression位表示视频解码器是否可以异步解压缩帧。kVTDecodeFrame EnableTemporalProcessing位指示解码器是否可以延迟对输出回调的调用,以便以时间(显示)顺序进行处理。如果这两个标志都被清除,解压完成,输出回调函数将在VTDecompressionSessionDecodeFrame返回之前被调用。如果设置了其中一个标志,VTDecompressionSessionDecodeFrame可能会在调用输出回调函数之前返回。
sourceFrameRefCon: 解码标识。如果sampleBuffer包含多个帧,输出回调函数将使用这个sourceFrameRefCon值多次调用。
infoFlagsOut: 指向VTEncodeInfoFlags指针,用于接收有关解码操作的信息。如果解码正在进行,可以设置kVTDecodeInfo_Asynchronous,如果帧被删除;可以设置kVTDecodeInfo_FrameDropped;如果不想接收次信息可以传NUL。
*/
VTDecompressionSessionDecodeFrame(
	CM_NONNULL VTDecompressionSessionRef	session,
	CM_NONNULL CMSampleBufferRef			sampleBuffer,
	VTDecodeFrameFlags						decodeFlags, // bit 0 is enableAsynchronousDecompression
	void * CM_NULLABLE						sourceFrameRefCon,
	VTDecodeInfoFlags * CM_NULLABLE 		infoFlagsOut) API_AVAILABLE(macosx(10.8), ios(8.0), tvos(10.2));

其中的sampleBuffer如果需要从NSData中读取,我们需要使用视频帧的数据创建CMBlockBuffer,再结合参数集信息创建CMSampleBuffer:

    CVPixelBufferRef outputPixelBuffer = NULL;
    CMBlockBufferRef blockBuffer = NULL;
    CMBlockBufferFlags flag0 = 0;
    // 创建blockBuffer
    OSStatus status = CMBlockBufferCreateWithMemoryBlock(kCFAllocatorDefault, frame, frameSize, kCFAllocatorNull, NULL, 0, frameSize, flag0, &blockBuffer);
    CMSampleBufferRef sampleBuffer = NULL;
    const size_t sampleSizeArray[] = {frameSize};
    // 创建sampleBuffer
    status = CMSampleBufferCreateReady(kCFAllocatorDefault, blockBuffer, _videoDesc, 1, 0, NULL, 1, sampleSizeArray, &sampleBuffer);

4. 处理解码后的数据

在解码的回调videoDecoderCallBack中我们可以获取图像和显示时间戳、显示持续时间,结合OpenGL、Core Imge进行显示,不再深入介绍。

5. 结束解码

// 销毁解码器
VTDecompressionSessionInvalidate(self.decodeSession);
// 释放内存
CFRelease(self.decodeSession);

iOS不同场景的硬解码流程图如下

注意事项

  1. 前后台切换会导致编解码出错,需要重新创建会话。
  2. 有些视频流虽然是AVCC格式,但NALU size的大小是3个字节,需要转为4字节格式。
  3. 切换分辨率时需要拿到新的参数集,重启解码器。
  4. 编码后的视频帧之间存在参考关系,为避免遗漏最后几帧,需要在解码完最后一帧数据后调用VTDecompressionSessionWaitForAsynchronousFrames,该接口会等待所有未输出的视频帧输出结束后再返回。

参考链接

Video Toolbox苹果官方文档
Overview of the High Efficiency Video Coding (HEVC) Standard
H.264标准文档

おすすめ

転載: juejin.im/post/7116148824237670436