004-视频H264编码详解(上)

4,975 阅读28分钟

前言

本篇开始讲解大家最感兴趣的知识点 👉🏻 H264视频编码,大致分上中下3篇,包括各个知识点的讲解和实际编码的部分。

一、H264结构与码流解析

1.1 H264结构图

image.png

上图H264结构中,一个视频图像编码后的数据叫做一帧一帧一个片(slice)或多个片组成,一个片又由一个或多个宏块(MB)组成,一个宏块由多个子块组成,子块即16x16的yuv数据宏块是作为H264编码的基本单位

  • 场和帧:视频的一场或一帧可用来产生一个编码图像。

  • :每个图象中,若干宏块被排列成片的形式。片分为I片、B片、P片其他一些片

    • I片只包含I宏块,P片可包含P和I宏块,而B片可包含B和I宏块。

      • I宏块利用从当前片中已解码的像素作为参考进行帧内预测。
      • P宏块利用前面已编码图象作为参考图象进行帧内预测。
      • B宏块则利用双向的参考图象(前一帧和后一帧)进行帧内预测。
    • 片的目的是为了限制误码的扩散和传输,使编码片相互间是独立的。某片的预测不能以其它片中的宏块为参考图像,这样某一片中的预测误差才不会传播到其它片中去。

  • 宏块:一个编码图像通常划分成若干宏块组成,一个宏块由一个16×16亮度像素和附加的一个8×8 Cb和一个8×8 Cr彩色像素块组成。

1.2 H264编码分层

H264编码分层,分为了2层.

  • NAL层: (Network Abstraction Layer,视频数据网络抽象层)

    • 它的作用是H264只要在网络上传输,在传输的过程每个包以太网是1500字节. 而H264的帧往往会大于1500字节的.所以就要进行拆包. 将一个帧拆成多个包进行传输.所有的拆包或者组包都是通过NAL层去处理的.
  • VCL层:(Video Coding Layer,视频数据编码层) 它的作用就是对视频原始数据进行压缩.

1.3 码流的基本概念

  • SODB:(String of Data Bits,原始数据比特流) ,长度不一定是8的倍数.它是由VCL层产生的.因为非8的倍数所以处理比较麻烦.

  • RBSP:(Raw Byte Sequence Payload,SODB+trailing bits) .算法是在SODB最后一位补1.不按字节对齐补0. 如果补齐0,不知道在哪里结束.所以补1.如果不够8位则按位补0.

  • EBSP:(Encapsulate Byte Sequence Payload) .就是生成压缩流之后,我们还要在每个帧之前加一个起始位.起始位一般是十六进制的0001.但是在整个编码后的数据里,可能会出来连续的2个0x00.那这样就与起始位产生了冲突.那怎么处理了? H264规范里说明如果处理2个连续的0x00,就额外增加一个0x03.这样就能预防压缩后的数据与起始位产生冲突.

  • NALU: NAL Header(1B)+EBSP.NALU就是在EBSP的基础上加1B的网络头.

EBSP解码的要点

  • 每个NAL前有一个起始码 0x00 00 01(或者0x00 00 00 01),解码器检测每个起始码,作为一个NAL的起始标识,当检测到下一个起始码时,当前NAL结束。

  • 同时H.264规定,当检测到0x00 00 01时,也可以表征当前NAL的结束。那么NAL中数据出现0x0000010x000000时怎么办?H.264引入了防止竞争机制,如果编码器检测到NAL数据存在0x000001或0x000000时,编码器会在最后个字节前插入一个新的字节0x03,这样解码器检测到0x000003时,把03抛弃,恢复原始数据(脱壳操作)。

  • 解码器在解码时,首先逐个字节读取NAL的数据,统计NAL的长度,然后再开始解码。

1.4 详解NAL Unit

NALU详解结构图如下:

image.png

  • NAL 单元是由一个NALU头部+一个切片.
  • 切片又可以细分成"切片头+切片数据".
  • 每个切片数据包括了很多宏块.
  • 每个宏块包括了宏块的类型,宏块的预测,残差数据.

H264码流分层结构图

image.png

  • A Annex格式数据: 就是起始码+Nal Unit 数据
  • NAL Unit: NALU 头+NALU数据
  • NALU 主体: 是由切片组成.切片包括切片头+切片数据
  • Slice数据: 宏块组成
  • PCM类: 宏块类型+pcm数据,或者宏块类型+宏块模式+残差数据
  • Residual: 残差块

⚠️ 这个图比较重要.大家可以多看看。

二、VideoToolBox简介

VideoToolBox是苹果iOS8.0后推出的原生的硬编码框架,利用硬件加速器,基于Core Foundation库函数(它是C语言编写的)。

2.1 使用步骤

我们一般使用VideoToolBox框架,需要做的事情包括👇🏻

  1. 创建session -> 设置编码相关参数 -> 开始编码 ->循环输入源数据(YUV 类型的数据,直接从摄像头获取)->获取编码后的H264数据 ->结束编码

  2. 构建H264文件,网络传输中其实也是H264文件

2.2 基本的数据结构

image.png

CMSampleBuffer中有编码解码2种情况,它们有区别👇🏻

  • 编码后 👉🏻 数据存储在CMBlockBuffer中,其中流数据就是从这里获取的
  • 未编码 👉🏻 数据存储在CVPixelBuffer

2.4 编码的过程

image.png

上图中,通过视频编码,将原始数据编码生成H264流数据,但是,不是说拿到了h264数据就能直接交给解码器去处理,解码器只能处理的是h264文件数据

2.3 h264文件

image.png

上图中👇🏻

  • 首先是SPSPPS,解码时需优先解码SPSPPS,才能接着对后面的数据进行解析。

  • 接着是I B P帧,可参考03-视频编码## 七、H264相关概念

  • 不管你使用那种框架编解码,如VideoToolBoxFFmpeg硬编码等,不管你是哪种平台,如macwindows移动端,都需要遵循H264文件这种格式去进行。

SPS 和 PPS

序列参数集SPS(Sequence Parameter Sets)

image.png

图像参数集PPS(Picture Parameter Sets)

image.png

image.png

image.png

这些仅了解即可。

2.4 判断帧类型 I B P

我们知道,视频是由一帧一帧的画面组成,而又是一片多片的数据组成,在网络传输的过程中,一片的数据可能很大,需要拆包发送,接收后再组包,那么问题来了:

如何判断识别帧类型,区分 I B P帧呢?

image.png

image.png

image.png

三、NALU单元数据详解

NALU = NAL Header + NAL Body

H264码流在网络中传输实际是以NALU的形式进行传输的,每个NALU由1个字节的Header和RBSP组成,如下图👇🏻

image.png

3.1 NAL Header解析

NAL Header为1个字节,占8位,那这8位里面到底包含了什么数据?👇🏻

  • 第0位:F
  • 第1-2位:NRI
  • 第3-7位:TYPE,类型,就是通过它来判断帧类型 I帧 B帧 P帧的

image.png

image.png

  • F: forbidden_zero_bit,在H264规范里面,规定了第一位必须是0,这个不详细解释了,记住即可。
  • NRI: 表示重要性,暂时无用处。000表示最无用,111最有用。用于表示当前NALU的重要性,值越大越重要。解码器在解码处理不过来的时候,可以丢掉重要性为0的NALU。
  • TYPE: 表示这个NAL的类型,以下表格有很多,不需要都记住,只需记住几个常用的即可👇🏻
    • 5:IDR图像的片(可以理解为I帧,I帧由多个I片组成)

    • 7:序列参数集(SPS)

    • 8:图像参数集(PPS)

image.png

3.2 NAL类型介绍

  • 单一类型:一个RTP包只包含NALU,就是说H264帧里只包含了一个片,例如P帧或者B帧都是单一类型
  • 组合类型:一个RTP包含多个NALU,类型是24-27,像pps或者sps一般都放在一个包里,以为2个数据单元都非常小
  • 分片类型:一个NALU单元分成多个RTP包,类型28-29

单一的NALU的RTP包

image.png

组合NALU的RTP包 image.png

分片NALU的RTP包

image.png

第1个字节:FU indicator分片单元指示符
第2个字节:FU Header 分片单元头,有多个片,就有FU Header组合起来

FU Header

image.png

  • S: start bit用于指明分片的开始,在网络传输时,一个个包,我们知道他的分片的包,那么如何区分是开始还是末尾的包呢?如果为1就是分片的开始
  • E: end bit用于指明分片的结束
  • R: 未使用,设置为0
  • Type:指明分片NAL类型,网络传输完成后,还是需要将分片组合成NALU单元,这个NAL单元是关键帧还是非关键帧,是sps还是pps,就需要根据Type来判断

思考:在传输过程中将一个帧切割成多个片,如果在传输过程中顺序打乱,或者丢失了其中某个片,我们怎么判断NALU单元传输完整呢?

解决思路👇🏻

依据FU HeaderS/E位,并借助于RTP包的包头,在RTP的包头包括了每个包的序列号,如果收到的包,收到了S包,也收到了E包,中间的包的序号是连续的,那就说明包是完整的,如果不是连续的就是丢包了,如果没有丢包就可以组合起来。

四、AVFoundation采集视频数据实现(1)

接下来,就是编码演示一下如何采集视频数据。大家可以回忆下之前的02-AVFoundation高级捕捉,我们之前实现的是一个基于系统相机录制视频的功能,并没有涉及视频编码,所以这次编码演示不同👇🏻

  1. 数据采集 👉🏻 基于AVFoudation框架(这个应该很熟悉了)
  2. 视频编码 👉🏻 基于VideoToolBox框架 整个过程大致就是👇🏻

数据采集 -> 编码完成 -> H264文件 -> 写入沙盒/网络传输

4.1 数据采集

相信大家现在都清楚数据采集的流程了,这里不多做说明,直接上代码(就在ViewController里处理)。

  1. 首先声明属性👇🏻
@interface ViewController ()<AVCaptureVideoDataOutputSampleBufferDelegate>

@property(nonatomic,strong)UILabel *cLabel;

@property(nonatomic,strong)AVCaptureSession *cCapturesession;//捕捉会话,用于输入输出设备之间的数据传递

@property(nonatomic,strong)AVCaptureDeviceInput *cCaptureDeviceInput;//捕捉输入

@property(nonatomic,strong)AVCaptureVideoDataOutput *cCaptureDataOutput;//捕捉输出

@property(nonatomic,strong)AVCaptureVideoPreviewLayer *cPreviewLayer;//预览图层


@end

不同于相机的视频功能,这次输出使用的是AVCaptureVideoDataOutput,所以需遵循的delegate是AVCaptureVideoDataOutputSampleBufferDelegate

然后是需要创建队列完成2件事 👉🏻 捕获编码👇🏻

@implementation ViewController
{
    int  frameID; //帧ID

    dispatch_queue_t cCaptureQueue; //捕获队列

    dispatch_queue_t cEncodeQueue;  //编码队列

    VTCompressionSessionRef cEncodeingSession;//编码session

    CMFormatDescriptionRef format; //编码格式

    NSFileHandle *fileHandele; //文件指针,存储沙盒时使用
}
  1. ViewDidLoad中的初始化👇🏻
- (void)viewDidLoad {

    [super viewDidLoad];

    // Do any additional setup after loading the view, typically from a nib.
    
    //基础UI实现
    _cLabel = [[UILabel alloc]initWithFrame:CGRectMake(20, 20, 200, 100)];
    _cLabel.text = @"cc课堂之H.264硬编码";
    _cLabel.textColor = [UIColor redColor];
    [self.view addSubview:_cLabel];

    UIButton *cButton = [[UIButton alloc]initWithFrame:CGRectMake(200, 20, 100, 100)];
    [cButton setTitle:@"play" forState:UIControlStateNormal];
    [cButton setTitleColor:[UIColor whiteColor] forState:UIControlStateNormal];
    [cButton setBackgroundColor:[UIColor orangeColor]];
    [cButton addTarget:self action:@selector(buttonClick:) forControlEvents:UIControlEventTouchUpInside];
    [self.view addSubview:cButton];
}

接下来就是按钮的点击事件

- (void)buttonClick:(UIButton *)button {
    //判断_cCapturesession 和 _cCapturesession是否正在捕捉
    if (!_cCapturesession || !_cCapturesession.isRunning ) {
        //修改按钮状态
        [button setTitle:@"Stop" forState:UIControlStateNormal];
        //开始捕捉
        [self startCapture];
    } else {
        [button setTitle:@"Play" forState:UIControlStateNormal];
        //停止捕捉
        [self stopCapture];
    }
}
  1. 开始录制视频👇🏻
- (void)startCapture {
    self.cCapturesession = [[AVCaptureSession alloc]init];
    //设置捕捉分辨率
    self.cCapturesession.sessionPreset = AVCaptureSessionPreset640x480;

    //使用函数dispath_get_global_queue去得到队列
    cCaptureQueue = dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0);
    cEncodeQueue  = dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0);
    
    AVCaptureDevice *inputCamera = nil;
    //获取iPhone视频捕捉的设备,例如前置摄像头、后置摄像头......
    NSArray *devices = [AVCaptureDevice devicesWithMediaType:AVMediaTypeVideo];

    for (AVCaptureDevice *device in devices) {
        //拿到后置摄像头
        if ([device position] == AVCaptureDevicePositionBack) {
            inputCamera = device;
        }
    }

    //将捕捉设备 封装成 AVCaptureDeviceInput 对象
    self.cCaptureDeviceInput = [[AVCaptureDeviceInput alloc]initWithDevice:inputCamera error:nil];
    
    //判断是否能加入后置摄像头作为输入设备
    if ([self.cCapturesession canAddInput:self.cCaptureDeviceInput]) {
        //将设备添加到会话中
        [self.cCapturesession addInput:self.cCaptureDeviceInput];
    }
    //配置输出
    self.cCaptureDataOutput = [[AVCaptureVideoDataOutput alloc]init];

    //设置丢弃最后的video frame 为NO
    [self.cCaptureDataOutput setAlwaysDiscardsLateVideoFrames:NO];

    //设置video的视频捕捉的像素点压缩方式为 YUV4:2:0
    [self.cCaptureDataOutput setVideoSettings:[NSDictionary dictionaryWithObject:[NSNumber numberWithInt:kCVPixelFormatType_420YpCbCr8BiPlanarFullRange] forKey:(id)kCVPixelBufferPixelFormatTypeKey]];
}

关于 YUV4:2:0,这个之前没有接触过,接下来我们看看。

五、YUV颜色详解

我们比较熟悉的颜色系统 👉🏻 RGB,它每一个颜色通道占有1个字节。而YUV,是做音视频这块业务开发比较熟悉的,它的特点👇🏻

  1. YUV(也称为YCbCr),是电视系统所采用的一种颜色编码方式
  2. Y: 表示亮度,也就是灰阶值,它是基础信号
  3. U和V表示的则是色度,UV的作用是描述影像的色彩及饱和度,它们用于指定像素的颜色

YUV视频的关系:摄像机录制出来的视频就是YUV

5.1 YUV常见格式

  • YUV4:2:0(YCbCr 4:2:0) 👉🏻 比RGB少二分之一
  • YUV4:2:2(YCbCr 4:2:2) 👉🏻 比RGB少三分之一,节省了很多空间,有历史原因。
  • YUV4:4:4(YCbCr 4:4:4) 👉🏻 理解为1:1:1,就是4个Y对应4个U和4个V。

YUV4:4:4

在4:4:4的模式下,色彩的全部信息被保全下来,如图👇🏻

image.png

相邻的四个像素点ABCD,每个像素点有自己的YUV,在色彩的二次采样的过程中,分别保留自己的YUV,称之为4:4:4。

YUV4:2:2

image.png

ABCD四个相邻的像素点,A(Y0,U0,V0),B(Y1,U1,V1),C(Y2,U2,V2),D(Y3,U3,V3),当二次采样的时候,A采样的时候保留(Y0,U0),B保留(Y1,V1),C保留(Y2,U2),D保留(Y3,V3);也就是说,每个像素点的Y(明亮度)保留其本身的值,而U和V的值是每间隔一个采样,而最终就变成👇🏻

image.png

也就是说A借B的V1,B借A的U0,C借D的V3,D借C的U2,这就是传说中的4:2:2,⼀张1280 * 720 ⼤⼩的图⽚,在YUV 4:2:2 采样时的⼤⼩为👇🏻

(1280 * 720 * 8 + 1280 * 720 * 0.5 * 8 * 2)/ 8 / 1024 / 1024 = 1.76 MB 。

可以看到YUV 4:2:2 采样的图像⽐RGB 模型图像节省了三分之⼀的存储空间,在传输时占⽤的带宽也会随之减少。

YUV4:2:0

上面说到的4:2:2中我们可以看到相邻的两个像素点的UV是左右互相借的,那可不可以上下左右借呢,答案当然是可以的👇🏻

image.png

YUV 4:2:0 采样,并不是指只采样U 分量⽽不采样V 分量。⽽是指,在每⼀⾏扫描时,只扫描⼀种⾊度分量(U 或者V),和Y 分量按照2 : 1 的⽅式采样。

⽐如,第⼀⾏扫描时,YU 按照2 : 1 的⽅式采样,那么第⼆⾏扫描时,YV 分量按照2:1 的⽅式采样。对于每个⾊度分量来说,它的⽔平⽅向和竖直⽅向的采样和Y 分量相⽐都是2:1 。假设第⼀⾏扫描了U 分量,第⼆⾏扫描了V 分量,那么需要扫描两⾏才能够组成完整的UV 分量。

从映射出的像素点中可以看到,四个Y 分量是共⽤了⼀套UV 分量,⽽且是按照2*2 的⼩⽅格的形式分布的,相⽐YUV 4:2:2 采样中两个Y 分量共⽤⼀套UV 分量,这样更能够节省空间。⼀张1280 * 720 ⼤⼩的图⽚,在YUV 4:2:0 采样时的⼤⼩为:

(1280 * 720 * 8 + 1280 * 720 * 0.25 * 8 * 2)/ 8 / 1024 / 1024 = 1.32 MB 相对于2.63M节省了一半的空间

5.2 YUV存储格式

  • 平面格式(planar formats) :对于planar的YUV格式,先连续存储所有像素点的Y,紧接着存储所有像素点的U,随后是所有像素点的V,如 YYYY YYYY UU VV。

    • I420: YYYYYYYY UU VV --> YUV420P (PC专用的)
    • YV12: YYYYYYYY VV UU --> YUV420P
  • 紧缩格式(packed formats):对于packed的YUV格式,每个像素点的Y,U,V是连续交替存储的,如YUV YUV YUV YUV,这种排列方式跟 RGB 很类似。

    • NV12: YYYYYYYY UVUV --> YUV420SP
    • NV21: YYYYYYYY VUVU --> YUV420SP

有可能在开发过程中,比如安卓和iOS,在解码视频后,发现视频图像出现倒置或者翻转,有可能就是因为他们的YUV的格式不一致导致的,PC端一般常用I420安卓一般默认NV21,而iOS默认NV12,如果想行为统一,就需要保证一致的存储格式。

六、AVFoundation采集视频数据实现(2)

YUV颜色体系了解后,我们继续完成视频的采集流程👇🏻

- (void)startCapture {
    self.cCapturesession = [[AVCaptureSession alloc]init];
    
    //设置捕捉分辨率
    self.cCapturesession.sessionPreset = AVCaptureSessionPreset640x480;

    //使用函数dispath_get_global_queue去得到队列
    cCaptureQueue = dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0);
    cEncodeQueue  = dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0);

    
    AVCaptureDevice *inputCamera = nil;
    //获取iPhone视频捕捉的设备,例如前置摄像头、后置摄像头......
    NSArray *devices = [AVCaptureDevice devicesWithMediaType:AVMediaTypeVideo];
    for (AVCaptureDevice *device in devices) {
        //拿到后置摄像头
        if ([device position] == AVCaptureDevicePositionBack) {
            inputCamera = device;
        }
    }

    //将捕捉设备 封装成 AVCaptureDeviceInput 对象
    self.cCaptureDeviceInput = [[AVCaptureDeviceInput alloc]initWithDevice:inputCamera error:nil];

    //判断是否能加入后置摄像头作为输入设备
    if ([self.cCapturesession canAddInput:self.cCaptureDeviceInput]) {
        //将设备添加到会话中
        [self.cCapturesession addInput:self.cCaptureDeviceInput];
    }
    //配置输出
    self.cCaptureDataOutput = [[AVCaptureVideoDataOutput alloc]init];

    //设置丢弃最后的video frame 为NO
    [self.cCaptureDataOutput setAlwaysDiscardsLateVideoFrames:NO];

    //设置video的视频捕捉的像素点压缩方式为 420
    [self.cCaptureDataOutput setVideoSettings:[NSDictionary dictionaryWithObject:[NSNumber numberWithInt:kCVPixelFormatType_420YpCbCr8BiPlanarFullRange] forKey:(id)kCVPixelBufferPixelFormatTypeKey]];

    //设置捕捉代理 和 捕捉队列
    [self.cCaptureDataOutput setSampleBufferDelegate:self queue:cCaptureQueue];
    //判断是否能添加输出
    if ([self.cCapturesession canAddOutput:self.cCaptureDataOutput]) {
        //添加输出
        [self.cCapturesession addOutput:self.cCaptureDataOutput];
    }

    //创建连接
    AVCaptureConnection *connection = [self.cCaptureDataOutput connectionWithMediaType:AVMediaTypeVideo];
    //设置连接的方向
    [connection setVideoOrientation:AVCaptureVideoOrientationPortrait];

    //初始化图层
    self.cPreviewLayer = [[AVCaptureVideoPreviewLayer alloc]initWithSession:self.cCapturesession];
    //设置视频重力
    [self.cPreviewLayer setVideoGravity:AVLayerVideoGravityResizeAspect];
    //设置图层的frame
    [self.cPreviewLayer setFrame:self.view.bounds];
    //添加图层
    [self.view.layer addSublayer:self.cPreviewLayer];
    
    //文件写入沙盒
    NSString *filePath = [[NSSearchPathForDirectoriesInDomains(NSDocumentDirectory,NSUserDomainMask,YES) lastObject]stringByAppendingPathComponent:@"cc_video.h264"];
    //先移除已存在的文件
    [[NSFileManager defaultManager] removeItemAtPath:filePath error:nil];
    //新建文件
    BOOL createFile = [[NSFileManager defaultManager] createFileAtPath:filePath contents:nil attributes:nil];
    if (!createFile) {
        NSLog(@"create file failed");
    } else {
        NSLog(@"create file success");
    }
    NSLog(@"filePaht = %@",filePath);
    fileHandele = [NSFileHandle fileHandleForWritingAtPath:filePath];

    //初始化videoToolbBox
    [self initVideoToolBox];
    //开始捕捉
    [self.cCapturesession startRunning];
}

七、VideoToolBox视频编码参数配置

接下来就是videoToolbBox的初始化过程,包括视频编码的一些参数的配置。需要做的事情包括👇🏻

  1. 创建编码session 👉🏻 cEncodeingSession
  2. 配制编码的参数

7.1 创建编码session

创建编码session使用的C函数是VTCompressionSessionCreate👇🏻

image.png

逐一解释下各个参数的含义👇🏻

  • 参数1:分配器,设置NULL为默认分配
  • 参数2:分辨率width,单位是像素,如果此数据非法,系统会改为合理的值
  • 参数3:分辨率height,同上
  • 参数4:编码类型,如kCMVideoCodecType_H264
  • 参数5:编码规范。设置NULL由videoToolbox自己选择
  • 参数6:源像素缓冲区属性.设置NULL不让videToolbox创建,而自己创建
  • 参数7:压缩数据分配器.设置NULL,默认的分配
  • 参数8:回调函数。当VTCompressionSessionEncodeFrame被调用压缩一次后会被异步调用.

⚠️注:当你设置NULL的时候,你需要调用VTCompressionSessionEncodeFrameWithOutputHandler方法进行压缩帧处理,支持iOS9.0以上

  • 参数9:回调客户定义的参考值,即将self桥接,让C函数可以调用OC方法
  • 参数10:编码会话变量

7.2 配制编码的参数

配制编码的参数也需要使用C函数VTSessionSetProperty👇🏻

image.png

这个函数很简单,参数释义如下👇🏻

  • 参数1:配置参数的设置对象 cEncodeingSession
  • 参数2:属性名称
  • 参数3:属性的值

7.3 完整初始化代码

//初始化videoToolBox
- (void)initVideoToolBox {
    dispatch_sync(cEncodeQueue, ^{
        frameID = 0;
        
        // 分辨率:与AVFoudation的分辨率保持一致
        int width = 480,height = 640;
        
        //1.调用VTCompressionSessionCreate创建编码session
        OSStatus status = VTCompressionSessionCreate(NULL, width, height, kCMVideoCodecType_H264, NULL, NULL, NULL, didCompressH264, (__bridge void *)(self), &cEncodeingSession);
        NSLog(@"H264:VTCompressionSessionCreate:%d",(int)status);
        if (status != 0) {
            NSLog(@"H264:Unable to create a H264 session");
            return ;
        }
        //2.配制参数
        
        //设置实时编码输出(避免延迟)
        VTSessionSetProperty(cEncodeingSession, kVTCompressionPropertyKey_RealTime, kCFBooleanTrue);

        //舍弃B帧
        VTSessionSetProperty(cEncodeingSession, kVTCompressionPropertyKey_ProfileLevel,kVTProfileLevel_H264_Baseline_AutoLevel);
        
        //是否产生B帧(因为B帧在解码时并不是必要的,是可以抛弃B帧的)
        VTSessionSetProperty(cEncodeingSession, kVTCompressionPropertyKey_AllowFrameReordering, kCFBooleanFalse);
        
        //设置关键帧(GOPsize)间隔,GOP太小的话图像会模糊
        int frameInterval = 10;

        //需要类型转换
        /**

         CFNumberCreate(CFAllocatorRef allocator, CFNumberType theType, const void *valuePtr)
         * allocator: 分配器 kCFAllocatorDefault默认
         * theType: 数据类型
         * *valuePtr: 指针,地址
         */
        CFNumberRef frameIntervalRaf = CFNumberCreate(kCFAllocatorDefault, kCFNumberIntType, &frameInterval);
        VTSessionSetProperty(cEncodeingSession, kVTCompressionPropertyKey_MaxKeyFrameInterval, frameIntervalRaf);
        //设置期望帧率,不是实际帧率
        int fps = 10;

        CFNumberRef fpsRef = CFNumberCreate(kCFAllocatorDefault, kCFNumberIntType, &fps);
        VTSessionSetProperty(cEncodeingSession, kVTCompressionPropertyKey_ExpectedFrameRate, fpsRef);
        
        //码率的理解:码率大了话就会非常清晰,但同时文件也会比较大。码率小的话,图像有时会模糊,但也勉强能看
        //码率计算公式,参考印象笔记
        //设置码率、上限、单位是bps
        int bitRate = width * height * 3 * 4 * 8;
        CFNumberRef bitRateRef = CFNumberCreate(kCFAllocatorDefault, kCFNumberSInt32Type, &bitRate);
        VTSessionSetProperty(cEncodeingSession, kVTCompressionPropertyKey_DataRateLimits, bitRateRef);

        //设置码率,均值,单位是byte
        int bigRateLimit = width * height * 3 * 4;
        CFNumberRef bitRateLimitRef = CFNumberCreate(kCFAllocatorDefault, kCFNumberSInt32Type, &bigRateLimit);
        VTSessionSetProperty(cEncodeingSession, kVTCompressionPropertyKey_AverageBitRate, bitRateLimitRef);

        //开始编码
        VTCompressionSessionPrepareToEncodeFrames(cEncodeingSession);
    });
}

其中,关于码率计算公式,可参考下图👇🏻

image.png

八、AVFoundation采集视频数据实现(3)

采集视频的流程还剩下停止捕捉视频编码准备这2个节点了。

8.1 停止捕捉

在使用VideoToolBox视频编码之前,我们回到采集视频的流程,刚才我们实现了开始捕捉startCapture,还有停止捕捉未实现👇🏻

- (void)stopCapture {
    //停止捕捉
    [self.cCapturesession stopRunning];

    //移除预览图层
    [self.cPreviewLayer removeFromSuperlayer];

    //结束videoToolbBox
    [self endVideoToolBox];

    //关闭文件
    [fileHandele closeFile];
    fileHandele = NULL;
}

其中,结束VideoToolBox代码如下👇🏻

-(void)endVideoToolBox {
    VTCompressionSessionCompleteFrames(cEncodeingSession, kCMTimeInvalid);
    VTCompressionSessionInvalidate(cEncodeingSession);
    CFRelease(cEncodeingSession);
    cEncodeingSession = NULL;
}

8.2 视频编码准备

准备工作大家应该知道,肯定是在输出的delegate方法中去完成,我们此时使用的是输出是AVCaptureVideoDataOutput,它的delegate是AVCaptureVideoDataOutputSampleBufferDelegate获取视频流所触发的方法是 image.png

-(void)captureOutput:(AVCaptureOutput *)captureOutput didOutputSampleBuffer:(CMSampleBufferRef)sampleBuffer fromConnection:(AVCaptureConnection *)connection {
    //开始视频录制,获取到摄像头的视频帧,传入encode方法中
    dispatch_sync(cEncodeQueue, ^{
        // 这是未编码/未压缩的视频流
        [self encode:sampleBuffer];
    });
}

但是有个问题,视频和音频数据都是通过AVFoudation采集,然后交由这个代理方法!那么如何区分是视频还是音频数据呢? 👇🏻

通过captureOutput对象,判断它是AVCaptureVideoDataOutput还是AVCaptureAudioDataOutput

九、VideoToolBox视频编码实现(1)

9.1 编码函数

创建编码session一样,视频编码的函数也是C函数👇🏻

image.png

其参数释义如下👇🏻

  • 参数1:编码会话变量
  • 参数2:未编码数据
  • 参数3:获取到的这个sample buffer数据的展示时间戳。每一个传给这个session的时间戳都要大于前一个展示时间戳.
  • 参数4:对于获取到sample buffer数据,这个帧的展示时间.如果没有时间信息,可设置kCMTimeInvalid.
  • 参数5:frameProperties: 包含这个帧的属性.帧的改变会影响后边的编码帧.
  • 参数6:ourceFrameRefCon: 回调函数会引用你设置的这个帧的参考值.
  • 参数7:infoFlagsOut: 指向一个VTEncodeInfoFlags来接受一个编码操作.如果使用异步运行,kVTEncodeInfo_Asynchronous被设置;同步运行,kVTEncodeInfo_FrameDropped被设置;设置NULL为不想接受这个信息.

9.2 视频编码encode

- (void)encode:(CMSampleBufferRef)sampleBuffer {
    //拿到每一帧未编码数据
    CVImageBufferRef imageBuffer = (CVImageBufferRef)CMSampleBufferGetImageBuffer(sampleBuffer);

    //设置帧时间,如果不设置会导致时间轴过长。
    CMTime presentationTimeStamp = CMTimeMake(frameID++, 1000);

    VTEncodeInfoFlags flags;    
    //编码函数
    OSStatus statusCode = VTCompressionSessionEncodeFrame(cEncodeingSession, imageBuffer, presentationTimeStamp, kCMTimeInvalid, NULL, NULL, &flags);
    if (statusCode != noErr) {
        NSLog(@"H.264:VTCompressionSessionEncodeFrame faild with %d",(int)statusCode);
        //结束编码
        VTCompressionSessionInvalidate(cEncodeingSession);
        CFRelease(cEncodeingSession);
        cEncodeingSession = NULL;
        return;
    }
    
    NSLog(@"H264:VTCompressionSessionEncodeFrame Success");
}

此时编码已经完成,接下来有2个问题👇🏻

  1. 去哪里获取编码成功的H264流数据?
  2. 拿到编码成功的数据后,接下来做什么?

9.3 编码完成回调

我们先来回答问题1,我们当初配置编码sessioncEncodeingSession时,指定了1个回调函数didCompressH264,这里就能拿到编码成功的H264流数据👇🏻

void didCompressH264(void *outputCallbackRefCon, void *sourceFrameRefCon, OSStatus status, VTEncodeInfoFlags infoFlags, CMSampleBufferRef sampleBuffer)

还记得我们之前讲解的H264文件格式吗?看下图👇🏻

image.png

NALU流数据中,第0个和第1个是SPS和PPS,这里面就包含了很多参数等关键信息,当然我们要先处理这个,而获取SPS和PPS,首先得拿到关键帧。这就是问题2:拿到编码成功的数据后,所需要做的事情。

9.3.1 关键帧的判断

大致分为3步👇🏻

  1. sampleBuffer中获取数据流数组array

CFArrayRef array = CMSampleBufferGetSampleAttachmentsArray(sampleBuffer, true);

  1. array中获取索引值为0的object

CFDictionaryRef dic = CFArrayGetValueAtIndex(array, 0);

  1. 判断是否关键帧

bool isKeyFrame = !CFDictionaryContainsKey(dic, kCMSampleAttachmentKey_NotSync);

9.3.2 获取SPS/PPS的C函数

image.png

  • 参数1:图像存储方式
  • 参数2:0 索引值
  • 参数3、参数4、参数5:传值是地址,输出SPS/PPS的参数信息
  • 参数6:输出的信息,默认传0

9.3.3 H264文件的生成

void didCompressH264(void *outputCallbackRefCon, void *sourceFrameRefCon, OSStatus status, VTEncodeInfoFlags infoFlags, CMSampleBufferRef sampleBuffer) {
    NSLog(@"didCompressH264 called with status %d infoFlags %d",(int)status,(int)infoFlags);
    
    //状态错误
    if (status != 0) {
        return;
    }
    
    //没准备好
    if (!CMSampleBufferDataIsReady(sampleBuffer)) {
        NSLog(@"didCompressH264 data is not ready");
        return;
    }
    
    // 将ref(之前桥接的self对象)转换成viewconntroller
    ViewController *encoder = (__bridge ViewController *)outputCallbackRefCon;

    //判断当前帧是否为关键帧
    bool keyFrame = !CFDictionaryContainsKey((CFArrayGetValueAtIndex(CMSampleBufferGetSampleAttachmentsArray(sampleBuffer, true), 0)), kCMSampleAttachmentKey_NotSync);

    //获取sps & pps 数据 只获取1次,保存在h264文件开头的第一帧中
    //sps(sample per second 采样次数/s),是衡量模数转换(ADC)时采样速率的单位

    //pps()
    if (keyFrame) {
        //图像存储方式,编码器等格式描述
        CMFormatDescriptionRef format = CMSampleBufferGetFormatDescription(sampleBuffer);

        //从第0个索引关键帧获取sps
        size_t sparameterSetSize,sparameterSetCount;
        const uint8_t *sparameterSet;

        OSStatus statusCode = CMVideoFormatDescriptionGetH264ParameterSetAtIndex(format, 0, &sparameterSet, &sparameterSetSize, &sparameterSetCount, 0);
        if (statusCode == noErr) {
            //获取pps
            size_t pparameterSetSize,pparameterSetCount;
            const uint8_t *pparameterSet;

            //从第1个索引关键帧获取pps
            OSStatus statusCode = CMVideoFormatDescriptionGetH264ParameterSetAtIndex(format, 1, &pparameterSet, &pparameterSetSize, &pparameterSetCount, 0);
            //sps和pps获取成功,准备写入文件
            if (statusCode == noErr) {
                // pps & sps -> NSData
                NSData *sps = [NSData dataWithBytes:sparameterSet length:sparameterSetSize];
                NSData *pps = [NSData dataWithBytes:pparameterSet length:pparameterSetSize];

                if(encoder) {
                    //写入文件
                    [encoder gotSpsPps:sps pps:pps];
                }
            }
        }
    }
    // 还有其他操作...
}

接着就是写入 sps & pps的方法gotSpsPps:pps:实现,先看图👇🏻

image.png

所以就是添加起始位00 00 00 01

//第一帧写入 sps & pps

- (void)gotSpsPps:(NSData*)sps pps:(NSData*)pps {
    NSLog(@"gotSpsPp %d %d",(int)[sps length],(int)[pps length]);

    //添加起始位00 00 00 01
    const char bytes[] = "\x00\x00\x00\x01";

    //减1是去掉`\0`结束符
    size_t length = (sizeof bytes) - 1;

    NSData *ByteHeader = [NSData dataWithBytes:bytes length:length];
    [fileHandele writeData:ByteHeader];
    [fileHandele writeData:sps];
    [fileHandele writeData:ByteHeader];
    [fileHandele writeData:pps];
}

十、VideoToolBox视频编码实现(2)

上面已经处理完SPS/PPS了,接着就是之后的NALU流数据处理了,就是下图的CMBlockBuffer👇🏻

image.png

CMBlockBuffer中汇总的就是编码后的数据流,我们需要获取它,然后转换成H264文件格式。

10.1 获取CMBlockBuffer

当然是C函数👇🏻

image.png

很简单,就一句代码👇🏻

CMBlockBufferRef dataBuffer = CMSampleBufferGetDataBuffer(sampleBuffer);

我们可以将dataBuffer理解为一个数组,我们需要遍历它,获取里面的数据。如何遍历呢?需要3个条件👇🏻

  1. 单个元素的length
  2. 总体数据的length
  3. 起始地址

然后通过C函数获取👇🏻

image.png

CMBlockBufferRef dataBuffer = CMSampleBufferGetDataBuffer(sampleBuffer);
size_t length,totalLength; // 单个数据length,整个流数据的length
char *dataPointer; //数据的首地址
// 根据单个数据length,整个NALU流数据的length,以及数据的首地址,就可以遍历整个数据流做处理了 -->可以理解为遍历数组
OSStatus statusCodeRet = CMBlockBufferGetDataPointer(dataBuffer, 0, &length, &totalLength, &dataPointer);
if (statusCodeRet == noErr) {
    //这里处理遍历,读取数据
}

10.2 大端模式 & 小端模式

在遍历处理数据之前,需要考虑一个问题 👉🏻 大端模式 & 小端模式

计算机硬件中,数据的存储方式有2种:大端字节序小端字节序

  • 大端字节序高位字节在前面低位字节在后面
  • 小端字节序低位字节在前面高位字节在后面

比如,16进制数据0x01234567大端字节序01 23 45 67,而小端字节序则是67 45 23 01

为什么会有小端字节序呢? 因为计算机电路先处理低位字节,效率会比较高!所以,计算机内部处理都是从低位字节开始,而人类的读写习惯是大端字节序,因此,除了计算机内部,其他一般情况都是保持大端字节序

10.3 循环遍历处理NALU数据

循环遍历有2种方式,一种是通过指针p++偏移来操作,一种是通过步长偏移操作,我们这里采用后者,代码如下 👇🏻

size_t bufferOffset = 0;

static const int AVCCHeaderLength = 4;//返回的nalu数据前4个字节不是001的startcode,而是大端模式的帧长度length
//循环:通过偏移量来获取NALU数据
while (bufferOffset < totalLength - AVCCHeaderLength) {
    uint32_t NALUnitLength = 0;
    //读取 一单元长度的 nalu
    memcpy(&NALUnitLength, dataPointer + bufferOffset, AVCCHeaderLength);
    
    //从大端模式转换为系统端模式(mac上就是小端模式)
    NALUnitLength = CFSwapInt32BigToHost(NALUnitLength);
    
    //获取nalu数据
    NSData *data = [[NSData alloc]initWithBytes:(dataPointer + bufferOffset + AVCCHeaderLength) length:NALUnitLength];
    
    //将nalu数据写入到文件
    [encoder gotEncodedData:data isKeyFrame:keyFrame];
    
    //读取下一个nalu 一次回调可能包含多个nalu数据
    bufferOffset += AVCCHeaderLength + NALUnitLength;
}

10.4 完整版didCompressH264

完整版代码 👇🏻

void didCompressH264(void *outputCallbackRefCon, void *sourceFrameRefCon, OSStatus status, VTEncodeInfoFlags infoFlags, CMSampleBufferRef sampleBuffer) {
    NSLog(@"didCompressH264 called with status %d infoFlags %d",(int)status,(int)infoFlags);

    //状态错误
    if (status != 0) {
        return;
    }

    //没准备好
    if (!CMSampleBufferDataIsReady(sampleBuffer)) {
        NSLog(@"didCompressH264 data is not ready");
        return;
    }

    ViewController *encoder = (__bridge ViewController *)outputCallbackRefCon;

    //判断当前帧是否为关键帧
    bool keyFrame = !CFDictionaryContainsKey((CFArrayGetValueAtIndex(CMSampleBufferGetSampleAttachmentsArray(sampleBuffer, true), 0)), kCMSampleAttachmentKey_NotSync);
    //获取sps & pps 数据 只获取1次,保存在h264文件开头的第一帧中
    //sps(sample per second 采样次数/s),是衡量模数转换(ADC)时采样速率的单位
    //pps()

    if (keyFrame) {
        //图像存储方式,编码器等格式描述
        CMFormatDescriptionRef format = CMSampleBufferGetFormatDescription(sampleBuffer);

        //从第0个索引关键帧获取sps
        size_t sparameterSetSize,sparameterSetCount;
        const uint8_t *sparameterSet;

        OSStatus statusCode = CMVideoFormatDescriptionGetH264ParameterSetAtIndex(format, 0, &sparameterSet, &sparameterSetSize, &sparameterSetCount, 0);
        if (statusCode == noErr) {
            //获取pps
            size_t pparameterSetSize,pparameterSetCount;
            const uint8_t *pparameterSet;

            //从第1个索引关键帧获取pps
            OSStatus statusCode = CMVideoFormatDescriptionGetH264ParameterSetAtIndex(format, 1, &pparameterSet, &pparameterSetSize, &pparameterSetCount, 0);
            
            //sps和pps获取成功,准备写入文件
            if (statusCode == noErr) {
                // pps & sps -> NSData
                NSData *sps = [NSData dataWithBytes:sparameterSet length:sparameterSetSize];
                NSData *pps = [NSData dataWithBytes:pparameterSet length:pparameterSetSize];

                if(encoder) {
                    //写入文件
                    [encoder gotSpsPps:sps pps:pps];
                }
            }
        }
    }

    CMBlockBufferRef dataBuffer = CMSampleBufferGetDataBuffer(sampleBuffer);
    size_t length,totalLength; // 单个数据length,整个流数据的length
    char *dataPointer; //数据的首地址
    // 根据单个数据length,整个NALU流数据的length,以及数据的首地址,就可以遍历整个数据流做处理了 -->可以理解为遍历数组
    OSStatus statusCodeRet = CMBlockBufferGetDataPointer(dataBuffer, 0, &length, &totalLength, &dataPointer);
    if (statusCodeRet == noErr) {
        size_t bufferOffset = 0;
        static const int AVCCHeaderLength = 4;//返回的nalu数据前4个字节不是001的startcode,而是大端模式的帧长度length
        
        //循环:通过偏移量来获取NALU数据
        while (bufferOffset < totalLength - AVCCHeaderLength) {
            uint32_t NALUnitLength = 0;
            //读取 一单元长度的 nalu
            memcpy(&NALUnitLength, dataPointer + bufferOffset, AVCCHeaderLength);

            //从大端模式转换为系统端模式(mac上就是小端模式)
            NALUnitLength = CFSwapInt32BigToHost(NALUnitLength);

            //获取nalu数据
            NSData *data = [[NSData alloc]initWithBytes:(dataPointer + bufferOffset + AVCCHeaderLength) length:NALUnitLength];
            
            //将nalu数据写入到文件
            [encoder gotEncodedData:data isKeyFrame:keyFrame];

            //读取下一个nalu 一次回调可能包含多个nalu数据
            bufferOffset += AVCCHeaderLength + NALUnitLength;

        }
    }
}

接着就是gotEncodedData:isKeyFrame:方法的实现👇🏻

- (void)gotEncodedData:(NSData*)data isKeyFrame:(BOOL)isKeyFrame {
    NSLog(@"gotEncodeData %d",(int)[data length]);

    if (fileHandele != NULL) {
        //添加4个字节的H264 协议 start code 分割符
        //一般来说编码器编出的首帧数据为PPS & SPS
        //H264编码时,在每个NAL前添加起始码 0x000001,解码器在码流中检测起始码,当前NAL结束。

        /*
         为了防止NAL内部出现0x000001的数据,h.264又提出'防止竞争 emulation prevention"机制,在编码完一个NAL时,如果检测出有连续两个0x00字节,就在后面插入一个0x03。当解码器在NAL内部检测到0x000003的数据,就把0x03抛弃,恢复原始数据。

         总的来说H264的码流的打包方式有两种,一种为annex-b byte stream format 的格式,这个是绝大部分编码器的默认输出格式,就是每个帧的开头的3~4个字节是H264的start_code,0x00000001或者0x000001。

         另一种是原始的NAL打包格式,就是开始的若干字节(1,2,4字节)是NAL的长度,而不是start_code,此时必须借助某个全局的数据来获得编 码器的profile,level,PPS,SPS等信息才可以解码。
         */

        const char bytes[] ="\x00\x00\x00\x01";
        //长度
        size_t length = (sizeof bytes) - 1;
        //头字节
        NSData *ByteHeader = [NSData dataWithBytes:bytes length:length];
        //写入头字节
        [fileHandele writeData:ByteHeader];
        //写入H264数据
        [fileHandele writeData:data];
    }
}

总结

  • H264结构与码流解析
    • H264结构图
      • 视频图像编码后 👉🏻
      • 👉🏻 一个片(slice)或多个片组成
      • 宏块 👉🏻 一个或多个宏块(MB)组成
    • H264编码分层
      • NAL层:   (Network Abstraction Layer,视频数据网络抽象层)
      • VCL层:(Video Coding Layer,视频数据编码层)
    • 码流
      • SODB:(String of Data Bits,原始数据比特流)
      • RBSP:(Raw Byte Sequence Payload,SODB+trailing bits)
      • EBSP:(Encapsulate Byte Sequence Payload)
      • NALU: NAL Header(1B)+EBSP 👉🏻 这个是重点
    • NAL Unit
      • NAL Unit = 一个NALU头部 + 一个切片
      • 切片 = 切片头 + 切片数据
      • 切片数据 = 宏块 + ... + 宏块
      • 宏块 = 类型 + 预测 + 残差数据
  • VideoToolBox
    • iOS8.0后推出的原生的硬编码框架,基于Core Foundation,C语言编写
    • 基本数据结构 👉🏻 CMSampleBuffer
      • 未编码 👉🏻 CVPixelBuffer
      • 编码后 👉🏻 CMBlockBuffer
    • 编码过程 👉🏻 CVPixelBuffer原始数据 -> video encoder -> CMBlockBuffer -> H264文件格式
    • H264文件
      • H264文件格式是NALU流数据类型
      • 帧的顺序 👉🏻 SPS + PPS + I B P帧
    • 识别I B P帧
      • 十六进制 换算成 二进制
      • 二进制4-8位,再换算成成十进制
      • 十进制结果参照对照表
  • NALU单元数据详解
    • NALU = NAL Header(1 Byte) + NAL Body
    • NAL Header解析
      • 1字节,即占8位
        • 第0位:F 值必须是0
        • 第1-2位:NRI 重要性 👉🏻 000最无用,111最有用
        • 第3-7位:TYPE,类型,就是通过它来判断帧类型 I帧 B帧 P帧的
          • 5表示I帧
          • 7表示SPS序列参数集
          • 8表示PPS图像参数集
    • NAL类型
      • 单一类型:一个RTP包只包含NALU,即H264帧里只包含了一个片
      • 组合类型:一个RTP包含多个NALU,例如像pps或者sps
      • 分片类型:一个NALU单元分成多个RTP包
        • 第1个字节:FU indicator分片单元指示符
        • 第2个字节:FU Header 分片单元头,有多个片
      • FU Header
        • Sstart bit用于指明分片的开始
        • Eend bit用于指明分片的结束
        • R: 未使用,设置为0
        • Type:指明分片NAL类型,是关键帧还是非关键帧,是sps还是pps
      • NALU单元传输完整的识别
        • 收到S包 和 E包
        • 中间的包的序号是连续的
  • YUV颜色体系
    • 也称YCbCr,是电视系统所采用的一种颜色编码方式
    • Y: 表示亮度,也就是灰阶值,它是基础信号
    • U和V表示的则是色度,UV的作用是描述影像的色彩及饱和度,它们用于指定像素的颜色
    • YUV常见格式
      • YUV4:2:0(YCbCr 4:2:0) 👉🏻 比RGB少二分之一
      • YUV4:2:2(YCbCr 4:2:2) 👉🏻 比RGB少三分之一
      • YUV4:4:4(YCbCr 4:4:4) 👉🏻 理解为1:1:1
    • YUV存储格式
      • 平面格式(planar formats)
        • I420:YUV420P (PC专用的)
        • YV12:YUV420P
      • 紧缩格式(packed formats)
        • NV12:YUV420SP (iOS默认)
        • NV21:YUV420SP (安卓默认)
  • AVFoundation采集视频数据实现
    • 整体过程 👉🏻 数据采集 -> 编码完成 -> H264文件 -> 写入沙盒/网络传输
    • 数据采集 👉🏻 基于AVFoudation框架
      • 输出源AVCaptureVideoDataOutput,需遵循AVCaptureVideoDataOutputSampleBufferDelegate
      • 队列同步完成2件事 👉🏻 捕获 和 编码
      • video的视频捕捉的像素点压缩方式为 YUV4:2:0
        • kCVPixelBufferPixelFormatTypeKey : kCVPixelFormatType_420YpCbCr8BiPlanarFullRange
    • 视频编码 👉🏻 基于VideoToolBox框架
      • 初始化videoToolbBox
        • 创建编码session 👉🏻 VTCompressionSessionCreate
        • 配制编码的参数 👉🏻 VTSessionSetProperty
          • 实时编码kVTCompressionPropertyKey_RealTime
          • 舍弃B帧kVTCompressionPropertyKey_ProfileLevel
          • 产生B帧kVTCompressionPropertyKey_AllowFrameReordering
          • 关键帧(GOPsize)间隔kVTCompressionPropertyKey_MaxKeyFrameInterval
          • 期望帧率kVTCompressionPropertyKey_ExpectedFrameRate
          • 码率上限kVTCompressionPropertyKey_DataRateLimits
          • 码率均值kVTCompressionPropertyKey_AverageBitRate
  • VideoToolBox视频编码
    • 停止捕捉
      • 停止捕捉session
      • 移除预览图层
      • 结束videoToolbBox
      • 关闭文件
    • 编码前准备
      • 编码的时机点 👉🏻 AVCaptureVideoDataOutputSampleBufferDelegate方法-(void)captureOutput:(AVCaptureOutput *)captureOutput didOutputSampleBuffer:(CMSampleBufferRef)sampleBuffer fromConnection:(AVCaptureConnection *)connection
    • 编码实现
      • 获取未编码每一帧 👉🏻 CMSampleBufferGetImageBuffer
      • 编码函数 👉🏻 VTCompressionSessionEncodeFrame
      • 获取编码成功的H264流数据 👇
        • 编码完成回调 👉🏻 VTCompressionSessionCreate时指定的回调函数
          • sampleBuffer中获取数据流数组CMSampleBufferGetSampleAttachmentsArray
          • array中获取索引值为0的CFDictionaryRefdic
          • 判断关键帧!CFDictionaryContainsKey(dic, kCMSampleAttachmentKey_NotSync)
      • 生成H264文件格式
        • 获取SPS/PPS 👉🏻 CMVideoFormatDescriptionGetH264ParameterSetAtIndex
        • 写入文件
          • 根据size和地址指针,读取NSData
          • 配置Header
            • 添加起始位"\x00\x00\x00\x01"
            • 去掉\0结束符
          • 写入顺序 👉🏻 Header + spsData + Header + ppsData
        • 获取CMBlockBuffer 👉🏻 CMSampleBufferGetDataBuffer
        • 遍历CMBlockBuffer 获取 nalu数据
          • 单个元素的length + 总体数据的length + 起始地址,指针偏移遍历
          • 大端模式转换成小端模式(mac系统默认小端模式)
        • 将nalu数据写入到文件