这篇文章本来想复用以前的 FFmpeg+SDL2 以及 FFmpeg+OpenAL 两篇。但是所用的语言不同,所以设计有差异;FFmpeg 的 API 更新(用的是3.4,当前最新的版本)也要求使用新的 send/receive 解码模型。这次的代码是从零开始一步一步写的,比起以前没搞懂,凭着模糊的直觉复制粘贴去做的那两篇,算是有所长进。我终于敢说,整个流程弄清楚了。
这个工程的考虑就不只是网上的那些“简单的播放器”的那么一点了,我本来也没想把它做得多“简单”,只是写得尽量简明。目标是一个成熟、稳定的库,就得十分注意正确性、鲁棒性和效率。编写过程中也碰到了一些困难,所幸都解决了。
文章分为六个部分。第一部分讲基本概念;第二部分讲视音频解码;第三部分讲同步控制;第四部分讲解码输出;第五部分讲工程中用到的一些细节的设计思路;最后一个部分讲一些对比和想法和现状讨论。(原本放在首页的大段文字都放在了文章最后部分,不想让“摘要”看起来太乱。)
代码位于 GitHub。
1、基本概念
1.1 容器和流
平常我们所谓的“音频文件”、“视频文件”,实际上都是容器(container)。一个容器可以包含一个或多个流(stream),流的种类有音频流、视频流、字幕流、数据流(如 Matroska 内嵌字体)等等。每个流使用一种编码,对应一种编解码器(codec)。关于什么容器支持什么流、什么编码的流,请自行学习。
1.2 DTS、PTS、I/P/B 帧
其实这几个概念在这里已经讲得挺清楚的了,这里稍微概括一下。
DTS 是解码时间戳(decompression timestamp,虽然写着是 decompression,不过可以近似认为是 decoding;这个帧应该何时解码),PTS 是显示时间戳(presentation timestamp;这个帧该何时显示)。I 帧是关键帧,P 帧是单向插补帧(比如从前面的 I/P 帧,加上差异计算得到),B 帧是双向插补帧(加上前后的差异)。
DTS 和 PTS 并不一定是顺序相同的。上面的页面展示了一个例子,不过这个例子是有错的。虽然顺序可能不同,但是所有的流都保证,DTS≤PTS。这个说明也可以在 FFmpeg 的文档中见到。其实想想这是很直接的,一个帧必须先解码才能展示嘛。所以那个例子应该修正如下:
PTS: 1 4 2 3
DTS: -2 -1 0 1
Stream: I P B B
注意 DTS 是可能有负的。不过这对于程序而言不是什么事。
1.3 包
这个可能是 FFmpeg 特有的概念。设计的原因估计是考虑网络传输,将一个或多个帧作为一个整体,也就是一个包(packet)来传输。这样一次解码整个包,得到一个或多个帧,可以一定程度上起到缓冲的作用。
1.4 平面
我曾经在 FFmpeg+OpenAL 那篇文章里有过疑惑,什么是“planar”。现在看来它的确是“平面”的意思。这个概念在像素/采样格式转换的时候会碰到。维基的解释其实已经挺明白了。
我们常用的 RGB 图像只有一个平面,而 YUV 则是典型的多平面的空间(3个平面)。所以在 sws_scale()
中的参数中可以见到两个数组,数据和步长,每个元素就是对应平面的值。音频中的多平面则对应复杂声道(5.1、7.1等等)的情况。
了解之后,才会明白为什么 sws_scale()
和 swr_resample()
的一些参数是数组,以及为什么输出缓冲区只需要一个元素(RGB 和普通双声道都只有一个平面)。
2、解码
2.1 解码准备
在解码之前,要做一些准备工作。
首先先调用 avformat_alloc_context()
创建一个 FormatContext
。它相当于容器的上下文,用来访问容器信息和其中的流。接着调用 avformat_find_stream_info()
获取流信息,因为有的容器可能没有格式的头信息,而这个函数会尝试进行初步解码来探测这些信息。
然后遍历流(formatContext->streams
),找其中可能的音频和视频流。找到符合条件的流的同时记录这个流的开始时间,之后输出的时候会用到。
2.2 解码模型
在当前的 FFmpeg(FFmpeg 3.4.x)中,解码过程遵循这样一个模型:
var packet = GetNextTypedPacket();
SendPacket(packet);
var (frame, state) = ReceiveFrame();
if (Succeeded(state)) {
StoreFrame(frame);
}
while (SucceededAndStillHaveFrames(state)) {
(frame, state) = ReceiveFrame();
if (Succeeded(state)) {
StoreFrame(frame);
}
}
以上是伪代码,而且省略了很多细节,比如错误处理、额外信息的处理、数据结构构造。不过单次解码的框架就是这样的了。
GetNextTypedPacket()
的逻辑是,不断调用 av_read_frame()
,直到得到了一个对应的视频或音频包。其余的包存在队列中等待另一侧处理。接着使用 SendPacket()
(avcodec_send_packet()
)将这个包发送到后台(内存映射?管道?套接字?没研究)等待解码。
发送包后调用 ReceiveFrame()
(avcodec_receive_frame()
)接收一个解码完成的帧,然后将这个帧保存到临时队列中。由于一个包可能对应多个帧,所以要用一个循环来保证所有帧都接收。对于视频最后的若干帧,要按照 FFmpeg 文档所说的,要继续尝试直到返回 EOF,因为有的解码器会在其缓冲区保留一些帧。
还有要注意的是,发送完毕后,解码不是立即的。所以接收的时候仍然有可能会碰到错误,视频帧会收到-11(device not ready)。这个目前我忽略了这个错误,直接继续循环。
2.3 视频解码
在整体的解码模型下,视频解码的特点每个解码完成的视频帧是独立、数据量较大、播放时间较长的。因此在保存视频帧的时候,要使用一个对象池来保存这些帧。概括起来就是发一次,收一次,存一次,多个活动缓冲区(就是对象池啦)。
2.4 音频解码
同样应用解码模型。不过音频帧有点麻烦。包和视频帧一般是一对一的,但是音频帧一般是一对多的。要说视频解码的循环是保险用的话,音频解码的可就要千万注意了。而且单个音频帧数据量较小,解码时间短,播放时间很短,比视频帧更连续。由于我们要用 OpenAL 播放音频,我们得自己管理缓冲区。为了最大化利用缓冲区的容量,不能直接将单个音频帧的数据输送到缓冲区,而是要做准备整体发送。概括起来就是发一次,收多次,存多次,一个活动缓冲区。
2.5 清理
每次解码包的时候要注意释放包的数据和减少引用。FFmpeg 内部维护了包的引用计数,所以如果要进行包的拷贝等操作,要特别注意对应的释放。帧视作不可改变,不过可重用,这个就要通过对象池来管理。最后要注意退出的时候释放所有的包和帧,再关闭各上下文。这些是挺基本的操作,不过处理不好的话容易引发内存泄漏,或抛出异常。
3、音视频同步
在1.2节我们讲了 DTS 和 PTS。这两个值就是同步的核心。由于无法保证任何一个流的解码时间稳定性,同步基准由外部时钟(一个 Stopwatch
)担任。
怎么保证顺序呢?自然是用队列了。
由于存在 P 帧和 B 帧,帧的先后变得非常重要。不过别忘了,在解码之前,我们是不知道帧的,只知道包。FFmpeg 中,每个包(AVPacket
)有一个 dts
字段和一个 pts
字段。dts
保存的是解码时间戳,pts
是预计播放时间戳。解码后的帧(AVFrame
)包含的则是 pts
,实际播放时间戳。对于音频,两个 pts
的值一般是相同的;视频则可能有差异,因为它的 PTS 是重新计算了的。
我们首先要保证解码结果的正确。解码所用的是 DTS,所以在包入队的时候,要保证队列开始的包的 DTS 永远是最小的。这里就有个坑了。在实际的视频文件中,一个包和下一个包的 DTS 不一定是递增或递减的;但是在稍大一点的尺度(我称之为“块”,block)上,一个块中 DTS 最大的包的 DTS 一定小于下一个块中 DTS 最小的包的 DTS。所以,在数据结构上,我们要使用一个非标准队列。SortedList
就是一个合适的数据结构。当然,我实际上自己实现了一个 PacketQueue
,不过现在看来 SortedList
,用 dts
作为键,已经足够了。在此基础上,我们得时刻保证这个队列有一定量的元素(除了视频结束之前),否则出队的元素之间很可能变得不连续。数值上,必须要保证队列中的元素数大于一个块的最大元素数。这个值一般在10以内。
在一帧解码完成后,就要根据这个帧计算后的 PTS 进行入队和排序。同样地,可以使用 SortedList
。
同步的逻辑很简单,就是将音频和视频帧输出,直到遇到 PTS 在当前时间之后的帧,停止并不输出这个帧。这样能保证准确性,因为有可能帧与帧之间的间隔很大,比如00:00一帧,00:05一帧,但是在00:02的时候就不应该看到00:05的那一帧,而是00:00的那一帧。这就需要保留(retain)输出了的最后一帧的内容。
4、输出
同样地,设计针对视频和音频,会有不同。
4.1 视频输出
画面本身就是不连续的,而我们在一个时间点只需要看到一帧,所以只需要输出同步时保留的最后一帧就可以了。如果接下来的解码没赶上,就继续输出这一帧。将这个帧使用 sws_scale()
缩放(和调整像素格式)后,将像素数据复制到 Texture2D
中就可以了。
4.2 音频输出
音频是连续的,所以要将此次输出的最终帧前的所有帧的数据输出到一个 AudioBuffer
,来保证最大化利用这个 AudioBuffer
。然后将这个 AudioBuffer
提交到 AudioSource
的缓冲队列中。最后更新 AudioSource
的缓冲列表,标记可以重用的 AudioBuffer
。AudioBuffer
、AudioSource
等等是我对 OpenAL 的封装。和视频对应,音频有 swr_resample()
来转换采样率和采样格式。
稍微吐槽一下。网上讲 OpenAL 的文章很少,讲用已知数量的缓冲区播放大文件的有一篇,而讲到我这种从不确定的流中来的动态分配缓冲就没有。最后是一边看着 Tizen 的指导(同样缺少缓冲管理,只是一个简单的死循环)推测内部过程一边摸索出来的。
5、一些设计
5.1 多线程
和之前(初春引擎和 FFmpeg+SDL2 的文章)的实现不同,这次为了保证播放流畅,用工作线程来进行解码和同步。和前端仅有的重叠部分就是写 Texture2D
的像素,不过这也用了一个锁来保证不覆盖(其实这里也可以用 swap chain,效率应该会更好)。音频是后台提交 OpenAL 播放的。
但是多线程有一个弱势,就是异常的捕获。主线程的异常很容易就由调试器捕获了,但是工作线程一旦发生异常,没有捕获的话,直接就导致程序崩溃。所以一开始的时候我是这么写的:
try {
while (true) {
// ...
}
} catch (Exception ex) {
ThrowOnMainThread(ex);
}
这个 ThrowOnMainThread()
的技巧是使用主线程的 SynchronizationContext
,借其 Post()
方法,用一个 lambda 函数将这个异常在主线程抛出。注意,如果 SynchronizationContext.Current
为 null
,则需要新建一个。
虽说这样能抛出,但是堆栈跟踪的信息就全丢失了,异常的抛出位置变成了这个 lambda 函数所在的位置。怎么解决呢?用一个构造函数支持 InnerException
的异常,将原始异常包住就可以了。我用的就是 ApplicationException
。调试的时候展开 InnerException
属性就能看到原始的异常和引发位置。
5.2 ASF/WMV 的处理
ASF 这个容器很糟糕。在 FFmpeg 的文档中,AVStream
的 start_time
字段有这样一条注释:
The ASF header does NOT contain a correct start_time. The ASF demuxer must NOT set this.
我一开始的时候没有使用 start_time
这个字段来计算真实的 PTS,结果其他视频还好,WMV(用的是 ASF 容器,VC-1 编码)视频全部出现了严重的延迟。所以我去查如何计算真实的 PTS,看到了 start_time
。
但问题又来了。ASF 容器中,start_time
是错误的。但是没有这个值,PTS 就没法计算。摸索了一阵子之后发现了一个经验公式,不过我不知道原理何在:
video_start_pts = avStream->cur_dts / 2
audio_start_pts = 0
这个公式谜之适合所测试的几个 WMV 文件,就先这么用着吧。
5.3 帧缓冲区和分配策略
这个问题不能和同步时的特性分开。见第2.2、第2.3和第3节。
音频由于单帧数据量小,需要进行合并输出。视频不需要合并,但要及时抛弃不需要的帧。总之都要最大化利用缓冲区。视频需要一个对象池管理待输出的帧,需要N个帧缓冲区(实际上是 AVFrame
);而音频只需要在解码视频帧时“顺便”解码并输出,只需要一个帧缓冲区即可,内部使用 MemoryStream
进行合并。
5.4 视频帧缓存
考虑到解码和 sws_scale()
都是开销较大的工作,所以在输出到 Texture2D
之前会先将数据放到视频帧缓存中。这个缓存一个视频只需要一个就够了。之后如果查询得知不需要解码新的帧,则会直接输出缓存的内容。当然,写 Texture2D
的时候也不用次次分配新的数组,这个是可以用单个缓存优化的。
音频帧就没有这个问题,因为缓冲区分配策略和输出策略不同,也不会重获取帧内容。
5.5 一些重用
sws_context
和 swr_context
是可以重用的。有参数发生变化的时候只需要更新就可以了,不用每次都分配新的上下文。
使用的若干个对象池也是为了尽量减少缓冲对象的分配。
5.6 av_seek_frame()
的坑
av_seek_frame()
的定义如下:
int av_seek_frame(AVFormatContext *s, int stream_index, int64_t timestamp, int flags);
其中 stream_index
是目标流,-1表示所有流;time_stamp
的单位是对应流的 time_base
。
在实际使用的时候:
ffmpeg.av_seek_frame(FormatContext, -1, 0, ffmpeg.AVSEEK_FLAG_BACKWARD);
很奇怪,如果 stream_index
分别指定为视频流和音频流,分别去 seek 的时候,视频解码就会出错,错误码是-11,音频没问题。在表现上,就是前十秒左右画面都是静止的,内容是静止的这段时间的最后一帧,然后突然正常播放。如果用-1反而没问题,不知道为什么。
这个函数在 Reset()
的时候要用到。重新播放一段视频(而不先卸载)的情况下就需要这个。这是实现 IsLooped
属性所必需的。
5.7 FFmpeg 的 API 变更
由于 FFmpeg 3.4 还比较新,所以没有多少人写关键的两个函数对 avcodec_send_packet/frame()
和 avcodec_receive_frame/packet()
。许多教程还是停留在 avcodec_decode_video2()
和 avcodec_decode_audio4()
上。但是在 FFmpeg 更新之后,所有的编解码器都要求实现新的 send/receive API,而不保证兼容以前的 API 了。在实验中,使用老 API 的版本(是的,两个解码函数,每个都尝试了两套 API)工作就不正常。当然也有可能是我手滑了。
6、一些感受
MilliSim 正在朝着使用 OpenGL+OpenAL 跨平台的方向前进,用 MonoGame 换掉原来的自制图形引擎(使用 D3D11)+NAudio(WAS API)。本文记载的内容原来是被设计用来替代 MilliSim 中用来播放视频的 MediaEngine
,命名为 OpenMLTD.Projector
,单独为一个项目。不过后来想到有一些通用的东西还可以加,于是重新规划为 MonoGame.Extended2
的子项目。
以前在做初春引擎的时候用的是 GebVideo.FFMPEG。虽说当时音频不会做,视频勉强播放起来了,但是还是有个严重的问题,就是这个程序集用的是 C++/CLI。C++/CLI 的缺点是和编译出的代码所使用的编译器版本、CRT 版本、平台、C/C++ 的内部结构定义都强相关。生成的程序集可能也使用了内部 API。所以如果要跨平台编译和运行的话,首选自然是 P/Invoke。
MonoGame 有多个平台的实现分支。其中 Windows 上后端是 D3D11+XAudio2;Windows/macOS/Linux 桌面环境(DesktopGL)上是 OpenGL+OpenAL;手机操作系统上的图形分别是 D3D11、Metal 和 OpenGL ES,音频则是各家的系统 API。有意思的是,其他的选择都有对应的 Video
和 VideoPlayer
实现,就 DesktopGL 没有。这应该就是授权的问题了,因为 DesktopGL 独立于平台,自然不能去调用平台相关的媒体 API。而媒体 API 背后,则是解码、同步、绘制等等一系列的功能。就说最关键的解码,你要说带个 FFmpeg/VLC 行不行呢?行,但是人家授权可是(主要是)LGPL 2.1,和 GPLv2 啊。所以 MonoGame 作为 MIT 授权的使用者,不会去用它们的。还有 MonoGame 的目标是封装系统中已有的功能,第三方库不属于这个范畴,除了可能在 Content Pipeline 中使用(如 SharpFont、FFmpeg),不会带到框架中的。
但做总是能做的,而且很可能有人做了。我虽然写了那么一个玩意儿,但是更想知道为什么这样的组件没人开源。