VideoToolbox解析

kim_jin發表於2017-12-13

由於公司專案的原因,一開始參照github上的kxmovie,利用FFMPEG和OpenGL寫了一個RTMPVideoPlayer。在播放解析的過程中,因為CPU和Memory的使用率比較大,手機播放久了會發熱。所以就只能想辦法解決這個問題了。在網上搜了一天的資料,發現iOS 8.0以後,Apple開放了VideoToolbox這個framework,可以用於視訊的硬編碼。可是在Apple的開發者官網找了好久都沒有找到相關的資料啊,簡直欲哭無淚啊。。後來只能在Stack Overflow和Apple的視訊裡面找到資料。把坑給填好。


介面概述

在iOS中,與視訊相關的介面有5個,從頂層開始分別是 AVKit - AVFoundation - VideoToolbox - Core Media - Core Video

其中VideoToolbox可以將視訊解壓到CVPixelBuffer,也可以壓縮到CMSampleBuffer。

如果需要使用硬編碼的話,在5個介面中,就需要用到AVKit,AVFoundation和VideoToolbox。在這裡我就只介紹VideoToolbox。

VideoToolbox物件

  • CVPixelBuffer - 未壓縮光柵影象快取區(Uncompressed Raster Image Buffer)

CVPixelBuffer

  • CVPixelBufferPool - 顧名思義,存放CVPixelBuffer

CVPixelBufferPool

  • pixelBufferAttributes - CFDictionary物件,可能會包含視訊的寬高,畫素格式型別(32RGBA, YCbCr420),是否可以用於OpenGL ES等相關資訊

  • CMTime - 分子是64-bit的時間值,分母是32-bit的時標(time scale)

  • CMVideoFormatDescription - 視訊寬高,格式(kCMPixelFormat_32RGBA, kCMVideoCodecType_H264), 其他諸如顏色空間等資訊的擴充套件

  • CMBlockBuffer -

CMBlockBuffer

  • CMSampleBuffer - 對於壓縮的視訊幀來說,包含了CMTime,CMVideoFormatDesc和CMBlockBuffer;對於未壓縮的光柵影象的話,則包含了CMTime,CMVideoFormatDesc和CMPixelBuffer

CMSampleBuffer

  • CMClock - 封裝了時間源,其中CMClockGetHostTimeClock()封裝了mach_absolute_time()

  • CMTimebase - CMClock上的控制檢視。提供了時間的對映:CMTimebaseSetTime(timebase, kCMTimeZero);; 速率控制: CMTimebaseSetRate(timebase, 1.0);

CMTimebase

Case One - 播放視訊流檔案

使用VideoToolbox硬編碼來播放網路上的流檔案時,整個完整的流程是這樣的:獲取網路檔案 -> 獲取多個已壓縮的H.264取樣 -> 呼叫AVSampleBufferDisplayLayer -> 播放

Overview

更詳細點看的話,在AVSamplerBufferDisplayLayer這一層中,我們還需要將視訊解碼到CVPixelBuffer中

More Detail

處理過程

下面要介紹的就是流檔案到CMSampleBuffers的H.264的處理過程:

在H.264的語法中,有一個最基礎的層,叫做Network Abstraction Layer, 簡稱為NAL。H.264流資料正是由一系列的NAL單元(NAL Unit, 簡稱NALU)組成的。

NALUs

一個NALU可能包含有:

  • 視訊幀(或者是視訊幀的片段) - P幀, I幀, B幀

Video Frame

  • H.264屬性集合:Sequence Parameter Set(SPS)和Picture Parameter Set(PPS)

流資料中,屬性集合可能是這樣的:

Parameter Set in Stream

經過處理之後,在Format Description中則是:

Parameter Set in Format Description

要從基礎的流資料將SPS和PPS轉化為Format Desc中的話,需要呼叫CMVideoFormatDescriptionCreateFromH264ParameterSets()方法

NALU header

對於流資料來說,一個NALU的Header中,可能是0x00 00 01或者是0x00 00 00 01作為開頭(兩者都有可能,下面以0x00 00 01作為例子)。0x00 00 01因此被稱為開始碼(Start code).

Start Code

一個MP4檔案的話,則是以0x00 00 80 00作為開頭。因此要將基本流資料轉換成CMSampleBuffer的話,需CMBlockBuffer+CMVideoFormatDesc+CMTime(Optional)。我們可以呼叫CMSampleBufferCreate()來完成轉換

NALU Conversion

時間控制

如果需要控制每一幀圖片的顯示時間的話,可以通過CMTimebase進行時間的控制

Time

sbDisplayLayer.controlTimebase = CMTimebaseCreateWithMasterClock(CMClockGetHostTimeClock());
CMTimebaseSetTime(sbDisplayLayer.controlTimebase, CMTimeMake(5, 1));CMTimebaseSetRate(sbDisplayLayer.controlTimebase, 1.0);
複製程式碼

總結

播放一個網路流檔案的流程大概就是這樣,總結起來就是:

1) 建立AVSampleBufferDisplayLayer

2)將H.264基礎流轉換為CMSampleBuffer

3)將CMSampleBuffers提供給AVSampleBufferDisplayLayer

4)可以使用自定義的CMTimebase

Case Two - 從已壓縮的流中獲取CVPixelBuffers

AVSampleBufferDisplayLayer

獲取解碼器

Get Access to the Decoder

這個步驟中,我們所需要的有:

  • 源資料的描述 - CMVideoFormatDescription
  • 輸出快取所需要的引數 - pixelBufferAttributes:

e.g :

NSDictionary *destinationImageBufferAttributes = [NSDictionary dictionaryWithObjectsAndKeys:

[NSNumber numberWithBool:YES],(id)kCVPixelBufferOpenGLESCompatibilityKey,nil];
複製程式碼
  • 回撥函式 - VTDecompressionOutputCallback。該回撥函式接收一下引數: CVPixelBuffer輸出,時間戳,編碼的錯誤碼,丟棄的幀

以上為Apple的Keynote中的介紹,下面通過程式碼來解釋

在我自己的Project中,我利用FFMPEG和VideoToolbox來進行網路MP4檔案的解析。關於FFMPEG的部分我就不解釋了。只貼VideoToolbox硬解碼部分。

另外,關於H.264開始碼這部分相關的資訊,也可以參考我另一篇文章

...

// 利用FFMPEG的解碼器,獲取到sps和pps,IDR資料
// SPS和PPS資料在codec中的extradata中
// IDR資料在packet的data中
- (void)setupVideoDecoder {
  _pCodecCtx = _pFormatCtx->streams[_videoStream]->codec;
  
  while (av_read_frame(_pFormatCtx, &_packet) >= 0) {
    // Whether is video stream
    if (_packet.stream_index == _videoStream) {
      [self.videoDecoder decodeWithCodec:_pCodecCtx packet:_packet];
    }
  }
}

...
複製程式碼

Decoder.m

#import "UFVideoDecoder.h"

@interface UFVideoDecoder () {
  NSData *_spsData;
  NSData *_ppsData;
  VTDecompressionSessionRef _decompressionSessionRef;
  CMVideoFormatDescriptionRef _formatDescriptionRef;
  OSStatus _status;
}

@end

@implementation UFVideoDecoder

- (void)decodeWithCodec:(AVCodecContext *)codec packet:(AVPacket)packet {
  
  [self findSPSAndPPSInCodec:codec];
  [self decodePacket:packet];
}

#pragma mark - Private Methods
// 找尋SPS和PPS資料
- (void)findSPSAndPPSInCodec:(AVCodecContext *)codec {
  // 將用不上的位元組替換掉,在SPS和PPS前新增開始碼
  // 假設extradata資料為 0x01 64 00 0A FF E1 00 19 67 64 00 00...其中67開始為SPS資料
  //  則替換後為0x00 00 00 01 67 64...

// 使用FFMPEG提供的方法。
// 我一開始以為FFMPEG的這個方法會直接獲取到SPS和PPS,誰知道只是替換掉開始碼。
// 要注意的是,這段程式碼會一直報**Packet header is not contained in global extradata, corrupted stream or invalid MP4/AVCC bitstream**。可是貌似對資料獲取沒什麼影響。我就直接忽略了
  uint8_t *dummy = NULL;
  int dummy_size;
  AVBitStreamFilterContext* bsfc =  av_bitstream_filter_init("h264_mp4toannexb");
  av_bitstream_filter_filter(bsfc, codec, NULL, &dummy, &dummy_size, NULL, 0, 0);
  av_bitstream_filter_close(bsfc);
  
// 獲取SPS和PPS的資料和長度
  int startCodeSPSIndex = 0;
  int startCodePPSIndex = 0;
  uint8_t *extradata = codec->extradata;
  for (int i = 3; i < codec->extradata_size; i++) {
    if (extradata[i] == 0x01 && extradata[i-1] == 0x00 && extradata[i-2] == 0x00 && extradata[i-3] == 0x00) {
      if (startCodeSPSIndex == 0) startCodeSPSIndex = i + 1;
      if (i > startCodeSPSIndex) {
        startCodePPSIndex = i + 1;
        break;
      }
    }
  }
  
  // 這裡減4是因為需要減去PPS的開始碼的4個位元組
  int spsLength = startCodePPSIndex - 4 - startCodeSPSIndex;
  int ppsLength = codec->extradata_size - startCodePPSIndex;
  
  _spsData = [NSData dataWithBytes:&extradata[startCodeSPSIndex] length:spsLength];
  _ppsData = [NSData dataWithBytes:&extradata[startCodePPSIndex] length:ppsLength];

  if (_spsData != nil && _ppsData != nil) {
    // Set H.264 parameters
    const uint8_t* parameterSetPointers[2] = { (uint8_t *)[_spsData bytes], (uint8_t *)[_ppsData bytes] };
    const size_t parameterSetSizes[2] = { [_spsData length], [_ppsData length] };
// 建立CMVideoFormatDesc
    _status = CMVideoFormatDescriptionCreateFromH264ParameterSets(kCFAllocatorDefault, 2, parameterSetPointers, parameterSetSizes, 4, &_formatDescriptionRef);
    if (_status != noErr) NSLog(@"\n\nFormat Description ERROR: %d", (int)_status);
  }
  
  if (_status == noErr && _decompressionSessionRef == NULL) [self createDecompressionSession];
}

// 建立session
- (void)createDecompressionSession {
  // Make sure to destory the old VTD session
  _decompressionSessionRef = NULL;
  
// 回撥函式
  VTDecompressionOutputCallbackRecord callbackRecord;
  callbackRecord.decompressionOutputCallback = decompressionSessionDecodeFrameCallback;
// 如果需要在回撥函式中呼叫到self的話
  callbackRecord.decompressionOutputRefCon = (__bridge void*)self;
  
  // pixelBufferAttributes
  NSDictionary *destinationImageBufferAttributes = [NSDictionary dictionaryWithObjectsAndKeys:[NSNumber numberWithBool:YES], (id)kCVPixelBufferOpenGLCompatibilityKey, [NSNumber numberWithInt:kCVPixelFormatType_32BGRA], (id)kCVPixelBufferPixelFormatTypeKey, nil];
  _status = VTDecompressionSessionCreate(NULL, _formatDescriptionRef, NULL, (__bridge CFDictionaryRef)(destinationImageBufferAttributes), &callbackRecord, &_decompressionSessionRef);

  if(_status != noErr) NSLog(@"\t\t VTD ERROR type: %d", (int)_status);
}

// 回撥函式
void decompressionSessionDecodeFrameCallback(void *decompressionOutputRefCon, void *sourceFrameRefCon, OSStatus status, VTDecodeInfoFlags infoFlags, CVImageBufferRef imageBuffer, CMTime presentationTimestamp, CMTime presentationDuration) {
  UFVideoDecoder *decoder = (__bridge UFVideoDecoder*)decompressionOutputRefCon;
  if (status != noErr) {
    NSError *error = [NSError errorWithDomain:NSOSStatusErrorDomain code:status userInfo:nil];
    NSLog(@"Decompressed error: %@", error);
  } else {
    [decoder.delegate getDecodeImageData:imageBuffer];
  }
  
}

// 解析IDR或no-IDR資料
- (void)decodePacket:(AVPacket)packet {
  uint8_t* frame = packet.data;
  int size = packet.size;
  
  int startIndex = 4; // 資料都從第5位開始
  int nalu_type = ((uint8_t)frame[startIndex] & 0x1F);
  // 1為IDR,5為no-IDR
  if (nalu_type == 1 || nalu_type == 5) {
  // 建立CMBlockBuffer
    CMBlockBufferRef blockBufferRef = NULL;
    _status = CMBlockBufferCreateWithMemoryBlock(NULL, frame, size, kCFAllocatorNull, NULL, 0, size, 0, &blockBufferRef);
   
    // 移除掉前面4個位元組的資料
    int reomveHeaderSize = size - 4;
    const uint8_t sourceBytes[] = {(uint8_t)(reomveHeaderSize >> 24), (uint8_t)(reomveHeaderSize >> 16), (uint8_t)(reomveHeaderSize >> 8), (uint8_t)reomveHeaderSize};
    _status = CMBlockBufferReplaceDataBytes(sourceBytes, blockBufferRef, 0, 4);
    
    // CMSampleBuffer
    CMSampleBufferRef sbRef = NULL;
    //        int32_t timeSpan = 90000;
    //        CMSampleTimingInfo timingInfo;
    //        timingInfo.presentationTimeStamp = CMTimeMake(0, timeSpan);
    //        timingInfo.duration =  CMTimeMake(3000, timeSpan);
    //        timingInfo.decodeTimeStamp = kCMTimeInvalid;
    const size_t sampleSizeArray[] = {size};
    _status = CMSampleBufferCreate(kCFAllocatorDefault, blockBufferRef, true, NULL, NULL, _formatDescriptionRef, 1, 0, NULL, 1, sampleSizeArray, &sbRef);
    
    // 解析
    VTDecodeFrameFlags flags = kVTDecodeFrame_EnableAsynchronousDecompression;
    VTDecodeInfoFlags flagOut;
    _status = VTDecompressionSessionDecodeFrame(_decompressionSessionRef, sbRef, flags, &sbRef, &flagOut);
    CFRelease(sbRef);
  }
}

@end

複製程式碼

根據以下步驟的話,就可以完成流的硬編碼:

1) FFMPEG解析

2)獲取SPS和PPS資料,建立CMVideoFormatDescription物件

3)建立VTDecompressionSession:注意回撥函式和pixelBufferAttributes

4)解析IDR資料, 建立CMBlockBuffer物件

5)去除IDR前面4個位元組的資料

6)建立CMSampleBuffer

7) 解碼:VTDecompressionSessionDecodeFrame

展示的部分還在寫,關於VideoToolbox的話就先寫到這裡。


下面這個傳送門通向SO裡關於硬解碼的一個回答,回答很詳細。可以作為參照

傳送門:

  1. Stack Overflow - how-to-use-videotoolbox-to-decompress-h-264-video-stream