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

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

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

文章的大纲html

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

探索意味着前方都是未知的事物,但愿这篇文章可以带着读者,一块儿回顾我探索的过程,同时学习遇到的种种未知的事物。如今开始吧。前端

什么是视频帧预览?

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

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

常见的实现方式

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

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


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

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


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

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

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

进阶的实现方式

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


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


简单来讲,“单纯的移植”只需如下两步:bash

  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 文件体积过大;
  • 没有利用多线程以防止阻塞主进程;
  • 存在内存泄露;

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

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

相关文章
相关标签/搜索