用JavaScript编写MPEG1解码器

用JavaScript编写MPEG1解码器

几年前,我开始从事于完全用JavaScript编写的MPEG1视频解码器上。现在,我终于找到了清理该库的时间,改善其性能、使其具有更高的错误恢复能力和模块化能力,并添加MP2音频解码器和MPEG-TS解析器。这使得该库不仅仅是一个MPEG解码器,而是一个完整的视频播放器。

在本篇博文中,我想谈一谈我在开发这个库时遇到的挑战和各种有趣的事情。你将在官方网站上找到demo、源代码和文档以及为什么要使用JSMpeg:

重构

最近,我需要为一位客户在JSMpeg中实现音频流传输,然后我才意识到该库处于一种多么可怜的状态。从其首次发布以来,它已经有很多发展了。在过去的几年里,WebGL渲染器、WebSocket客户端、渐进式加载、基准测试设备等等已被加入。但所有这些都保存在一个单一的、庞大的类中,条件判断随处可见。

我决定首先通过分离它的逻辑组件来梳理清楚其中的混乱。我还总结了完成实现需要哪些:解复用器、MP2解码器和音频输出:

  • 源代码(Sources): AJAX, 渐进式AJAX和WebSocket
  • 解复用器(Demuxer): MPEG-TS (Transport Stream)
  • 解码器(Decoder): MPEG1视频& MP2音频
  • 渲染器(Render): Canvas2D & WebGL
  • 音频输出:WebAudio

加上一些辅助类:

  • 一个位缓存(Bit Buffer),用于管理原始数据
  • 一个播放器(Player),整合其他组件

每个组件(除了Sources之外)都有一个.write(buffer)方法来为其提供数据。这些组件可以“连接”到接收处理结果的目标组件上。流经该库的完整流程如下所示:

用JavaScript编写MPEG1解码器

JSMpeg目前有3种不同的Source实现(AJAX\AJAX渐进式和WebSocket),还有2种不同的渲染器(Canvas2D和WebGL)。该库的其他部分对这此并不了解 - 即视频解码器不关心渲染器内部逻辑。采用这种方法可以轻松添加新的组件:更多的Source,解复用器,解码器或输出。

我对这些连接在库中的工作方式并不完全满意。每个组件只能有一个目标组件(除了多路解复用器,每个流有都有一个目标组件)。这是一个折衷。最后,我觉得:其他部分会因为没有充分的理由而过度工程设计并使得库过于复杂化。

WebGL渲染

MPEG1解码器中计算密集度最高的任务之一是将MPEG内部的YUV格式(准确地说是Y'Cr'Cb)转换为RGBA,以便浏览器可以显示它。简而言之,这个转换看起来像这样:

用JavaScript编写MPEG1解码器

对于单个1280x720视频帧,该循环必须执行921600次以将所有像素从YUV转换为RGBA。每个像素需要对目标RGB数组写入3次(我们可以预先填充alpha组件,因为它始终是255)。

这是每帧270万次写入操作,每次需要5-8次加、减、乘和位移运算。对于一个60fps的视频,我们每秒钟完成10亿次以上的操作。再加上JavaScript的开销。JavaScript可以做到这一点,计算机可以做到这一点,这一事实仍然让我大开眼界。

使用 WebGL ,这种颜色转换(以及随后在屏幕上显示)可以大大加快。逐像素的少量操作对 GPU 而言是小菜一碟。GPU 可以并行处理多个像素,因为它们是独立于任何其他像素的。运行在 GPU 上的 WebGL 着色器(shader)甚至不需要这些烦人的位移 - GPU 喜欢浮点数:

用JavaScript编写MPEG1解码器

使用 WebGL,颜色转换所需的时间从 JS 总时间的 50% 下降到仅需 YUV 纹理上传时间的约 1% 。

我遇到了一个与 WebGL 渲染器偶然相关的小问题。JSMpeg 的视频解码器不会为每个颜色平面生成三个 Uint8Arrays ,而是一个 Uint8ClampedArrays 。它是这样做的,因为 MPEG1 标准规定解码的颜色值必须是紧凑的,而不是分散的。让浏览器通过 ClampedArray 进行交织比在 JavaScript 中执行更快。

依然存在于某些浏览器(Chrome和Safari)中的缺陷会阻止WebGL直接使用Uint8ClampedArray。因此,对于这些浏览器,我们必须为每个帧的每个数组创建一个Uint8Array视图。这个操作非常快,因为没有需要真实复制的事情,但我仍然希望不使用它。

JSMpeg会检测到这个错误,并仅在需要时使用该解决方法。我们只是尝试上传一个固定数组并捕获此错误。令人遗憾的是,这种检测会触发控制台中的一个非静默的警告,但这总比没有好吧。

用JavaScript编写MPEG1解码器

对直播流媒体的WebAudio

很长一段时间里,我假设为了向WebAudio提供原始PCM样本数据而没有太多延迟或爆破音,你需要使用ScriptProcessorNode。只要你从脚本处理器获得回调,你就可以及时复制解码后的采样数据。这确实有效。我试过这个方法。它需要相当多的代码才能正常工作,当然这是计算密集型和不优雅的作法。

幸运的是,我最初的假设是错误的。

WebAudio上下文维护自己的计时器,它有别于JavaScript的Date.now()或performance.now()。 此外,你可以根据上下文的时间指导你的WebAudio源在未来的准确时间调用start()。有了这个,你可以将非常短的PCM缓冲器串在一起,而不会有任何瑕疵。

你只需计算下一个缓冲区的开始时间,就可以连续添加所有之前的缓冲区的时间。总是使用 WebAudio Context 自己的时间来做这件事是很重要的。

用JavaScript编写MPEG1解码器

不过需要注意的是:我需要获得队列音频的精确剩余时间。我只是简单地将它作为当前时间和下一个启动时间的区别来实现:

用JavaScript编写MPEG1解码器

我花了一段时间才弄明白,这行不通。你可以看到,上下文的 currentTime 只是每隔一段时间才更新一次。它不是一个精确的实时值。

用JavaScript编写MPEG1解码器

因此,如果需要精确的音频播放位置(或者基于它的任何内容),你必须恢复到 JavaScript 的  performance.now() 方法。

iOS 上的音频解锁

你将要爱上苹果时不时扔到 Web 开发人员脸上的麻烦。其中之一就是在播放任何内容之前都需要在页面上解锁音频。总的来说,音频播放只能作为对用户操作的响应而启动。你点击了一个按钮,音频则播放了。

这是有道理的。我不反驳它。当你访问某个网页时,你不希望在未经通知的情况下发出声音。

是什么让它变得糟糕透顶呢?是因为苹果公司既没有提供一种利索的解锁音频的方法,也没有提供一种方法来查询 WebAudio Context 是否已经解锁。你所要做的就是播放一个音频源并不断检查是否正在顺序播放。尽管如此,在播放之后你还不能马上检查。是的,你必须等一会!

用JavaScript编写MPEG1解码器

通过 AJAX 逐步加载

比如说你有一个 50MB 的文件要通过 AJAX 加载。该视频刚开始加载时没有问题。你甚至可以检查其当前的进度(下载量 vs 总量),并且呈现出一个很好的加载动画。你不能做的只是当剩余文件还在加载时访问已下载的数据。

对于增加分块 ArrayBuffers 到 XMLHttpRequest 中有一些提议,但是还没有通过浏览器实现。较新的 fetch API (我仍然不明白它的目的)提出了一些相似的特点,但还是没有通过浏览器的支持。然而,我们仍可以在 JavaScript 中使用 Range-Requests 来做分块下载。

HTTP 标准实现了一个 Range 头部,允许你仅仅抓取一个资源的部分内容。如果你只需要一个大文件的前 1024 个字节,你可以在请求的头文件中设置标头 Range:bytes = 0-1024 。然而在我们开始之前,我们必须知道文件的大小。我们可以使用 HEAD 请求代替一个 GET 请求来实现。这样返回的仅仅是资源的 HTTP 头部,而没有主体内容。几乎所有的网页服务器都支持范围请求。我知道的一个例外是 PHP 内置的开发服务器。

JSMpeg 通过 AJAX 下载的默认块大小为 1mb 。JSMpeg 还为每个请求附加一个自定义的 GET 参数给 URL(例如 video.ts?0-1024 ),这样每个块从根本上都有自己的 URL ,并且可以和糟糕的缓存代理一起使用。

有了这个,你可以在第一个块到达后立即开始播放文件。此外,只有在需要时才会下载更多的块。如果有人只观看视频的前几秒,那么只有前几秒数据才会被下载。JSMpeg 通过测量加载块所花费的时间来实现这一点,添加了大量的安全边界,并将其与已加载的块的剩余持续时间进行比较。

在 JSMpeg 中,解复用器会尽可能快地切分流。它还要解析每个数据包的显示时间戳(PTS)。然而,视频和音频解码器仅实时地增加其播放位置。最后的解复用的 PTS 和解码器当前 PTS 之间的差值是已下载块的剩余播放时间。播放器定期使用此 headroom 时间调用 Source 的 resum() 方法:

用JavaScript编写MPEG1解码器

音频&视频同步

JSMpeg 尝试尽可能平滑地播放音频。它在对音频采样数据入队时不会引入任何间隙或压缩。视频播放将以音频的播放位置作为参考。它是这样做的,因为即使是最微小的间隙或不连续性,在音频中也比在视频中更为明显。如果视频帧延迟几毫秒或丢失,还不是那么容易被察觉。

大多数情况下,JSMpeg依赖于MPEG-TS容器的播放时间戳(PTS)进行播放,而不是自己计算播放时间。这意味着,MPEG-TS文件中的PTS必须一致且准确。从我从互联网上所收集的信息来看,情况并非总是如此的。但现代编码器似乎已经认识到了这一点。

一个复杂因素是PTS并不总是从0开始。例如,如果你连接到了WebCam并运行了一段时间,则PTS可能是打开WebCam时的启动时间,而不是开始录像时的时间。因此,JSMPeg会搜索它可以找到的第一个PTS,并将其用作所有流的全局开始时间。

MPEG1和MP2解码器还会跟踪每个PTS的缓冲区位置以及它们收到的所有PTS。有了这些信息,我们可以将音频和视频流seek到特定的时间。

目前,JSMpeg会高兴地寻找一个帧间帧并在先前解码的帧之上解码它。处理这个问题的正确方法是在我们要seek的帧之前将其回退到最后的关键帧并解码其间的所有帧。 这是我仍然需要解决的问题。

构建工具和Javascript生态系统

我尽量避免使用构建工具。很有可能的是,你的漂亮工具集可以为你自动完成所有的事情,但在一到两年内就会停止工作。建立你的构建环境从来都不像“只调用webpack”那么简单,或者使用“咕哝”或者“跑任务”是当今的“热点”。它总是像这样

(...) webpack 来自哪里? 哦, 我需要 npm。

npm 来自哪里? 哦,我需要 nodejs。

nodejs 来自哪里? 哦, 我需要 homebrew。

那是什么? gyp 编译错误? 哦,我需要安装 XCode。

哦, webpack 需要 babel 插件?

什么?  left-pad 依赖还是没有解决?

...

突然,你花了两个小时的时间,下载了几GB的工具。所有的构建一个 20kb 的库,对于一种甚至不需要编译的语言。2年后我如何建立这个库?那5年呢?

我已彻底了解webpack并讨厌之。对我而言,它的方式过于复杂了。我想了解其内部发生了什么事。这是我编写这个库而不是深入WebRTC原因的一部分。

所以,JSMpeg的构建步骤是一个shell脚本,仅需调用一次uglifyjs即可在2秒内更改为使用cat(或在Windows上copy)。或者,你只需在HTML中单独加载源文件即可。搞定。

质量,比特率以及后续计划

在合理的比特率下,令我感到惊讶,MPEG1的质量根本不算差。看看jsmpeg.com上的演示视频 - 当然,这是适合压缩的示例。缓慢的动作以及不是太多的切割。尽管如此,此视频占用50mb,因为它是4分钟时长,并且可提供与大多数Youtube视频相媲美的质量,而Youtube视频仅“小”30%。

在我的测试中,我总是可以获得我认为使用最高2Mbit/s的“高质量”的视频。根据你的用例(想要一个coffe cam?),你可以达到100Kbit/s甚至更低。对于比特率/帧率,这里没有下限。

相关推荐