Farlanki.

iOS 播放 H265 Elementary Stream

字数统计: 1.4k阅读时长: 5 min
2019/05/01 Share

今天来看看iOS如何解码一个 H265 编码的 Elementary 视频裸流?

什么是 Elementary Stream

H265编码有两种格式:Annex-B 和 HVCC 格式。HVCC 用于 MP4。Annex-B 又被称为 Elementary Stream 格式,是由编码器输出的原始基础码流。iOS 的硬件解码框架 VideoToolBox 只支持 HVCC 格式,所以,如果我们需要处理的是 Elementary Stream 格式的文件,应该首先将它转换成 HVCC,然后喂给苹果解码器。

处理 Elementary Stream 的基本流程

处理 Elementary Stream 的基本流程分为三步:组帧、解码、渲染。下面我们来分别看看这几个基本流程。

组帧

处理 Elementary Stream 的第一步是组帧。可以组帧的具体含义是从 Elementary Stream 中分割出每一帧的数据。

我们可以使用 ffmpeg 中的 av_parser_parse2方法帮助我们进行组帧。具体做法是从Elementary Stream 中循环读取一定大小的数据,然后喂给 av_parser_parse2。当喂入的数据足够多,parser 能从中分离出一帧时,便将那一帧的数据吐出。其余的数据保存在 parser 的 context 中,等待之后的数据成帧。

需要注意的是,Elementary Stream 的帧分隔符有两种,分别为 00 00 0100 00 00 01两种,但是 ffmpeg 的 parser 在找到 AUD 后,直接将 i-5 作为上一帧的结尾。例如,对于 00 00 00 01 46 01,第一个00被作为上一帧的尾,而剩下的00 00 01 46 01会作为下一帧的头。这个切割方法对解码是完全没有影响的,但是如果需要做提取 sei 信息等操作,就需要注意。

关于组帧和 parser 的更多资料,可以参考这篇文章

解码

组帧之后,我们得到的是完整的一帧的数据,之后我们就可以进入到下一步流程-解码了。这里介绍的是使用 iOS 的系统框架 VideoToolBox。

在开始解码之前,我们首先需要初始化解码器。我们需要拿到SPSPPSVPS数据。我们可以通过查看每个帧的 Nalu 头,以确定其是否SPSPPSVPS 的其中一种。如果是,则先将其保存下来。当我们集齐SPSPPSVPS,便可以初始化解码器了。

在初始化解码器之后,我们就可以将组帧后的每一帧数据丢给解码器。之前说过,我们需要处理的是 Elementary Stream 格式的文件,应该首先将它转换成 HVCC,然后再喂给苹果解码器。将 Elementary Stream 格式转换成 HVCC 格式并不复杂。Elementary Stream 格式和 HVCC 格式的数据,在数据载荷层面是一样的,都是 Nalu 包,不同的只有包头的数据。HVCC 格式将 Nalu 包的长度以大端形式放在包头,取代 Elementary Stream 格式中的分隔符00 00 01或者00 00 00 01.所以,我们先要算出 Nalu 的数据长度,然后在原本存放分隔符的地方,以大端的形式填上数据长度。另外,对于 HVCC 格式,SPSPPSVPS这几种 Nalu 是不会出现在码流中的,所以如果在将帧喂给解码器之前发现这几种 Nalu 的其中一种,需要将其去掉。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
-(int)decodeWork:(uint8_t*)data Size:(int)size{

if (size <= 4 || (self.decoderInited != YES)
) {
return -1;
}

uint16_t nal_unit_header = data[4];
uint16_t nal_header_forbiden_bit = 0x8000 & nal_unit_header;
if (nal_header_forbiden_bit) {
//Detect forbidden bit
return -1;
}

uint8_t nal_unit_type = (nal_unit_header&0x7E) >> 1;;
switch (nal_unit_type) {
case H265_VPS_TAG:
case H265_SPS_TAG:
case H265_PPS_TAG:
return 0;
default:
break;
}

//fill size in nal head
size_t in_block_size = size;
uint32_t nal_size = (int)in_block_size - 4;
uint8_t* nal_size_ptr = (uint8_t*)&nal_size;

//save nal into au buffer
if (_au_size + size <= AU_MAX_SIZE) {

//conver the size to big-endian mode
uint8_t* nal_start_code = _au_buf + _au_size;
nal_start_code[0] = nal_size_ptr[3];
nal_start_code[1] = nal_size_ptr[2];
nal_start_code[2] = nal_size_ptr[1];
nal_start_code[3] = nal_size_ptr[0];

memcpy(_au_buf + _au_size +4, data+4, size-4);
_au_size += size;
}
else{
NSLog(@"error: au size:%d append:%d", _au_size, size);
}

return 0;
}

需要注意,以上函数的处理单位是一个 Nalu。所以,如果一个帧中有多个 Slice,需要先使用分隔符,将每个 Slice 分离出来,分别转换为 HVCC 格式,将同一帧的所有 Slice 的转换好的数据按顺序存放在一个 Buffer 中,再将该 Buffer 送给解码器。

VideoToolBox 的使用方法,可以参考这篇文章

渲染

一帧无论解码成功与失败,创建解码器时指定的回调函数都会被调用。如果解码成功,我们拿到的就是一个 YUV 格式的 CVImageBufferRef。接下来我们需要显示这个 Buffer。具体来说,我们需要将这个 Buffer 转换为 RGB 格式,然后显示在屏幕上。最常见的方法肯定是使用 iOS 的系统框架 Metal。相关的 Shader 编写,Metal 使用方法等,网上有很多资料,这里不再赘述。通过 Metal,我们可以充分运用 GPU 算力,消除性能瓶颈。

有一个地方需要注意,如果视频中有 B 帧,那么解码顺序和显示顺序会有不同的情况。这时候,如果直接将解码后的 CVImageBufferRef 渲染出来,视频画面有跳动现象,这时候就需要按照帧的显示顺序对帧进行重新排列。

参考资料

iOS 基于Metal的视频流(AVFoundation采集)渲染流程及详细解析
FFmpeg的H.264解码器源代码简单分析:解析器(Parser)部分
iOS 利用VideoToolBox对视频进行编解码

CATALOG
  1. 1. 什么是 Elementary Stream
  2. 2. 处理 Elementary Stream 的基本流程
  3. 3. 组帧
  4. 4. 解码
  5. 5. 渲染
    1. 5.1. 参考资料