AVCC与Annex-B
H264码流分为AVCC与Annex-B两种组织格式。
AVCC格式 也叫AVC1格式,MPEG-4格式,字节对齐,因此也叫Byte-Stream Format。用于mp4/flv/mkv等封装中。
Annex-B格式 也叫MPEG-2 transport stream format格式(ts格式), ElementaryStream格式。用于TS流中(以及使用TS作为切片的hls格式中)。
这两种格式的区别有两点: 1. NALU的分割方式不同; 2. SPS/PPS的数据结构不同。
- AVCC格式使用NALU长度(固定字节,字节数由extradata中的信息给定)进行分割,在封装文件或者直播流的头部包含extradata信息(非NALU),extradata中包含NALU长度的字节数以及SPS/PPS信息。
- Annex-B格式使用start code进行分割,start code为0x000001或0x00000001,SPS/PPS作为一般NALU单元以start code作为分隔符的方式放在文件或者直播流的头部。
AVCC格式的extradata格式定义在“ISO_IEC_14496-15"文档中,Annex-B格式的SPS/PPS定义可以在"ISO_IEC_14496-10"文档中找到
MediaCodec与VideoToolBox使用的数据格式
Android的硬解码接口MediaCodec只能接收Annex-B格式的H264数据,而iOS平台的VideoToolBox则相反,只支持AVCC格式。
这就导致:
在Android平台硬解播放flv/mp4/mkv等封装的视频时,需要将AVCC格式的extradata以及NALU数据转为Annex-B格式;
在iOS平台播放ts或ts切片的hls视频时,需要将Annex-B格式的SPS/PPS NALU转为AVCC格式的extradata,以及将其他以size方式分割的NALU转为start code方式。
解码器的初始化及数据输入
初始化解码器,除了配置输入视频流的的编码格式、宽高以及输出格式之外,还需要配置一些额外的信息。对于H264视频,需要填充的就是我们前面提到的SPS/PPS信息。
Android平台MediaCodec的初始化
我们需要将Annex-B格式的两个SPS/PPS NALU单元通过setByteBuffer方法,以"csd-0"为名称(或SPS设为"csd-0", PPS设为"csd-1")设置到MediaFormat对象中,并调用configure接口配置到MediaCodec中去。
MediaCodec设置SPS/PPS信息的示例代码
MediaCodec mediaCodec = MediaCodec.createDecoderByType("video/avc");
MediaFormat mediaFormat = MediaFormat.createVideoFormat("video/avc", width, height);
// extradata中是Annex-B格式的SPS、PPS NALU数据
mediaFormat.setByteBuffer("csd-0", extradata);
// ...
mediaCodec.configure(mediaFormat, surface, 0, 0);
// ...
如上节所述,对于mp4/flv/mkv等封装,我们得到的是AVCC格式的extradata,需要先将该extradata转换为Annex-B格式的两个NALU, 然后用startcode进行分割。
Android平台在配置解码方式时,最好使用MediaCodec直接渲染到Surface的方式,一是可以避免不同硬件平台繁杂的YUV格式兼容,二是在解码渲染高分辨率的视频时可以有非常明显的效率提升
iOS平台VideoToolBox接口的初始化
VideoToolBox针对AVCC格式和Annex-B格式的SPS/PPS信息设置,分别提供了两个方法:
- CMVideoFormatDescriptionCreate: 可以设置AVCC格式的extradata信
- CMVideoFormatDescriptionCreateFromH264ParameterSets: 用来设置Annex-B格式的SPS/PPS NALU信息(需要去掉startcode)
需要注意,iOS平台不支持隔行H264视频的解码,需要在创建videoToolBox前从SPS中判断当前视频是否隔行编码。
数据格式的转换
如前所述,Android平台只接受Annex-B格式以startcode分割的H264 NALU;iOS平台则相反,只接受AVCC格式以size分割的NALU. 在原视频流格式不匹配时需要进行相应的转换。
iOS还有以下的一些限制需要留意:
- 如果源视频流本身已经是AVCC格式,但NALU size的大小是3个字节,而非4字节时,需要转为4字节格式。具体的话,需要先更改extradata中标识NALU size的字段,然后每个视频帧中的NALU size都要改成4个字节。
- 如果一个视频帧内有多个NALU(多slice),那必须将这些NALU打包到一个CMSampleBuffer中,一次性送给解码器。
seek时的处理
编码后的视频帧之间存在着参考关系,我们无法直接从任意一帧开始解码,只能从可随机访问帧开始,在H264中就是IDR帧。
从IDR帧开始解码
对于点播视频,mp4/flv/mkv的头信息中都会保存整个视频的IDR帧索引,seek时需要定位到原seek位置附近的IDR帧再送数据给解码器。 如果要实现短视频中的精确seek逻辑,可以先seek到离目标位置最近的上一个IDR帧开始解码,但不输出图像,直到目标位置的视频被解码出来。
刷新解码器
进行seek操作时,除了要保证从IDR帧开始之外,还需要在送新的IDR帧数据前对解码器进行刷新操作。
- Android平台可以通过调用MediaCodec的flush()接口来实现。
- iOS平台则需要重新创建videoToolBox.
前后台切换
对Android、iOS平台,都存在App切后台,播放器渲染View被销毁而导致解码出错的情况。
切回前台的处理
App切到后台时,iOS的videoToolBox session会失效,切回前台后原session也不能继续使用,需重新创建videoToolBox实例;Android平台在配置了Surface的情况下,如果Surface被销毁,则在切回前台时也需要配置新的Surface来重新创建并初始化MediaCodec.
如果我们要提高用户体验,实现前后台切换时的无缝播放,而不是重新拉流,那么可以在用户切后台的时候暂停播放,切回前台时重新创建解码器,继续从原位置开始播放。
不过参考前面seek章节的说明,我们恢复播放的位置很可能不是IDR帧,这种情况下就会出现切回前台后画面会先黑一段时间,直到下一个IDR帧被解码。黑屏的时间会跟视频流的IDR帧间隔有关,最差情况下黑屏时间接近IDR帧间隔。 为了尽量避免黑屏现象的出现,我们可以参考前面精确seek的处理,在解码过程中一直缓存当前GOP(Group Of Picture)的视频帧数据,在恢复时从当前GOP的IDR帧开始解码但不输出图像,直到恢复点。
不过上述方案也无法100%解决黑屏问题,解码恢复点前的视频数据本身会有时间消耗,GOP越大,解码恢复可能需要的时间也就越长,黑屏时间也就会越长。
Android平台使用TextureView避免Surface被销毁
对Android平台,我们也可以通过使用TextureView渲染来尽量避免Surface被销毁。
具体实现上,可以:
- 在TextureView的onSurfaceTextureAvailable回调中保存当前创建的SurfaceTexture;
- App切后台时,TextureView的onSurfaceTextureDestroyed回调中返回false,不让系统销毁当前的SurfaceTexture;
- 在下一次App切回前台,onSurfaceTextureAvailable回调中,将前面保存的SurfaceTexture通过setSurfaceTexture接口设置给TextureView,并销毁回调参数中传回的surfaceTexture;
- 播放器销毁时,需要销毁保存的surfaceTexture.
无缝分辨率切换的处理
考虑到用户网络的差异性,以及不同时间段的拥堵状况不同,为了兼顾拉流清晰度与流畅度,我们可以通过实时检测用户的网络情况,并动态切换视频的分辨率、码率来提高播放体验。
rtmp直播,http/flv直播,hls直播以及hls点播可以支持动态分辨率切换。
分辨率切换时需要拿到新的SPS/PPS并重启解码器
- 对于rtmp, http/flv直播,以及mp4分片的hls视频,分辨率切换时我们能够拿到新的AVCC格式的extradata(使用ffmpeg解封装时这个信息是在AVPacket的sidedata中), 此时需要用新的extradata数据重新创建解码器,所需的分辨率信息可以从extradata中解析出来。
- 而对于ts切片的hls直播点播视频,SPS/PPS信息是以Annex-B格式保存在正常的NALU中,而且每个IDR帧前都会有SPS/PPS的NALU。对此,我们需要监控每个收到的视频包,获取其NALU类型,如果是SPS/PPS, 则从中解析出分辨率等信息,如果有变化,则用新的SPS/PPS重新创建解码器。
播放完成时避免遗漏最后几帧
前面我们提到过,编码后的视频帧之间存在着参考关系,而且存在双向参考帧(B帧)的视频流其解码输出顺序和输入的顺序是不同的,同时解码器在异步模式下也不会立即返回解码后的视频帧,这就导致我们在输入最后一帧数据给解码器后,可能还会有一些视频帧没有输出。
为了避免遗漏最后几帧的情况,我们需要做一些处理:
- Android平台需要给MediaCodec送入一个带有BUFFER_FLAG_END_OF_STREAM标记的buffer数据(可以是空buffer),然后等待MediaCodec输出带有该标记的内容,再销毁解码器,结束播放。
- iOS平台需要在送完最后一帧数据后,调用VTDecompressionSessionWaitForAsynchronousFrames接口,该接口会等待所有未输出的视频帧输出结束后再返回。
VideoToolBox兼容不标准的多slice视频
在iOS平台的硬解的实践中,我们可能会遇到如下图的这种情况(上面一部分有画面,下面部分是绿屏):
这种现象实际上就是多slice视频的组织格式不符合VideoToolBox的要求引起的。
以上图的视频为例,该视频流的每一帧是由3个slice构成的,对于VideoToolBox可以正常解码的组织格式应该如下图所示:
而该视频的帧组织方式则如下图所示:
可以看出,该视频混用了AVCC与Annex-B格式的分隔符,导致iOS VideoToolBox只能解码第一个slice单元,从而出现下半部分绿屏的情况。
- 对于这类问题视频的处理: 如果是源视频流可控,可以调整源视频流的打包方式,按第一种图示的方式打包。
- 对于不可控的场景,播放器也可以做下兼容:因为一个NALU中的内容一定是不包含startcode的,所以如果在一个NALU中找到了startcode,就可以将其处理成第一种图示中的格式。