播放器的封装层:三个层次、写线程与时间戳

从零做一个全平台播放器·约 1676 字

帧编成包,只是把画面压进了一行行字节;要变成能播的文件,还得有人把两路流合到一起、给每帧安排时间、把不该落的包丢掉。这一篇写封装这一层:三档分工是谁管什么、写出去为什么只有一条队列一条线程、时间戳在写之前要做哪两次换算,以及这一层另外两种用法——不解码的转封装,和不落文件时把帧与声音交出去。

封装的三个层次:接口、只收包的一层、加编码的一层

封装三档:只收包的一层、加了两路编码流的一层,以及写出去的那条线程

三个层次是叠起来的。最上面是接口,描述的是「开、关、现在什么状态」加上几个设置(要不要硬编、编什么编码)。中间那一档只干封装:音视频的包按类型塞进来,它不管包是怎么来的。最下面一档在这之上加了两路编码流,一路视频一路音频,收的是原始帧——有 YUV 平面、GPU 帧、PCM 数据块——编完再交给上面那一档。

两路流各管一路的三个决定:描述怎么定(编码器要什么尺寸与格式),用哪个编码器(候选链,和录制链那一篇讲的是同一套),以及编完的包往哪送。

开流先把两路的参数集备好

视频没有配置帧,这个输出直接开不起来——配置帧要合并成一段放进流的描述里。音频这边 AAC 得现算一个解码配置头。两样都得在写头之前备齐。

写出去只有一条队列和一条线程

输出这一侧拿着一份输出上下文和一条队列,自己是一条线程。音视频的包不分两条队列,都进同一条先进先出的队列,队列有上限、满了就阻塞入队的调用方。

交错不是自己按时间戳比出来的:包一条条交给封装库的交错写,由它按两条流的时间戳去排。写线程在循环里做三件事:攒访问单元、丢掉不该落的包、把包交给封装实现。

攒访问单元是因为容器里一个样本就是一帧:同一个解码时间戳的几个包是同一帧的多个切片,要合并成一个访问单元再写,时间戳一变先把上一个冲出去。配置包不参与合并,直通。

配置中途变了还有一步:新的配置帧会被补投到那个关键帧前面一点,让解码器在用它之前先拿到说明书。

丢掉的是三类包。一类是填充用的分隔包(AUD),它常常和真正的数据帧同一个时间戳,留着会把样本切坏;一类是没有任何图像数据的纯元数据访问单元,它落进去会在文件开头留一个孤零零的访问单元;还有一类是解码时间戳小于零的包——那是时间戳本身错的帧,写进文件会在错误的时间点上闪一下。

流的初始化也在这里等:等到第一个关键帧来了才建流、写头,所以一个文件的第一个访问单元就是关键帧。

收尾有两处:退出时把最后攒着的那个访问单元冲出去;关闭之后队列里剩下的包还要排干——离线快速转封装时,尾部几十帧往往都还堆在队列里,丢掉就是截短片。这段排空有时间上的兜底,免得收尾卡在一条写不出去的目标上。

时间戳:首包归零,再换到流的时间基

时间戳的两步:首包归零、换到流的时间基;音频没有时间戳时按锚点合成

链上的时间戳一路以毫秒走,编码器也是毫秒基。到了写这一步要做两件事:把第一个包当基准、所有包的戳减掉它;再按目标流自己的时间基换一次。

基准取的是第一个包的解码时间戳,不是渲染时间戳。有 B 帧的流,首帧的解码时间戳可能比渲染时间戳还小,拿渲染时间戳做基准,按解码时间戳排下来的包就会出现负数,而那些负数的包正好被上面那条丢弃规则拦掉——丢的是开头那个关键帧,文件开头就没有关键帧了,播放器前几秒是绿的或者花屏,要等下一个关键帧才恢复。拿解码时间戳做基准,所有包都不小于零。

音频这一侧还有一件补的活。源上来的音频包有可能没有时间戳,直播里更常见。播放那侧不靠包上的时间戳:解码器按采样轴自己往下推。录制这条链没有解码轴,没有时间戳的包直接进来会把基准毒化,所以这里给它合成一个——有有效时间戳的包当锚点,步长从锚点之间学,第一个包从零起量。这也是进度只跟视频时间戳走的原因:音频那些合成出来的戳会前后跳,拿它报进度,进度条会来回窜。

不解码的两种用法:整条转封装与单轨直拷

同一条封装路上挂着一个兄弟:转封装。它不解码也不编码,源出来的编码包原样转发进封装,速度取决于读写的快慢;它没有额外的线程,也没有帧处理那一段。

两个录制器由同一个工厂按一个布尔分派,选「解码后重编」还是「只转封装」。

重编那条还能再混一档:某一轨不重编,包直接拷过去(视频重编、音频直拷这种组合)。直拷要问一句目标容器装不装得下那一轨的编码——装不下就不能拷,得留下来重编;音频那道检查挂在开流的时候,和前面 mp4 换 mov 是同一个判据。

两条链最后都落到同一个封装实现上,输出目标也都是一行地址。

输出留空:取帧与抽音频

输出留空时,这条链干脆不建封装器,帧只经处理段的回调对外。取画面用的就是这条路:截一张图,或者把每帧交给外面的处理。

音频那侧另有一个取法:在处理段上挂一个 tap,tap 自带队列,队列满了丢最旧的,不阻塞。

这两种取法的队列策略与录制那条正好相反。录制宁可把解码顶停也不丢帧,它只对文件的完整负责;tap 那条宁可丢也不卡住喂它的线程,它服务的是实时那一侧——识别慢一点、重采样多一点,都不能让声音卡住。

包到这里就落了盘。同一行输出地址换成网络地址,格式怎么挑、写失败怎么算,是下一篇。

引擎 avox 在 GitHub 开源:github.com/xxxzhou/avox,封装这一层、写线程与时间戳那两步都在里面。

音视频开发 播放器 录制 封装 MP4 时间戳 C++