探索纯前端实现实时的视频帧预览

6,644 阅读15分钟

这篇文章主要记录了我探索纯前端实现实时的视频帧预览的过程,并且总结我是如何利用 WebAssembly,将 FFmpeg 的视频处理能力带到 Web 平台中的。 文章中至少给出了以下问题的答案:

  • 如何使用 FFmepg 生成视频缩略拼图?包含 FFmpeg 是什么?
  • 如何使用 WebAssembly 移植 C 程序到浏览器中?
  • 如何在浏览器中解析 mp4 文件以获取其中某一帧的字节数据?
  • 如何发送 HTTP 范围请求?

文章的大纲

  • 什么是视频帧预览?
  • 常见的实现方式
  • 进阶的实现
  • 最终的实现
  • 总结

探索意味着前方都是未知的事物,希望这篇文章能够带着读者,一起回顾我探索的过程,同时学习遇到的种种未知的事物。现在开始吧。

什么是视频帧预览?

在一些视频点播网站的视频播放界面,用户将鼠标移动到进度条上时,会弹出一个浮窗并展示了一张图片,意在告诉用户这张图片是鼠标所在位置的时间点对应的视频画面。而且目前的实现,用户体验是足够好的,预览图出现的速度非常快,而且不同时间范围展示的也是不同的画面,达到模拟实时预览的效果,如图:

这样的视频画面预览功能,我把它称为视频帧预览。而我要探索的,就是如何通过前端技术来实现视频帧预览中的每一个环节,并且实现真正的实时预览。在探索之前,先来了解一下目前常见的实现方式。

常见的实现方式

通过翻看各大视频网站,发现弹窗中的画面一般是一张背景图片,打开背景图片的链接看到的是一张视频缩略拼图。打开 Chrome 浏览器 DevTools 的 Elements 面板,可以看到:

将链接打开是这样一幅图:
可以看出这张图是由视频中不同画面的缩略图拼接而成的,我将它称为视频缩略拼图。那么,这样的拼图又是如何生成的?其中一个方法是使用 FFmpeg。


FFmpeg 是一个非常强大的音视频处理工具,它的官网是这么介绍的:

注:一个用于录制,转换,流式传输音视频的完整的跨平台解决方案。


我写了一个 C 应用程序,实现了如何使用 FFmpeg 生成视频缩略拼图。它接收一个视频文件路径作为参数,获取到参数后,使用 FFmpeg 的方法读取视频文件并经过一系列步骤(解复用 -> 帧解码 -> 帧转码… )处理之后,会在当前目录生成一张拼图。 总结了一下程序逻辑执行的步骤:

  1. 初始化输入:这一步主要做了一些初始化的工作,比如获取入参、读取视频文件、初始化必要的对象并分配内存等;
  2. 初始化解码器:获取适合视频文件的解码器并打开它;
  3. 按指定的间隔时间读取视频帧数据:根据入参指定的间隔时间,从视频文件中读取帧数据;
  4. 按指定的列数排列数据:根据入参指定的拼图每行包含的图片数,排列解码后的帧数据;
  5. 生成拼图文件:将排列好的拼图的字节序列写入图片文件中。

以上是生成视频缩略拼图程序逻辑执行的步骤。因为这部分与这篇文章的主题无关,所以就不贴代码了。感兴趣的同学可以前往 GitHub - VVangChen/video-thumbnail-sprite 查看完整源码,也可以下载可执行文件在本地运行。

进阶的实现方式

上面讲到了如何使用 FFmpeg 生成视频缩略拼图,接下来向探索的目标再进一步。在常见的实现方式中,最重要的一环就是生成视频缩略拼图,那么能不能将这最重要的一环在浏览器中实现呢?答案是肯定的,并且应该能联想到最近比较火热的 WebAssembly,因为它就是为此而生。


WebAssembly,是一门被设计成可以运行在浏览器中的编译目标语言,意在通过移植将原生应用的能力带到浏览器中。如果想要了解更多,可以浏览它的官网WebAssembly 或者前往 WebAssembly | MDN 进行学习。接下来要讲的,是如何将上面实现的,生成视频缩略拼图的 C 程序移植到 Chrome 中。


简单来说,“单纯的移植”只需以下两步:

  1. 使用 emconfigure 和 emmake 配置并编译 FFmpeg;
  2. 使用 emcc 编译上面的 C 程序。

其中 emconfigure、emmake 和 emcc 都是 Emscripten 的 emsdk 提供的工具,通过 emsdk 可以非常简单地将 C/C++ 程序移植到浏览器中。使用 emcc 能够将 C 程序编译成 wasm 模块,同时还会生成一个 JS 文件,它暴露了一系列工具方法,使得 JS 能够访问 C 模块导出的方法,访问 C 模块的内存。emsdk 的安装方法参考 Download and install。 安装好之后我们开始移植我们的 C 程序:

  1. 先进入事先下载好的 FFmpeg 目录,运行以下命令配置编译程序:
emconfigure ./configure --prefix=/usr/local/ffmpeg-web --cc="emcc" --enable-cross-compile --target-os=none --arch=x86_64 --cpu=generic \
  --disable-ffplay --disable-ffprobe --disable-asm --disable-doc --disable-devices --disable-pthreads --disable-w32threads --disable-network \
  --disable-hwaccels --disable-parsers --disable-bsfs --disable-debug --disable-protocols --disable-indevs --disable-outdevs --enable-protocol=file
  1. 配置完成后再运行 emmake make && sudo make install
  2. 进入上面的 C 程序目录,运行命令进行编译:
emcc -o web_api.html web_api.c preview.c \
-s ASSERTIONS=1 -s VERBOSE=1 \
-s ALLOW_MEMORY_GROWTH=1 -s TOTAL_MEMORY=67108864 \
-s WASM=1 \
-s EXTRA_EXPORTED_RUNTIME_METHODS='["ccall", "cwrap"]' \
`pkg-config --libs --cflags libavutil libavformat libavcodec libswscale`

我们可以看到运行命令生成了 wasm 和 js 文件,这样我们就算完成了移植。


但是 “单纯的移植” 后的应用是不能直接运行的,因为在浏览器中程序不能直接操作用户的本地文件。所以需要稍微改造一下之前的 C 程序,以及增加 Web 端的代码,移植后的应用逻辑大概是这样的:

  1. 获取用户上传的视频数据;
  2. 将视频数据传给 C 模块;
  3. C 模块获取到视频后,生成视频缩略图;
  4. 将视频缩略图返回给 Web 程序;
  5. Web 端获取到视频缩略图,通过 Canvas 将其绘制出来。

接下来简单分析一下移植后的 Web 应用。因为这一节不是今天的主题,就不贴完整的代码了,感兴趣的同学可以前往GitHub - VVangChen/video-thumbnail-sprite查看完整源码及示例。


移植后的 Web 应用,与之前的 C 程序最大的不同在于视频数据获取的方式。之前的 C 程序可以直接加载本地文件,而现在需要在 Web 端将用户上传的视频缓存到内存中,再通过调用 C 模块暴露的方法,将内存地址传递给 C 模块。C 模块在获取到内存地址后,从内存之中读取视频文件的数据,然后进行之后的处理。除了视频数据获取的方式不同,C 模块也不再需要生成图片文件,而是将排列好的 RGB 数据通过内存返回给 Web 端。 主要看下 Web 端与 C 模块交互的部分,关键代码如下:
function generateSprite(data, cols = 5, interval = 10) {
  // 获取 c 模块暴露的 getSpriteImage 方法
  const getSpriteImage = Module.cwrap('getSpriteImage', 'number',
                  ['number', 'number', 'number', 'number']);
  const uint8Data = new Uint8Array(data.buffer)
  // 分配内存
  const offset = Module._malloc(uint8Data.length)
  // 将数据写入内存
  Module.HEAPU8.set(uint8Data, offset)
  // 调用 getSpriteImage,得到生成的拼图地址
  const ptr = getSpriteImage(offset, uint8Data.length, cols, interval)

  // 从内存中取出拼图的内存地址
  const spriteData = Module.HEAPU32[ptr / 4]
  ...
  ...
  ,,,
  // 获取拼图数据
  const spriteRawData = Module.HEAPU8.slice(spriteData, spriteData + size)

  // 释放内存
  Module._free(offset)
  Module._free(ptr)
  Module._free(spriteData)

  return ...
}

另外,如果 Web 端想要调用 C 模块的方法,需要在 C 代码中使用宏标记想要暴露给 Web 端的方法,如下所示:

EMSCRIPTEN_KEEPALIVE // 用来标记想要暴露给 Web 端的方法
SpriteImage *getSpriteImage(uint8_t *buffer, const int buff_size, int cols, int interval);

这样就可以在 JS 中直接调用 C 模块的 getSpriteImage 方法,等待 C 模块生成视频缩略拼图后返回给 Web 端,然后在 Canvas 画布中将其绘制并展示。可以前往 GitHub - VVangChen/video-thumbnail-sprite查看完整源码及示例。

最终的实现

在上一节中,完成了在浏览器中独立地实现完整的视频帧预览功能。那么离探索的目标只差一步,就是真正实时地生成视频预览图。 开头讲过,真正实时有两个条件,一是不预先准备好图片,而是在鼠标移到进度条上时再去生成;二是每个时间点的预览图都是不同的,就是说展示的图片一定是那一秒的视频画面。 其中第一个条件只是时间上的延迟,所以只要在鼠标移到进度条上时再触发生成拼图的动作就行;而第二个条件,只要缩短拼图中缩略图的采样频率到1秒1次就行。 现有的方案都是基于拼图来实现的,但是事实上,现在的需求并不需要预先生成所有画面的缩略图,只需要生成那一秒的就行。考虑到已经能够生成所有画面的缩略图,那么只生成一张肯定是可以实现的。 另外,既然现在只需要生成一张缩略图,而不是所有视频画面的拼图,那么是不是只需要获取这一张缩略图的数据就行?答案也是肯定的。所以如何获取某个时间点的视频缩略图数据,是这次探索成败的关键。 先来看下最终实现的程序,执行逻辑是怎样的:

  1. 获取鼠标指针所选时间点对应的视频画面的帧数据;
  2. 将帧数据传给 C 模块;
  3. C 模块使用 FFmpeg 解码帧数据并转成 RGB 数据;
  4. 将生成的 RGB 数据传回给 Web 端;
  5. 在 Canvas 画布上绘制 RGB 数据。

其中 2 - 5 与上一节实现的方法相同,就不再赘述,查看完整源码请前往 :github.com/VVangChen/v… 。 剩下的内容中,主要讲下如何实现第 1 步,获取鼠标指针所选时间点的帧数据。它可以被拆解为两个步骤:

  1. 因为视频画面的帧数据属于视频文件的一部分,它在视频文件中应该是一段连续的字节数据序列,所以在第一步,需要计算出帧数据在视频文件中的偏移量,以及帧数据的长度
  2. 第二步,需要发起一个请求,获取视频文件 [偏移量, 偏移量 + 帧数据长度] 范围内的数据

在这里,只考虑目前比较流行的 mp4 格式的视频文件。所以可以将第一步转换成:如何在 mp4 格式的视频文件中,计算出某个时间点对应的帧数据的偏移量及其大小? 这涉及到对 mp4 文件结构的解析。mp4 文件是由一个个连续的被称为 ‘box’ 的结构单元构成的,每一个 ‘box’ 由 header 和 data 组成,header 至少包含大小和类型,data 可以是 ‘box’ 自身的数据,也可以是一个或多个 ‘box’。不同的 ‘box’ 有不同的作用,对于计算帧数据的偏移量,主要需要用到以下几个 ‘box’:

  • moov:保存着视频编解码需要的数据
  • mdhd:保存着视频相关的元数据
  • stts:用于查询 sample 的时间表示
  • stss:用于查询文件所有关键帧的索引
  • stsc:用于查询 sample 所属块的索引和 sample 在块中的索引
  • stco:用于查询 sample 所在 chunk 的偏移量
  • stsz:用于查询 sample 的大小
  • mdat:存放着音视频码流数据

通过这些 ‘box’,按照一定的算法就可以得到帧数据的偏移量和大小:

  1. 首先需要获得 mp4 文件根结构,moov 的位置可能在文件的开头或者结尾,知道了它的位置之后就可以获得 moov 的数据;
  2. 解析 moov,获得上面提到的所有 box 数据并解析;
  3. 获取帧在码流中的时间表示;
  4. 通过时间计算帧在字节序列中的索引;
  5. 通过索引获得帧所属块在字节序列中的索引和它在块中的索引;
  6. 计算帧所属块在字节序列的偏移量;
  7. 通过它在块中的索引,计算它在块中的偏移量;
  8. 通过它在块中的偏移量和帧所属块在字节序列中的偏移量得到。

实现了如何计算帧数据在 mp4 文件中的偏移量,以及帧数据的长度。接下来进行第二步,获取视频文件 [偏移量, 偏移量 + 帧数据长度] 范围内的数据。它可以被转换成下面这个问题:如何获取 URL 资源的某部分数据? 它可以通过 HTTP 的范围请求来实现。如果资源服务器支持,只需要在 HTTP 请求中指定一个 Range 请求头,它的值是想要获取的资源数据的范围,看下示例:

function fetchRangeData(url, offset, size) {
  return new Promise((resolve) => {
    const xhr = new XMLHttpRequest()
    xhr.onload = (e) => {
      resolve(xhr.response)
    }
    xhr.open('GET', url)
    // 设置 Range 请求头
    xhr.setRequestHeader('Range', `bytes=${offset}-${offset + size - 1}`)
    xhr.responseType = 'arraybuffer'
    xhr.send()
  })
}

通过调用 fetchRangeData 函数,传入资源的 URL,想要请求的字节偏移量和字节大小,就可以获得你想要的字节序列。


至此,已经实现了获取某个时间点的视频帧数据,但这并不意味着一定能够生成用户想要的预览图。即使从获取到的部分帧数据大小也可以发现,它们非常小,有的才几十字节,显然不够描述一幅图片。如果把这些帧数据直接传给 FFmpeg,也无法成功被解码。这又是为什么呢? 这是因为在 H.264 编码中,帧主要分为三种类型: 1. I 帧:独立解码帧,又称关键帧(Intra frame),表示解码它不依赖其他帧 2. P 帧:前向预测帧,表示解码它需要参照帧序列中的上一帧 3. B 帧:双向预测帧,表示解码它需要参照帧序列中的上一帧和后一帧 显而易见,P 帧和 B 帧相对于 I 帧,会小很多。这也是为什么一些帧只需要几十个字节。 从上面帧类型的描述可以得知,在解码时帧与帧之间的依赖(参照)关系,如果不是 I 帧,就无法被独立解码。要解码非 I 帧,就需要获取到它参照的所有帧。在 H.264 编码的码流中,帧序列中的帧是以参照关系排列的,参照关系也决定了帧解码的顺序,因为被参照帧的解码顺序一定在参照帧的前面。 因为只有 I 帧能够独立解码,所以它在一组参照关系中一定是被排在最前面。如果想要解码非 I 帧,只需要获取到所选帧到它所在参照组中最前面的 I 帧之间的所有帧。一般将可以独立解码的参照组序列称为一组帧(GOP),它一般是两个 I 帧之间的一段帧序列。如示例图所示:

查看获取帧序列的代码请前往: github.com/VVangChen/v… 获取到鼠标指针所选时间点的帧数据后,将其传给 C 模块,生成 RGB 数据后返回给 Web 端,然后在 Canvas 画布上绘制并展示,用户就可以看到所选时间点的视频画面了。 至此,就实现了使用纯前端技术实现实时的视频帧预览。

总结

感谢能够耐心看完的读者。肯定有人会问了,做这件事的意义在哪里?我能回答是既然是探索,前方肯定也应该是未知的,路的尽头在走到之前谁也不清楚是什么,何况探索的脚步并未停止。 目前实现的程序还存在很多问题,比如:

  • 每次生成预览图,所有帧数据都需要重新获取;
  • 获取的帧数据只被利用于预览功能,浏览器播放视频时又会重新获取这些数据;
  • 编译生成的 wasm 文件体积过大;
  • 没有利用多线程以防止阻塞主进程;
  • 存在内存泄露;

接下来会着手解决这些问题,并继续探索如何将其应用于生产环境,使其更具实际使用的价值。所以探索的脚步并未停止,敬请期待,共勉。

如果文章有错误或待商榷的地方,欢迎指出或讨论,感谢!