<audio id="audio" title="16 | 如何实现一个 WebAssembly 在线多媒体处理应用（二）？" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/37/da/3727d6d3ecdc3015e78084fbddec75da.mp3"></audio>

你好，我是于航。

在上一节课中，我们介绍了本次实践项目在代码层面的大体组成结构，着重给你讲解了需要了解的一些基础性知识，比如“滤镜的基本原理及实现方法”以及“Emscripten 的基本用法”等等。而在这节课中，我们将继续构建这个基于 Wasm 实现的多媒体 Web 应用。

## HTML

首先，我们来构建这个 Web 应用所对应的 HTML 部分。这部分代码如下所示：

```
&lt;!DOCTYPE html&gt;
  &lt;html lang=&quot;en&quot;&gt;
  &lt;head&gt;
    &lt;meta charset=&quot;UTF-8&quot;&gt;
    &lt;title&gt;DIP-DEMO&lt;/title&gt;
    &lt;style&gt;
      * { font-family: &quot;Arial,sans-serif&quot;; }
      .fps-num { font-size: 50px; }
      .video { display: none; }
      .operation { margin: 20px; }
      button {
        width: 150px;
        height: 30px;
        margin-top: 10px;
        border: solid 1px #999;
        font-size: 13px;
        font-weight: bold;
      }
      .radio-text { font-size: 13px; }
    &lt;/style&gt;
  &lt;/head&gt;
  &lt;body&gt;
    &lt;canvas class=&quot;canvas&quot;&gt;&lt;/canvas&gt;
    &lt;div class=&quot;operation&quot;&gt;
      &lt;h2&gt;帧率：&lt;span class=&quot;fps-num&quot;&gt;NaN&lt;/span&gt; FPS&lt;/h2&gt;
      &lt;input name=&quot;options&quot; value=&quot;0&quot; type=&quot;radio&quot; checked=&quot;checked&quot;/&gt; 
      &lt;span class=&quot;radio-text&quot;&gt;不开启渲染.&lt;/span&gt; &lt;br/&gt;
      &lt;input name=&quot;options&quot; value=&quot;1&quot; type=&quot;radio&quot;/&gt; 
      &lt;span class=&quot;radio-text&quot;&gt;使用 &lt;b&gt;[JavaScript]&lt;/b&gt; 渲染.&lt;/span&gt; 
      &lt;br/&gt;
      &lt;input name=&quot;options&quot; value=&quot;2&quot; type=&quot;radio&quot;/&gt; 
      &lt;span class=&quot;radio-text&quot;&gt;使用 &lt;b&gt;[WebAssembly]&lt;/b&gt; 渲染.&lt;/span&gt; 
      &lt;br/&gt;
      &lt;button&gt;确认&lt;/button&gt;
    &lt;/div&gt;
    &lt;video class=&quot;video&quot; type=&quot;video/mp4&quot;
      muted=&quot;muted&quot; 
      loop=&quot;true&quot; 
      autoplay=&quot;true&quot; 
      src=&quot;media/video.mp4&quot;&gt;
  &lt;/body&gt;
  &lt;script src='./dip.js'&gt;&lt;/script&gt;
&lt;/html&gt;

```

为了便于演示，HTML 代码部分我们尽量从简，并且直接将 CSS 样式内联到 HTML 头部。

其中最为重要的两个部分为 `“&lt;canvas&gt;`” 标签和 “`&lt;video&gt;`” 标签。`&lt;canvas&gt;` 将用于展示对应 `&lt;video&gt;` 标签所加载外部视频资源的画面数据；而这些帧数据在被渲染到`&lt;canvas&gt;`之前，将会根据用户的设置，有选择性地被 JavaScript 代码或者 Wasm 模块进行处理。

还有一点需要注意的是，可以看到我们为`&lt;video&gt;` 标签添加了名为 “muted”、“loop” 以及 “autoplay” 的三个属性。这三个属性分别把这个视频资源设置为“静音播放”、“循环播放”以及“自动播放”。

实际上，根据 Chrome 官方给出的 “Autoplay Policy” 政策，我们并不能够直接依赖其中的 “autoplay” 属性，来让视频在用户打开网页时立即自动播放。稍后你会看到，在应用实际加载时，我们仍会通过调用 `&lt;video&gt;` 标签所对应的 play() 方法，来确保视频资源可以在网页加载完毕后，直接自动播放。

最后，在 HTML 代码的末尾处，我使用 `&lt;script&gt;` 标签加载了同目录下名为 “dip.js” 的 JavaScript 文件。在这个文件中，我们将完成该 Web 应用的所有控制逻辑，包括：视频流的控制与显示逻辑、用户与网页的交互逻辑、JavaScript 版滤镜的实现、Wasm 版滤镜实现对应的模块加载、初始化与调用逻辑，以及实时帧率的计算逻辑等。

## JavaScript

趁热打铁，我们接着来编写整个 Web 应用组成中，最为重要的 JavaScript 代码部分。

### 视频流的控制与显示逻辑

第一步，我们要实现的是将 `&lt;video&gt;` 标签所加载的视频资源，实时渲染到 `&lt;canvas&gt;` 标签所代表的画布对象上。这一步的具体实现方式，你可以参考下面这张示意图。

<img src="https://static001.geekbang.org/resource/image/1c/98/1c22fcd901f33a622b1fdc117a7db798.png" alt="">

其中的核心逻辑是，我们需要通过名为 “CanvasRenderingContext2D.drawImage()” 的 Web API ，来将 `&lt;video&gt;` 标签所承载视频的当前帧内容，绘制到 `&lt;canvas&gt;` 上。这里我们使用到的 drawImage() 方法，支持设置多种类型的图像源，`&lt;video&gt;` 标签所对应的 “HTMLVideoElement” 便是其中的一种。

CanvasRenderingContext2D 接口是 Web API 中， Canvas API 的一部分。通过这个接口，我们能够获得一个，可以在对应 Canvas 上进行 2D 绘图的“渲染上下文”。稍后在代码中你会看到，我们将通过 `&lt;canvas&gt;` 对象上名为 “getContext” 的方法，来获得这个上下文对象。

我们之前曾提到，drawImage() 方法只能够绘制 `&lt;video&gt;` 标签对应视频流的“当前帧”内容，因此随着视频的播放，“当前帧”内容也会随之发生改变。

为了能够让绘制到 `&lt;canvas&gt;` 上的画面可以随着视频的播放来实时更新，这里我们将使用名为 “window.requestAnimationFrame” 的 Web API， 来实时更新绘制在 `&lt;canvas&gt;` 上的画面内容（如果你对这个  API 不太熟悉，可以点击[这里](https://time.geekbang.org/column/article/288704)回到“基础课”进行复习）。

下面我们给出这部分功能对应的代码实现：

```
// 获取相关的 HTML 元素；
let video = document.querySelector('.video');
let canvas = document.querySelector('.canvas');

// 使用 getContext 方法获取 &lt;canvas&gt; 标签对应的一个 CanvasRenderingContext2D 接口；
let context = canvas.getContext('2d');
 
// 自动播放 &lt;video&gt; 载入的视频；
let promise = video.play();
if (promise !== undefined) {
  promise.catch(error =&gt; {
    console.error(&quot;The video can not autoplay!&quot;)
  });
}
// 定义绘制函数；
function draw() {
  // 调用 drawImage 函数绘制图像到 &lt;canvas&gt;；
  context.drawImage(video, 0, 0);
  // 获得 &lt;canvas&gt; 上当前帧对应画面的像素数组；
  pixels = context.getImageData(0, 0, video.videoWidth, video.videoHeight);
  // ...    
  // 更新下一帧画面；
  requestAnimationFrame(draw);
}
// &lt;video&gt; 视频资源加载完毕后执行；
video.addEventListener(&quot;loadeddata&quot;, () =&gt; {
  // 根据 &lt;video&gt; 载入视频大小调整对应的 &lt;canvas&gt; 尺寸；
  canvas.setAttribute('height', video.videoHeight);
  canvas.setAttribute('width', video.videoWidth);
  // 绘制函数入口；
  draw(context);
});

```

关于代码中每一行的具体功能，你可以参考附加到相应代码行前的注释加以理解。首先，我们需要获得相应的 HTML 元素，这里主要是 `&lt;canvas&gt;` 和 `&lt;video&gt;` 这两个标签对应的元素对象，然后我们获取了 `&lt;canvas&gt;` 标签对应的 2D 绘图上下文。

紧接着，我们处理了 `&lt;video&gt;` 标签所加载视频自动播放的问题，这里我们直接调用了 `&lt;video&gt;` 元素的 play 方法。该方法会返回一个 Promise，针对 reject 的情况，我们做出了相应的处理。

然后，我们在 `&lt;video&gt;` 元素的加载回调完成事件 “loadeddata” 中，根据所加载视频的尺寸相应地调整了 `&lt;canvas&gt;` 元素的大小，以确保它可以完整地显示出视频的画面内容。同时在这里，我们调用了自定义的 draw 方法，来把视频的首帧内容更新到 `&lt;canvas&gt;` 画布上。

在 draw 方法中，我们调用了 drawImage 方法来更新 `&lt;canvas&gt;` 画布的显示内容。该方法在这里接受三个参数，第一个为图像源，也就是 `&lt;video&gt;` 元素对应的 HTMLVideoElement 对象；第二个为待绘制图像的起点在 `&lt;canvas&gt;` 上 X 轴的偏移；第三个参数与第二个类似，相应地为在 Y 轴上的偏移。这里对于最后两个参数，我们均设置为 0。

然后，我们使用了名为 “CanvasRenderingContext2D.getImageData()” 的方法（下文简称 “getImageData”）来获得 `&lt;canvas&gt;` 上当前帧对应画面的像素数组。

getImageData 方法接受四个参数。前两个参数指定想要获取像素的帧画面，在当前帧画面 x 轴和 y 轴上的偏移范围。最后两个参数指定这个范围的长和宽。

四个参数共同指定了画面上的一个矩形位置，在对应该矩形的范围内，所有像素序列将会被返回。我们会在后面来使用和处理这些返回的像素数据。

最后，我们通过 requestAnimationFrame 方法，以 60Hz 的频率来更新 `&lt;canvas&gt;` 上的画面。

在上述这部分代码实现后，我们的 Web 应用便可在用户打开网页时，直接将 `&lt;video&gt;` 加载播放的视频，实时地绘制在 `&lt;canvas&gt;` 对应的画布中。

### 用户与网页的交互逻辑

接下来，我们继续实现 JavaScript 代码中，与“处理用户交互逻辑”这部分功能有关的代码。

这部分代码比较简单，主要流程就是监听用户做出的更改，然后将这些更改后的值保存起来。这里为了实现简单，我们直接以“全局变量”的方式来保存这些设置项的值。这部分代码如下所示：

```
// 全局状态；
const STATUS = ['STOP', 'JS', 'WASM'];
// 当前状态；
let globalStatus = 'STOP';
// 监听用户点击事件；
document.querySelector(&quot;button&quot;).addEventListener('click', () =&gt; {
  globalStatus = STATUS[
    Number(
      document.querySelector(&quot;input[name='options']:checked&quot;).value
    )
  ];
});

```

这里我们需要维护应用的三种不同状态，即：不使用滤镜（STOP）、使用 JavaScript 实现滤镜（JS）、使用 Wasm 实现滤镜（WASM）。全局变量 globalStatus 维护了当前应用的状态，在后续的代码中，我们也将使用这个变量的值，来调用不同的滤镜实现，或者选择关闭滤镜。

### 实时帧率的计算逻辑

作为开始真正构建 JavaScript 版滤镜函数前的最后一步，我们先来实现帧率的实时计算逻辑，然后观察在不开启任何滤镜效果时的 `&lt;canvas&gt;` 渲染帧率情况。

帧率的一个粗糙计算公式如下图所示。对于帧率，我们可以将其简单理解为在 1s 时间内屏幕上画面能够刷新的次数。比如若 1s 时间内画面能够更新 60 次，那我们就可以说它的帧率为 60 赫兹（Hz）。

<img src="https://static001.geekbang.org/resource/image/c1/f7/c159210666f44bb8df1cdfdb8fccc4f7.png" alt="">

因此，一个简单的帧率计算逻辑便可以这样来实现：首先，把每一次从对画面像素开始进行处理，直到真正绘制到 `&lt;canvas&gt;`这整个流程所耗费的时间，以毫秒为单位进行计算；然后用 1000 除以这个数值，即可得到一个估计的，在 1s 时间所内能够渲染的画面次数，也就是帧率。

这部分逻辑的 JavaScript 实现代码如下所示：

```
function calcFPS (vector) {
  // 提取容器中的前 20 个元素来计算平均值；
  const AVERAGE_RECORDS_COUNT = 20;  
  if (vector.length &gt; AVERAGE_RECORDS_COUNT) {
    vector.shift(-1);  // 维护容器大小；
  } else {
    return 'NaN';
  }
  // 计算平均每帧在绘制过程中所消耗的时间；
  let averageTime = (vector.reduce((pre, item) =&gt; { 
    return pre + item;
  }, 0) / Math.abs(AVERAGE_RECORDS_COUNT));
  // 估算出 1s 内能够绘制的帧数；
  return (1000 / averageTime).toFixed(2);
}

```

这里，为了能够让帧率的估算更加准确，我们为 JavaScript 和 Wasm 这两个版本的滤镜实现，分别单独准备了用来保存每帧计算时延的全局数组。这些数组会保存着在最近 20 帧里，每一帧计算渲染时所花费的时间。

然后，在上面代码中的函数 calcFPS 内，我们会通过对这 20 个帧时延记录取平均值，来求得一个更加稳定、相对准确的平均帧时延。最后，使用 1000 来除以这个平均帧时延，你就能够得到一个估算出的，在 1s 时间内能够绘制的帧数，也就是帧率。

上面代码中的语句 vector.shift(-1) 其主要作用是，当保存最近帧时延的全局数组内元素个数超过 20 个时，会移除其中最老的一个元素。这样，我们可以保证整个数组的大小维持在 20 及以内，不会随着应用的运行而产生 OOM（Out-of-memory）的问题。

我们将前面讲解的这些代码稍微整合一下，并添加上对应需要使用到的一些全局变量。然后尝试在浏览器中运行这个 Web 应用。在不开启任何滤镜的情况下，你可得到如下的画面实时渲染帧率（这里我们使用 Chrome 进行测试，不同的浏览器和版本结果会有所差异）。

<img src="https://static001.geekbang.org/resource/image/a7/b7/a70ce11d428523cb0126923765ca73b7.gif" alt="">

### JavaScript 滤镜方法的实现

接下来，我们将编写整个 Web 应用的核心组成之一 —— JavaScript 滤镜函数。关于这个函数的具体实现步骤，你可以参考在上一节课中介绍的“滤镜基本原理”。

首先，根据规则，我们需要准备一个 3x3 大小的二维数组，来容纳“卷积核”矩阵。然后将该矩阵进行 180 度的翻转。最后得到的结果矩阵，将会在后续直接参与到各个像素点的滤镜计算过程。这部分功能对应的 JavaScript 代码实现如下所示：

```
// 矩阵翻转函数；
function flipKernel(kernel) {
  const h = kernel.length;
  const half = Math.floor(h / 2);
  // 按中心对称的方式将矩阵中的数字上下、左右进行互换；
  for (let i = 0; i &lt; half; ++i) {
    for (let j = 0; j &lt; h; ++j) {
      let _t = kernel[i][j];
      kernel[i][j] = kernel[h - i - 1][h - j - 1];
      kernel[h - i - 1][h - j - 1] = _t;
    }
  }
  // 处理矩阵行数为奇数的情况；
  if (h &amp; 1) {
    // 将中间行左右两侧对称位置的数进行互换；
    for (let j = 0; j &lt; half; ++j) {
      let _t = kernel[half][j];
      kernel[half][j] = kernel[half][h - j - 1];
      kernel[half][h - j - 1] = _t;
    } 
  }
  return kernel;
}
// 得到经过翻转 180 度后的卷积核矩阵；
const kernel = flipKernel([
  [-1, -1, 1], 
  [-1, 14, -1], 
  [1, -1, -1]
]);

```

关于“如何将矩阵数组进行 180 度翻转”的实现细节，你可以参考代码中给出的注释来加以理解。

在一切准备就绪后，我们来编写核心的 JavaScript 滤镜处理函数 jsConvFilter。该处理函数一共接受四个参数。第一个参数是通过 getImageData 方法，从 `&lt;canvas&gt;` 对象上获取的当前帧画面的像素数组数据。

getImageData 在执行完毕后会返回一个 ImageData 类型的对象，在该对象中有一个名为 data 的属性。data 属性实际上是一个 Uint8ClampedArray 类型的 “Typed Array”，其中便存放着所有像素点按顺序排放的 RGBA 分量值。你可以借助下面这张图来帮助理解上面我们描述的，各个方法与返回值之间的对应关系。

<img src="https://static001.geekbang.org/resource/image/24/b2/246c9fbfc1668c146d2e409yyf768eb2.png" alt="">

jsConvFilter 处理函数的第二和第三个参数为视频帧画面的宽和高；最后一个参数为所应用滤镜对应的“卷积核”矩阵数组。至此，我们可以构造如下的 JavaScript 版本“滤镜函数”：

```
function jsConvFilter(data, width, height, kernel) {
  const divisor = 4;  // 分量调节参数；
  const h = kernel.length, w = h;  // 保存卷积核数组的宽和高；
  const half = Math.floor(h / 2);
  // 根据卷积核的大小来忽略对边缘像素的处理；
  for (let y = half; y &lt; height - half; ++y) {
    for (let x = half; x &lt; width - half; ++x) {
      // 每个像素点在像素分量数组中的起始位置；
      const px = (y * width + x) * 4;  
      let r = 0, g = 0, b = 0;
      // 与卷积核矩阵数组进行运算；
      for (let cy = 0; cy &lt; h; ++cy) {
        for (let cx = 0; cx &lt; w; ++cx) {
          // 获取卷积核矩阵所覆盖位置的每一个像素的起始偏移位置；
          const cpx = ((y + (cy - half)) * width + (x + (cx - half))) * 4;
          // 对卷积核中心像素点的 RGB 各分量进行卷积计算(累加)；
          r += data[cpx + 0] * kernel[cy][cx];
          g += data[cpx + 1] * kernel[cy][cx];
          b += data[cpx + 2] * kernel[cy][cx];
        }
      }
      // 处理 RGB 三个分量的卷积结果；
      data[px + 0] = ((r / divisor) &gt; 255) ? 255 : ((r / divisor) &lt; 0) ? 0 : r / divisor;
      data[px + 1] = ((g / divisor) &gt; 255) ? 255 : ((g / divisor) &lt; 0) ? 0 : g / divisor;
      data[px + 2] = ((b / divisor) &gt; 255) ? 255 : ((b / divisor) &lt; 0) ? 0 : b / divisor;
    }
  }
  return data;
}

```

你可以借助代码中的注释来了解整个卷积过程的实现细节。其中有这样几个点需要注意：

在整个方法的实现过程中，我们使用了名为 divisor 的变量，来控制滤镜对视频帧画面产生的效果强度。divisor  的值越大，滤镜的效果就越弱。

在遍历整个帧画面的像素序列时（最外层的两个循环体），我们将循环控制变量 y 和 x 的初始值，设置为 Math.floor(h / 2)，这样可以直接忽略对帧画面边缘像素的处理，进而也不用考虑图像卷积产生的“边缘效应”。

所谓“边缘效应”，其实就是指当我们在处理帧画面的边缘像素时，由于卷积核其范围内的一部分“单元格”无法找到与之相对应的像素点，导致边缘像素实际上没有经过“完整”的滤镜计算过程，会产生与预期不符的滤镜处理效果。而这里为了简化流程，我们选择了直接忽略对边缘像素的处理过程。

最后，在得到经过卷积累加计算的 RGB 分量值后，我们需要判断对应值是否在 [0, 255] 这个有效区间内。若没有，我们就将这个值，直接置为对应的最大有效值或最小有效值。

现在，我们将前面的所有代码功能加以整合，然后试着在浏览器中再次运行这个 Web 应用。你会看到类似下图的结果。相较于不开启滤镜，使用滤镜后的画面渲染帧率明显下降了。

<img src="https://static001.geekbang.org/resource/image/68/7c/68a6f2f38461bc9114cb480053644b7c.gif" alt="">

## 总结

好了，讲到这，今天的内容也就基本结束了。最后我来给你总结一下。

今天我们主要讲解了本次实践项目中与 JavaScript 代码相关的几个重要功能的实现思路，以及实现细节。

JavaScript 代码作为当前用来构建 Web 应用所必不可少的一个重要组成部分，它负责构建整个应用与用户进行交互的逻辑处理部分。不仅如此，我们还使用 JavaScript 代码实现了一个滤镜处理函数，并用该函数处理了 `&lt;canvas&gt;` 上的帧画面像素数据，然后再将这些数据重新绘制到 `&lt;canvas&gt;` 上。

在下一节课里，你将会看到我们实现的 Wasm 滤镜处理函数，与 JavaScript 版滤镜函数在图像处理效率上的差异。

## **课后练习**

最后，我们来做一个练习题吧。

你可以试着更改我们在 JavaScript 滤镜函数中所使用的卷积核矩阵（更改矩阵中元素的值，或者改变矩阵的大小），来看看不同的卷积核矩阵会产生怎样不同的滤镜效果。

今天的课程就结束了，希望可以帮助到你，也希望你在下方的留言区和我参与讨论，同时欢迎你把这节课分享给你的朋友或者同事，一起交流一下。
