播放器的解码接入与解码器选型

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

接源篇把压缩码流喂进了队列,可队列里躺的是 H.264、H.265 的原样字节,屏幕还是黑的。这一步把包解成图像。接口只有一套:输入包、回调帧,谁来解都走这条线;唯一的分岔在出来的帧上——CPU 帧走一个回调,GPU 帧走另一个。先把 FFmpeg 的解码器接进来,把「包到帧」这条线跑通,再立起一张候选表:同一个编码下注册了多少实现,就按排好的次序逐条试,试不通换下一条。平台硬解留到下一章,但接口和这张表从第一天就只认一套。

解码器只有一套接口

从包到帧:IO 包队列 → 解码线程 → 视频轨帧队列 → 渲染;下面一排是解码器候选表的排法

视频解码器的基类只有一个,它继承 AVDecoder 和一个观察者接口。往上一层只调三件事——设上下文、推配置帧、喂一个包;解出来的帧从回调里出来,不由上层去取。

基类留了六个钩子。拿到解码器描述之后跑 onVaild,子类在这里初始化自己的数据;onPreDecoder 配解码器,前提是配置帧已经就绪,没有配置就返回 noConfig;decode 是真正吃包的那一下,回一个 DecodeResult;flush 是 seek 语义,把内部扣住的帧丢掉,它们属于跳走前的旧位置;onInputEnd 是流尾语义,把重排缓冲扣住的最后几帧放出来,不放就永远不显示;onClose 收摊。flush 和 onInputEnd 长得像,要的东西相反:一个要求作废,一个要求放行——早先只有 flush,流尾那一簇就被扣死。

回调面也比「两种帧」宽:onVideoDesc 在解码器真正打开后报一次,onPacket 每喂一个包之前报一次,onVideoComplete 报结束,另两个捎元数据——HDR 静态元数据与杜比视界的整形数据,值变了才回调。

帧的两种落点

接口不区分实现,但帧分两个落点。这不是两条解码线,是同一条线上的两种产物,由解码方式定:CPU 解码出来的是一段 YUV 内存,平台硬解出来的是显存里的纹理,不回内存。

上层不为这件事写分支,它只要知道「拿到了帧」。真正要区分的地方只有一处——渲染器,按解码器类型挑一个接得住这种帧的。解码类型一共五种:cpu、vulkan、iosVT(VideoToolbox)、androidMC(MediaCodec)、dx11(Windows 上 FFmpeg 的 dx11 解码)。这一章跑的是 cpu。

把 FFmpeg 的解码器接进来

FFmpeg 的解码器同时继承 VideoDecoder 和一层管 FFmpeg 的基类,打开是标准三步:找解码器、配上下文、开。

找解码器有两个入口:按编码 ID 用 avcodec_find_decoder,按名字用 avcodec_find_decoder_by_name。注册名分三层:FFmpeg 这条腿用原生名(h264、hevc、vp9);平台硬解的名字带 ff_ 前缀加平台后缀(ff_h264_vulkan、ff_h264_dx11、ff_h264_vaapi);安卓和 iOS 的原生解码器用带空格的描述名。「ff_h264 是软解名」是个常见误会,ff_ 只长在硬解那一侧。AV1 还多一个坑:av1 在 FFmpeg 里是只带硬件加速的包装,没有加速上下文时每个包都报不支持;带了 dav1d 的构建会把它换名成 libdav1d,没带的要把这个壳整条剔掉,免得候选表里躺着一条永远解不出东西的车道。

上下文是常规活:分配、解析配置帧、填像素格式和宽高、挂解码环境(这件事 FFmpeg 的实现在这一步是空的,平台硬解在这里建 D3D11 或 Vulkan 设备)、拼 extradata、打开。两个取值在这里定:解码线程数设成 0,让 FFmpeg 按核数自己定;打开「容忍坏帧」,烂帧照吐,不为一个坏包整段卡死。收发包是两步:送一个包、取一帧;取到帧回调一次,关键帧标志在这里补上——包的帧类型为 1 时置上关键帧位。

候选从一个编码下的全部实现里排出来

这里没有「注册一个解码器、按名造对象」的工厂。解码器挂在一张表上,按编码 ID 索引,同一个编码挂多个实现——FFmpeg 的、dx11、vulkan、平台原生各挂各的,注册就是把「造对象的函数」追加到这个编码名下。候选表取的是这张表在这个编码下的全量。

候选里只记注册表下标,不拷那个造对象的函数:函数的类型擦除信息在注册点生成,插件注册的就落在插件那个动态库里,拷到别处,插件一卸载就成了悬垂。

候选表的排法:偏好只决定谁先试

候选表按四段排:覆盖点名、偏好那一极的提示名、另一极的提示名、注册表其余按注册序兜底。覆盖点名是排障用的,指定哪条车道就强制走哪条,排在首位;排障要验的是指名那条到底行不行,所以它后面不挂 vulkan 备选。

提示名来自一张按平台和编码给的表,它只决定同一极里谁先试;是软解还是硬解不看提示名,看注册表里的硬解标记。软解那一侧的提示名是空的,所以另一极不能写死成某个名字——写死了,设了软解就没有回退。候选表直接取注册表全量,另一极因此总有候选。vulkan 那一族整体排最后:h264 和 h265 才有,而且各平台设上下文时要去探一次运行时,排在前面会让别的候选白等。

用的时候逐条试:initFunc 建出实例,接线和设上下文都算「这条车道能不能用」,过不了就记下名字换下一条。试新车道时旧车道还活着——候选全试完才判这条轨死。

车道的两条退出路径

一条车道跑起来之后,有两种情况会退出去找下一条。

一种是打不开。打开是懒的——第一次喂包时才真开会话,失败了返回 openFailed,当场换下一条候选,把配置帧重放进去接着解,不判视频轨死。

另一种是打开了但不出帧,靠首帧看门狗。它有两个讲究:只认实际产出,看的是帧队列里真的来了帧,不是解码返回值——重复的参数集包喂进去会空转返回成功,认返回值会被骗;而且只在持续吃得到输入时计时,包队列空了、读线程还堵在参数探测上不吐包,那属于饿等,计时清零重记。漏掉这一条,数据洪流到来前一刻,硬解那条车道正好被误杀。

连续供给一段时间仍然没有帧,就认定这条车道死了,换下一条。要是这时本地流已经被它读到尾,还得把播放位置退回开头,让新车道能从关键帧起解;候选全试完还是不行,就报解码超时,视频轨收摊。

换道时,配置帧跟着走

换道有交接:新车道起来时,先从上一条车道把配置帧拷过来重喂一遍,再接着吃包。首次选型没有上一条车道,配置帧只能等 IO 送上门。

换道之后还有一个动作:新车道如果从 GOP 中段起解,只会产出缺参考的坏帧,所以要把包丢到下一个关键帧为止;但只在首帧还没出来的时候做。画面已经在出的播放中换道,动落点会造成跳帧,那就保持位置不动。

解码线程的循环

开一条线程,从视频轨的包队列取包,解出来交给视频轨——这是解码任务的全部职责。线程壳由基类提供,循环体是一个纯虚函数,换实现不动线程机制。

循环体按包分两路:是配置帧就推给解码器,不进解包那一步;不是就喂进去解。推配置帧时还要看推的结果:内容和已经存过的一样,这个包直接跳过;参数集尺寸变了,记一个标记,留给需要重建解码器的场合。

第一个成功返回是「解码器打开了」的信号:这时候派发一次描述,把像素格式、宽高、帧率、渲染类型报上去。收到完成标志就派发结束、退出循环;倍速在二到四倍之间时,循环跑快一点,只解关键帧。

取不到包、或者参数集还没攒齐的时候,线程阻塞着等下一轮,不空转;正常解出数据的那些轮次反而要全速跑,不能睡。帧队列满时写帧那一步会阻塞,背压照旧,和读线程堵在入队是一回事。解码线程不需要知道下游是渲染还是录制。

解码失败了:视频停,音频继续

解码器配置失败返回的是负数,处理方式是这一篇里最不显眼、也最要紧的一处:要先把包队列放开再退。包队列满着退出,读线程会永远堵在入队那一步,反过来把解封装也堵死,音频包跟着断供,整条管线一起冻住——高码率片源配上无损音轨时真的撞到过。设计意图是视频这一路停下,音频继续播。

放开队列之后,把错误码交给视频轨,由它往上走:播放器先消化,把状态更新到该去的那一档,再把解码错误回调给上层。错误本身不占状态,走的是回调。

打开之前的两道检查:配置帧数量与包格式

真正开解码器之前有两道检查,过不了就报「缺配置」,不硬开。

一是配置帧数量:H.264 至少要两个(SPS、PPS),H.265 至少要三个(VPS、SPS、PPS)。数的时候只认参数集那几类 NAL,混进来的 SEI、AUD 跳过——不跳的话,一段里夹一个 SEI 就判定失败,参数会退回容器侧的宽高,反倒和先前采信的 SPS 尺寸打架。

二是包格式要和配置帧格式对上。包是 AVCC 还是 AnnexB,在收到第一个包时判一次,判定结果决定附加数据怎么拼。有些平台的原生解码器只吃一种(苹果要 AVCC、安卓要 AnnexB),进解码器之前要转一道;转的时候连 AnnexB 的三字节头都要统一成四字节,解码器可能不认前者。

这一层只摊平畸形时间戳

时间基换算不在这一层做。pts、dts 换成毫秒在 FFmpeg 的 IO 侧完成,音视频字幕一视同仁;B 帧可能没有 pts,拿 dts 顶上。起播基准也在上游定,取第一个视频关键帧的时间,只有整段没有视频轨时才退到第一个音频包。

解码这层只做一件时间戳的活:摊平。老容器里常有一簇包挤在同一个时间点附近,时间戳畸形得不成样子,直接送去解码器会出乱子。摊平器只改送给解码器的那份 pts,容器里的、导出用的一个都不动;做法是按帧率算出一帧的间隔,把同一簇的包按间隔错开,正常流的单包簇位移是零。尾巴上那一簇要等输入排空才放出来,否则最后一簇就扣死在手里。

换实现与 seek 都要等到关键帧

上层改「要不要用平台硬解」时,当场并不换解码器,只下发一条重置命令。命令线程收到后给视频轨置一个重置位,动的是正在跑的解码线程:置位之后不立刻退出,而是等到下一个关键帧(或者包队列空了)才跳出循环。两个实现都只认关键帧做入口,半路切换会把一个 P 帧喂给没有任何参考帧的新解码器,等一个 I 帧才有干净的起点。

seek 走的是另一条路,结论一样。硬解那条车道上验证过:只调解码器的 flush 是接不下去的,flush 清不掉帧序号与图像顺序计数,从非 IDR 的关键帧落点接着解,帧号会一路螺旋到零;软解这一侧 flush 清得掉,但两条车道走同一条重建路径。所以 seek 之后解码器整体重建;只有安卓的 MediaCodec 例外,它有原地 flush 的档,能保住实例和它后面那条纹理链。还有一处顺序讲究:seek 的 flush 不在命令线程里直接调解码器——这个函数跑在命令线程上,解码线程那边可能正卡在「分配上下文到打开」的窗口里,那个窗口内部是空的,同步 flush 打进去一定崩,所以只置一个标志,让解码线程自己在循环里处理。

引擎 avox 在 GitHub 开源:github.com/xxxzhou/avox,这条解码线、那张候选表都在里面。

音视频开发 播放器 FFmpeg 解码 C++