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

三个层次是叠起来的。最上面是接口,描述的是「开、关、现在什么状态」加上几个设置(要不要硬编、编什么编码)。中间那一档只干封装:音视频的包按类型塞进来,它不管包是怎么来的。最下面一档在这之上加了两路编码流,一路视频一路音频,收的是原始帧——有 YUV 平面、GPU 帧、PCM 数据块——编完再交给上面那一档。
两路流各管一路的三个决定:描述怎么定(编码器要什么尺寸与格式),用哪个编码器(候选链,和录制链那一篇讲的是同一套),以及编完的包往哪送。
开流先把两路的参数集备好
视频没有配置帧,这个输出直接开不起来——配置帧要合并成一段放进流的描述里。音频这边 AAC 得现算一个解码配置头。两样都得在写头之前备齐。
写出去只有一条队列和一条线程
输出这一侧拿着一份输出上下文和一条队列,自己是一条线程。音视频的包不分两条队列,都进同一条先进先出的队列,队列有上限、满了就阻塞入队的调用方。
交错不是自己按时间戳比出来的:包一条条交给封装库的交错写,由它按两条流的时间戳去排。写线程在循环里做三件事:攒访问单元、丢掉不该落的包、把包交给封装实现。
攒访问单元是因为容器里一个样本就是一帧:同一个解码时间戳的几个包是同一帧的多个切片,要合并成一个访问单元再写,时间戳一变先把上一个冲出去。配置包不参与合并,直通。
配置中途变了还有一步:新的配置帧会被补投到那个关键帧前面一点,让解码器在用它之前先拿到说明书。
丢掉的是三类包。一类是填充用的分隔包(AUD),它常常和真正的数据帧同一个时间戳,留着会把样本切坏;一类是没有任何图像数据的纯元数据访问单元,它落进去会在文件开头留一个孤零零的访问单元;还有一类是解码时间戳小于零的包——那是时间戳本身错的帧,写进文件会在错误的时间点上闪一下。
流的初始化也在这里等:等到第一个关键帧来了才建流、写头,所以一个文件的第一个访问单元就是关键帧。
收尾有两处:退出时把最后攒着的那个访问单元冲出去;关闭之后队列里剩下的包还要排干——离线快速转封装时,尾部几十帧往往都还堆在队列里,丢掉就是截短片。这段排空有时间上的兜底,免得收尾卡在一条写不出去的目标上。
时间戳:首包归零,再换到流的时间基

链上的时间戳一路以毫秒走,编码器也是毫秒基。到了写这一步要做两件事:把第一个包当基准、所有包的戳减掉它;再按目标流自己的时间基换一次。
基准取的是第一个包的解码时间戳,不是渲染时间戳。有 B 帧的流,首帧的解码时间戳可能比渲染时间戳还小,拿渲染时间戳做基准,按解码时间戳排下来的包就会出现负数,而那些负数的包正好被上面那条丢弃规则拦掉——丢的是开头那个关键帧,文件开头就没有关键帧了,播放器前几秒是绿的或者花屏,要等下一个关键帧才恢复。拿解码时间戳做基准,所有包都不小于零。
音频这一侧还有一件补的活。源上来的音频包有可能没有时间戳,直播里更常见。播放那侧不靠包上的时间戳:解码器按采样轴自己往下推。录制这条链没有解码轴,没有时间戳的包直接进来会把基准毒化,所以这里给它合成一个——有有效时间戳的包当锚点,步长从锚点之间学,第一个包从零起量。这也是进度只跟视频时间戳走的原因:音频那些合成出来的戳会前后跳,拿它报进度,进度条会来回窜。
不解码的两种用法:整条转封装与单轨直拷
同一条封装路上挂着一个兄弟:转封装。它不解码也不编码,源出来的编码包原样转发进封装,速度取决于读写的快慢;它没有额外的线程,也没有帧处理那一段。
两个录制器由同一个工厂按一个布尔分派,选「解码后重编」还是「只转封装」。
重编那条还能再混一档:某一轨不重编,包直接拷过去(视频重编、音频直拷这种组合)。直拷要问一句目标容器装不装得下那一轨的编码——装不下就不能拷,得留下来重编;音频那道检查挂在开流的时候,和前面 mp4 换 mov 是同一个判据。
两条链最后都落到同一个封装实现上,输出目标也都是一行地址。
输出留空:取帧与抽音频
输出留空时,这条链干脆不建封装器,帧只经处理段的回调对外。取画面用的就是这条路:截一张图,或者把每帧交给外面的处理。
音频那侧另有一个取法:在处理段上挂一个 tap,tap 自带队列,队列满了丢最旧的,不阻塞。
这两种取法的队列策略与录制那条正好相反。录制宁可把解码顶停也不丢帧,它只对文件的完整负责;tap 那条宁可丢也不卡住喂它的线程,它服务的是实时那一侧——识别慢一点、重采样多一点,都不能让声音卡住。
包到这里就落了盘。同一行输出地址换成网络地址,格式怎么挑、写失败怎么算,是下一篇。
引擎 avox 在 GitHub 开源:github.com/xxxzhou/avox,封装这一层、写线程与时间戳那两步都在里面。
音视频开发 播放器 录制 封装 MP4 时间戳 C++