使用 FFmpeg 在 MonoGame 中播放视频

文章目录
  1. 1. 1、基本概念
    1. 1.1. 1.1 容器和流
    2. 1.2. 1.2 DTS、PTS、I/P/B 帧
    3. 1.3. 1.3 包
    4. 1.4. 1.4 平面
  2. 2. 2、解码
    1. 2.1. 2.1 解码准备
    2. 2.2. 2.2 解码模型
    3. 2.3. 2.3 视频解码
    4. 2.4. 2.4 音频解码
    5. 2.5. 2.5 清理
  3. 3. 3、音视频同步
  4. 4. 4、输出
    1. 4.1. 4.1 视频输出
    2. 4.2. 4.2 音频输出
  5. 5. 5、一些设计
    1. 5.1. 5.1 多线程
    2. 5.2. 5.2 ASF/WMV 的处理
    3. 5.3. 5.3 帧缓冲区和分配策略
    4. 5.4. 5.4 视频帧缓存
    5. 5.5. 5.5 一些重用
    6. 5.6. 5.6 av_seek_frame() 的坑
    7. 5.7. 5.7 FFmpeg 的 API 变更
  6. 6. 6、一些感受

这篇文章本来想复用以前的 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 的缓冲列表,标记可以重用的 AudioBufferAudioBufferAudioSource 等等是我对 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.Currentnull,则需要新建一个。

虽说这样能抛出,但是堆栈跟踪的信息就全丢失了,异常的抛出位置变成了这个 lambda 函数所在的位置。怎么解决呢?用一个构造函数支持 InnerException 的异常,将原始异常包住就可以了。我用的就是 ApplicationException。调试的时候展开 InnerException 属性就能看到原始的异常和引发位置。

5.2 ASF/WMV 的处理

ASF 这个容器很糟糕。在 FFmpeg 的文档中,AVStreamstart_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_contextswr_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。有意思的是,其他的选择都有对应的 VideoVideoPlayer 实现,就 DesktopGL 没有。这应该就是授权的问题了,因为 DesktopGL 独立于平台,自然不能去调用平台相关的媒体 API。而媒体 API 背后,则是解码、同步、绘制等等一系列的功能。就说最关键的解码,你要说带个 FFmpeg/VLC 行不行呢?行,但是人家授权可是(主要是)LGPL 2.1,和 GPLv2 啊。所以 MonoGame 作为 MIT 授权的使用者,不会去用它们的。还有 MonoGame 的目标是封装系统中已有的功能,第三方库不属于这个范畴,除了可能在 Content Pipeline 中使用(如 SharpFont、FFmpeg),不会带到框架中的。

但做总是能做的,而且很可能有人做了。我虽然写了那么一个玩意儿,但是更想知道为什么这样的组件没人开源。

分享到 评论