在 iOS 8 之前,由于苹果并没有开放视频硬编解码的接口,因此大部分涉及视频处理的 App 都只能使用 CPU 来编解码视频,也就是俗称的软解码。然而使用 CPU 做这件事有着很明显的缺陷:耗电量过大,手机卡顿,处理速度太慢等等。终于,在 WWDC 2014 的时候,随 iOS 8 的发布苹果也开放了视频硬编解码的接口,也就是 VideoToolbox 框架。这套框架的接口可以说简单易用,作为对比,直到现在 Android 上的视频处理功能也十分原始。例如微信团队的这篇文章就介绍了他们在 Android 上录制音视频遇到的各种坑。虽然 VideoToolbox 可以说使用起来相对简单,但关于视频的领域知识依然比较繁杂,因此在接下来我会尽量解释相关概念,部分截图取自 WWDC 2014 Session 513

目前在 iOS 上视频处理框架主要是下面这个分层:

对于 AVKitAVFoundationVideoToolbox 来说,他们的功能和可定制性越来越强,但相应的使用难度也越大,因此你应该根据实际需求合理的选择使用哪个层级的接口。事实上,即使你使用 AVFoundationAVKit 依然可以获得硬件加速的效果,你失去的只是直接访问硬编解码器的权限。对于 VideoToolbox 来说,你可以通过直接访问硬编解码器,将 H.264 文件或传输流转换为 iOS 上的 CMSampleBuffer 并解码成 CVPixelBuffer,或将为压缩的 CVPixelBuffer 编码成 CMSampleBuffer

  • H.264 -> CMSampleBuffer -> CVPixelBuffer
  • CVPixelBuffer -> CMSampleBuffer -> H.264

例如,调用 AVCaptureSession 拍摄输出的每一帧图像都会被包装成 CMSampleBuffer 对象,通过这个 CMSampleBuffer 对象你就可以获取到未压缩的 CVPixelBuffer 对象;如果读取 H.264 文件你也可以获取数据生成压缩的 CMBlockBuffer 对象并创建一个 CMSampleBuffer 对象给 VideoToolbox 来解码。

也就是说,CMSampleBuffer 既可以作为 CVPixelBuffer 对象的容器,也可以作为 CMBlockBuffer 对象的容器,CVPixelBuffer 可以说是未压缩的图像数据容器,而 CMBlockBuffer 则是压缩图像数据容器。

硬编码

硬编码主要围绕 VTCompressionSession 这个对象的使用而展开,它大概有如下这么一个生命周期:

  1. 调用 VTCompressionSessionCreate 创建
  2. 使用 VTSessionSetProperty 配置必要的参数
  3. 调用 VTCompressionSessionPrepareToEncodeFrames 准备编码
  4. 当获取到 CVPixelBuffer 对象时,使用 VTCompressionSessionEncodeFrame 函数进行编码
  5. 调用 VTCompressionSessionCompleteFrames 完成编码
  6. 调用 VTCompressionSessionInvalidate 销毁 VTCompressionSession 对象

我写了一个 Demo 项目用来描述如何使用 VTCompressionSession 将摄像头捕捉到的 CMSampleBuffer 对象编码到一个 H.264 文件里,完整的代码在这里

略过 AVCaptureSession 的配置,我们主要看一下如何使用 VTCompressionSession

func captureOutput(_ output: AVCaptureOutput, didOutput sampleBuffer: CMSampleBuffer, from connection: AVCaptureConnection) {
    guard let pixelbuffer = CMSampleBufferGetImageBuffer(sampleBuffer) else {
        return
    }
        
    if compressionSession == nil {
        let width = CVPixelBufferGetWidth(pixelbuffer)
        let height = CVPixelBufferGetHeight(pixelbuffer)
         
        // (1)
        VTCompressionSessionCreate(kCFAllocatorDefault, Int32(width), Int32(height), kCMVideoCodecType_H264, nil, nil, nil, compressionOutputCallback,
                                       UnsafeMutableRawPointer(Unmanaged.passUnretained(self).toOpaque()), &compressionSession)
            
        guard let c = compressionSession else {
            return
        }
           
        // (2) 
        // set profile to Main
        VTSessionSetProperty(c, kVTCompressionPropertyKey_ProfileLevel, kVTProfileLevel_H264_Main_AutoLevel)
        // capture from camera, so it's real time
        VTSessionSetProperty(c, kVTCompressionPropertyKey_RealTime, true as CFTypeRef)
        // 关键帧间隔
        VTSessionSetProperty(c, kVTCompressionPropertyKey_MaxKeyFrameInterval, 10 as CFTypeRef)
        // 比特率和速率
        VTSessionSetProperty(c, kVTCompressionPropertyKey_AverageBitRate, width * height * 2 * 32 as CFTypeRef)
        VTSessionSetProperty(c, kVTCompressionPropertyKey_DataRateLimits, [width * height * 2 * 4, 1] as CFArray)
          
        // (3)  
        VTCompressionSessionPrepareToEncodeFrames(c)
    }
        
    guard let c = compressionSession else {
        return
    }
        
    guard isCapturing else {
        return
    }
        
    compressionQueue.sync {
        pixelbuffer.lock(.readwrite) {
            let presentationTimestamp = CMSampleBufferGetOutputPresentationTimeStamp(sampleBuffer)
            let duration = CMSampleBufferGetOutputDuration(sampleBuffer)
            // (4)
            VTCompressionSessionEncodeFrame(c, pixelbuffer, presentationTimestamp, duration, nil, nil, nil)
        }
    }
}

通过 CVPixelBuffer 对象我们获取到这个视频每帧图片的宽高属性 widthheight,在(1)处,使用 widthheightkCMVideoCodecType_H264 这个参数创建 VTCompressionSession 对象;在(2)处,我们设置了几个重点属性,其中 kVTCompressionPropertyKey_MaxKeyFrameInterval 属性非常重要,它控制最后生成的 H.264 文件中关键帧的出现频率,也就是关键帧间隔。说到关键帧,那么有几个概念必须得解释一下。

  • NALU. 对于一个 H.264 裸流或者文件来说,它是由一个一个的 NALU(Network Abstraction Layer Unit) 单元组成,每个 NALU 既可以表示图像数据,也可以表示处理图像所需要的参数数据。它主要有两种格式:Annex BAVCC。也被称为 Elementary StreamMPEG-4 格式,Annex B 格式以 0x0000010x00000001 开头,AVCC 格式以所在的 NALU 的长度开头。
  • Parameter Set. 主要有 Sequence Parameter Set(简称 sps)Picture Parameter Set(简称 pps)。其中,sps 作用比较重要,它描述了接下来视频序列帧的通用参数,对于视频的编解码至关重要。例如,解析完 sps 后可以得到一个叫 vui 的参数单元,它的部分参数如下:

    利用这些参数就可以计算出视频的帧率信息。关于 sps 更具体的信息可以参考这篇博客

  • I/P/B frame. 这三个主要的帧格式就是视频的数据单元。其中,I frame 是一个自包含的帧,它的编解码不依赖其他帧,而 P frame 则依赖它前序的 I frameB frame 则既依赖它前序的帧也依赖它后面的帧。

一个典型的 Elementary Stream 格式的 H.264 裸流如图:

下面我们来看看编码完成的回调函数:

func compressionOutputCallback(outputCallbackRefCon: UnsafeMutableRawPointer?,
                               sourceFrameRefCon: UnsafeMutableRawPointer?,
                               status: OSStatus,
                               infoFlags: VTEncodeInfoFlags,
                               sampleBuffer: CMSampleBuffer?) -> Swift.Void {
    guard status == noErr else {
        print("error: \(status)")
        return
    }
    
    if infoFlags == .frameDropped {
        print("frame dropped")
        return
    }
    
    guard let sampleBuffer = sampleBuffer else {
        print("sampleBuffer is nil")
        return
    }
    
    if CMSampleBufferDataIsReady(sampleBuffer) != true {
        print("sampleBuffer data is not ready")
        return
    }
    
    let vc: ViewController = Unmanaged.fromOpaque(outputCallbackRefCon!).takeUnretainedValue()
    
    if let attachments = CMSampleBufferGetSampleAttachmentsArray(sampleBuffer, true) {
        let rawDic: UnsafeRawPointer = CFArrayGetValueAtIndex(attachments, 0)
        let dic: CFDictionary = Unmanaged.fromOpaque(rawDic).takeUnretainedValue()
        
        // if not contains means it's an IDR frame
        let keyFrame = !CFDictionaryContainsKey(dic, Unmanaged.passUnretained(kCMSampleAttachmentKey_NotSync).toOpaque())
        if keyFrame {
            // sps
            let format = CMSampleBufferGetFormatDescription(sampleBuffer)
            var spsSize: Int = 0
            var spsCount: Int = 0
            var nalHeaderLength: Int32 = 0
            var sps: UnsafePointer<UInt8>?
            if CMVideoFormatDescriptionGetH264ParameterSetAtIndex(format!,
                                                                  0,
                                                                  &sps,
                                                                  &spsSize,
                                                                  &spsCount,
                                                                  &nalHeaderLength) == noErr {
                // pps
                var ppsSize: Int = 0
                var ppsCount: Int = 0
                var pps: UnsafePointer<UInt8>?
                
                if CMVideoFormatDescriptionGetH264ParameterSetAtIndex(format!,
                                                                      1,
                                                                      &pps,
                                                                      &ppsSize,
                                                                      &ppsCount,
                                                                      &nalHeaderLength) == noErr {
                    let spsData: NSData = NSData(bytes: sps, length: spsSize)
                    let ppsData: NSData = NSData(bytes: pps, length: ppsSize)
                    
                    // save sps/pps to file
                    vc.handle(sps: spsData, pps: ppsData)
                }
            }
        } // end of handle sps/pps
        
        // handle frame data
        guard let dataBuffer = CMSampleBufferGetDataBuffer(sampleBuffer) else {
            return
        }
        
        var lengthAtOffset: Int = 0
        var totalLength: Int = 0
        var dataPointer: UnsafeMutablePointer<Int8>?
        if CMBlockBufferGetDataPointer(dataBuffer, 0, &lengthAtOffset, &totalLength, &dataPointer) == noErr {
            var bufferOffset: Int = 0
            let AVCCHeaderLength = 4
            
            while bufferOffset < (totalLength - AVCCHeaderLength) {
                var NALUnitLength: UInt32 = 0
                // first four character is NALUnit length
                memcpy(&NALUnitLength, dataPointer?.advanced(by: bufferOffset), AVCCHeaderLength)
                
                // big endian to host endian. in iOS it's little endian
                NALUnitLength = CFSwapInt32BigToHost(NALUnitLength)
                
                let data: NSData = NSData(bytes: dataPointer?.advanced(by: bufferOffset + AVCCHeaderLength), length: Int(NALUnitLength))
                vc.encode(data: data, isKeyFrame: keyFrame)
                
                // move forward to the next NAL Unit
                bufferOffset += Int(AVCCHeaderLength)
                bufferOffset += Int(NALUnitLength)
            }
        }
    }
}

正如上面介绍的,一个 NALU 中的图像帧序列有 I/P/B frame 三种,其中,I frame 非常重要,P frameB frame 的编解码都需要依赖与他们相关的一个 I frame,然而,仅仅依靠 CMBlockBuffer 对象并不能判断它是否是一个 I frame,因此,在 CMSampleBuffer 对象中有 一个 Attachment 数组,用来描述这帧图像的一些属性,通过 CMSampleBufferGetSampleAttachmentsArray 方法获取:

// I frame
attachments: (
        {
        DependsOnOthers = 0;
        EncoderRetryCount = 0;
    }
)

// P/B frame
attachments: (
        {
        DependsOnOthers = 1;
        EncoderRetryCount = 0;
        NotSync = 1;
    }
)

容易看出,通过检查是否含有 NotSync 这个键就可以判断这个帧是否为 I frame,正如代码里所示。

从上面的 CMSampleBuffer 对象结构图可以看出来,CMSampleBuffer 中含有一个 CMVideoFormatDescription 对象,它表示这个视频帧的图像属性,其中就包括 spspps 这两个属性。由于 I frame 的重要性,当它出现的时候,我们应该检查这帧 CMSampleBuffer 对应的 CMVideoFormatDescription 属性,获取 spspps 数据,并将它写入 H.264 文件或传输流中去:

fileprivate var NALUHeader: [UInt8] = [0, 0, 0, 1]

func handle(sps: NSData, pps: NSData) {
    guard let fh = fileHandler else {
        return
    }
    
    let headerData: NSData = NSData(bytes: NALUHeader, length: NALUHeader.count)
    fh.write(headerData as Data)
    fh.write(sps as Data)
    fh.write(headerData as Data)
    fh.write(pps as Data)
}

正如上面所说的:每一个 NALU 由一个 0x0000010x00000001 开头。这里我们使用后者,将其分别写在 spspps 数据的头部。

接下来,我们就需要处理这一帧视频的图像数据了。通过 CMSampleBufferGetDataBufferCMBlockBufferGetDataPointer 我们可以获取视频数据的内存地址。VTCompressionSession 编码出来的视频帧为 AVCC 格式,因此我们可以读取头部 4 个字节数据来获取当前 NALU 的长度。这里有一个需要注意的是,AVCC 格式使用大端字节序,它可能跟当前使用的系统字节序不一样,事实上,iOS 系统使用小端字节序,因此我们需要先将这个长度数据转换为 iOS 系统使用的小端字节序:

NALUnitLength = CFSwapInt32BigToHost(NALUnitLength)

然后我们就可以从 dataPointer 中获取 NALUnitLength 长度的数据,作为一个 NALU 数据写入文件中:

func encode(data: NSData, isKeyFrame: Bool) {
    guard let fh = fileHandler else {
        return
    }

    let headerData: NSData = NSData(bytes: NALUHeader, length: NALUHeader.count)
    fh.write(headerData as Data)
    fh.write(data as Data)
}

最后,调用 VTCompressionSessionCompleteFramesVTCompressionSessionInvalidate 就可以完成编码并销毁这个 VTCompressionSession 对象了。你可以使用 iExplorer 工具从 Demo 的沙盒中提取最终的 H.264 文件,并使用 mpviina 播放器播放。

硬解码

当然,你也可以使用 VideoToolbox 来播放刚才录制出来的 H.264 文件。这里我会使用这个项目来解释 VTDecompressionSession 的使用。

类似 VTCompressionSession,硬解码也围绕 VTDecompressionSession 的使用而展开:

  1. 创建:VTDecompressionSessionCreate
  2. 解码:VTDecompressionSessionDecodeFrame
  3. 完成:VTDecompressionSessionFinishDelayedFrames
  4. 销毁:VTDecompressionSessionInvalidate

创建一个 VTDecompressionSession 需要知道 spspps 参数,因此需要首先读取出 H.264 文件中的 spspps 这两个 NALU

func netPacket() -> VideoPacket? {
    if streamBuffer.count == 0 && readStremData() == 0{
        return nil
    }
    
    // make sure start with start code
    if streamBuffer.count < 5 || Array(streamBuffer[0...3]) != startCode {
        return nil
    }
    
    // find second start code , so startIndex = 4
    var startIndex = 4
    
    while true {
        while ((startIndex + 3) < streamBuffer.count) {
            if Array(streamBuffer[startIndex...startIndex+3]) == startCode {
                
                let packet = Array(streamBuffer[0..<startIndex])
                streamBuffer.removeSubrange(0..<startIndex)
                
                return packet
            }
            startIndex += 1
        }
        
        // not found next start code, read more data
        if readStremData() == 0 {
            return nil
        }
    }
}

fileprivate func readStremData() -> Int{
    if let stream = fileStream, stream.hasBytesAvailable{
        var tempArray = Array<UInt8>(repeating: 0, count: bufferCap)
        let bytes = stream.read(&tempArray, maxLength: bufferCap)
        
        if bytes > 0 {
            streamBuffer.append(contentsOf: Array(tempArray[0..<bytes]))
        }
        return bytes
    }
    return 0
}

通过 readStreamData 方法读取 bufferCap 长度的数据到 streamBuffer 数组中,然后,通过查找下一个以 0x00000001 开头的子数组可以找出当前这个 NALU 的数据。

对于以 4 字节 0x00000001 开头的 NALU 来说,通过将第 5 个字节与 0x1F 取并操作可以用来判断这个 NALU 的类型:

let nalType = videoPacket[4] & 0x1F
        
switch nalType {
case 0x05:
    print("Nal type is IDR frame")
    if createDecompSession() {
        decodeVideoPacket(videoPacket)
    }
case 0x07:
    print("Nal type is SPS")
    spsSize = videoPacket.count - 4
    sps = Array(videoPacket[4..<videoPacket.count])
case 0x08:
    print("Nal type is PPS")
    ppsSize = videoPacket.count - 4
    pps = Array(videoPacket[4..<videoPacket.count])
default:
    print("Nal type is B/P frame")
    decodeVideoPacket(videoPacket)
    break;
}

想了解 NALU 都有哪些类型可以看这里。这里主要处理了 0x05 对应的 I frame0x07 对应的 sps0x08 对应的 pps 单元。当获取到 spspps 后就可以调用 createDecompression 方法创建 VTDecompressionSession 对象了:

func createDecompSession() -> Bool{
    formatDesc = nil
        
    if let spsData = sps, let ppsData = pps {
        let pointerSPS = UnsafePointer<UInt8>(spsData)
        let pointerPPS = UnsafePointer<UInt8>(ppsData)
            
        // make pointers array
        let dataParamArray = [pointerSPS, pointerPPS]
        let parameterSetPointers = UnsafePointer<UnsafePointer<UInt8>>(dataParamArray)
            
        // make parameter sizes array
        let sizeParamArray = [spsData.count, ppsData.count]
        let parameterSetSizes = UnsafePointer<Int>(sizeParamArray)
            
        let status = CMVideoFormatDescriptionCreateFromH264ParameterSets(kCFAllocatorDefault, 2, parameterSetPointers, parameterSetSizes, 4, &formatDesc)
            
        if let desc = formatDesc, status == noErr {
            if let session = decompressionSession {
                VTDecompressionSessionInvalidate(session)
                decompressionSession = nil
            }
                
            var videoSessionM : VTDecompressionSession?
                
            let decoderParameters = NSMutableDictionary()
            let destinationPixelBufferAttributes = NSMutableDictionary()
            destinationPixelBufferAttributes.setValue(NSNumber(value: kCVPixelFormatType_420YpCbCr8BiPlanarFullRange as UInt32), forKey: kCVPixelBufferPixelFormatTypeKey as String)
                
            var outputCallback = VTDecompressionOutputCallbackRecord()
            outputCallback.decompressionOutputCallback = decompressionSessionDecodeFrameCallback
            outputCallback.decompressionOutputRefCon = UnsafeMutableRawPointer(Unmanaged.passUnretained(self).toOpaque())
                
            let status = VTDecompressionSessionCreate(kCFAllocatorDefault, desc, decoderParameters, destinationPixelBufferAttributes, &outputCallback, &videoSessionM)
                
            if(status != noErr) {
                print("\t\t VTD ERROR type: \(status)")
            }
                
            self.decompressionSession = videoSessionM
        } else {
            print("IOS8VT: reset decoder session failed status=\(status)")
        }
    }
        
    return true
}

通过读取出来的 spspps 信息创建一个 CMVideoFormatDescription 对象,指定 pixelBufferAttributes 和解码回调函数创建 VTDecompressionSession 对象。当读取到视频帧数据时就可以调用 decodeVideoPacket 方法解码:

func decodeVideoPacket(_ videoPacket: VideoPacket) {
    let bufferPointer = UnsafeMutablePointer<UInt8>(mutating: videoPacket)
    var blockBuffer: CMBlockBuffer?
    var status = CMBlockBufferCreateWithMemoryBlock(kCFAllocatorDefault,bufferPointer, videoPacket.count, kCFAllocatorNull, nil, 0, videoPacket.count, 0, &blockBuffer)
    if status != kCMBlockBufferNoErr {
        return
    }
    
    var sampleBuffer: CMSampleBuffer?
    let sampleSizeArray = [videoPacket.count]
    
    status = CMSampleBufferCreateReady(kCFAllocatorDefault,
                                       blockBuffer,
                                       formatDesc,
                                       1, 0, nil,
                                       1, sampleSizeArray,
                                       &sampleBuffer)
    
    if let buffer = sampleBuffer, let session = decompressionSession, status == kCMBlockBufferNoErr {
        let attachments:CFArray? = CMSampleBufferGetSampleAttachmentsArray(buffer, true)
        if let attachmentArray = attachments {
            let dic = unsafeBitCast(CFArrayGetValueAtIndex(attachmentArray, 0), to: CFMutableDictionary.self)
            CFDictionarySetValue(dic, Unmanaged.passUnretained(kCMSampleAttachmentKey_DisplayImmediately).toOpaque(), Unmanaged.passUnretained(kCFBooleanTrue).toOpaque())
        }
        
        // or decompression to CVPixcelBuffer
        var flagOut = VTDecodeInfoFlags(rawValue: 0)
        var outputBuffer = UnsafeMutablePointer<CVPixelBuffer>.allocate(capacity: 1)
        status = VTDecompressionSessionDecodeFrame(session, buffer, [._EnableAsynchronousDecompression], &outputBuffer, &flagOut)
    }
}

首先通过刚才读取出来的 videoPacket 数据创建一个 CMBlockBuffer 对象,并接着用它和刚才创建的 CMVideoFormatDescription 对象 formatDesc 生成一个 CMSampleBuffer 对象,并调用 VTDecompressionSessionDecodeFrame 解码这个 CMSampleBuffer 对象。

总结

总体来说,VideoToolbox 的使用还算是比较简单,不过这里我只用到了比较基础的部分,可能 Demo 里面也有使用不规范的地方,如有错误,还望指出:)

此外,这两个 Demo 里关于指针的使用也值得学习,比如使用 UnmanagedAnyObjectUnsafe|Mutable|RawPointer 之间转换,使用数组替代 UnsafeMutablePointer 等等。具体使用可以参考苹果官方的文档

参考