😎

WebAssemblyとWebWorkerで作るブラウザで動くWebPエンコーダー

2021/12/13に公開約8,300字

こちらでも同じ記事を書いています

WebP エンコーダーの必要性

Web 上で使われる画像形式として WebP の利用頻度が上がっています。その他のフォーマットに対して、データサイズ的に有利に働くからです。よく行われるのは、アップロードした画像をサーバ側で WebP に変換してクライアントに配信されるという流れです。しかし根本的に考えるとアップロードする前に WebP にしてしまえば、いろいろな無駄が省けます。ということでアップロード前にブラウザ上で WebP に変換すれば問題解決です。

ブラウザで WebP のエンコードをするには

ブラウザの標準機能だと WebP のデコードは可能ですが、エンコードする機能は Chrome だけにしか存在しません。つまり汎用的な対応を考えた場合、その機能は自分で何とかする必要があります。

https://github.com/webmproject/libwebp

こちらに webp を扱うためのライブラリがあり、C 言語から wasm で出力も出来るようになっているので利用します。この際に必要になるのがコンパイラです。

wasm を出力する C コンパイラ

emsdk をダウンロードしてインストールします

https://emscripten.org/docs/getting_started/downloads.html

emcc コマンドが通るようになれば OK です

WebP エンコーダーの作り方

こちらにサンプルプログラムが載っています

https://developer.mozilla.org/ja/docs/WebAssembly/existing_C_to_wasm

これを元にプログラムを書いてみます

WebP エンコーダーを C++で書く

プログラムの作成

  • src/webp.cpp
#include <emscripten/bind.h>
#include <emscripten/val.h>
#include "src/webp/encode.h"

using namespace emscripten;

val encode(std::string img_in, int width, int height, float quality) {
  uint8_t* img_out;
  size_t size = WebPEncodeRGBA((uint8_t*)img_in.c_str(), width, height, width * 4, quality, &img_out);
  val result = size ? val::global("Uint8Array").new_(typed_memory_view(size, img_out)) : val::null();
  WebPFree(img_out);
  return result;
}

EMSCRIPTEN_BINDINGS(my_module) {
  function("encode", &encode);
}

サンプルは C 言語で書かれていましたが、bind と val を使うために C++に直しています。こちらの方法を使うと、リソースの管理や関数コードの JavaScript への引き継ぎが簡単に行えます。

コンパイル

  • Makefile
SHELL=/bin/bash
webp: src/webp.cpp
	emcc -O3 --bind -msimd128 \
    -s WASM=1 -s ALLOW_MEMORY_GROWTH=1 -s ENVIRONMENT=web,worker -s EXPORT_ES6=1 -s DYNAMIC_EXECUTION=0 -s MODULARIZE=1 \
    -I libwebp src/webp.cpp -o dist/webp.js \
    libwebp/src/{dsp,enc,utils}/*.c

必要なオプションを設定して emcc でコンパイルをかけます。SIMD 対応にして libwebp のソースから必要な部分のみをチョイスしています。
コンパイルを行うと、webp.wasmwebp.jsが出力されます。

TypeScript の型を作成

  • src/webp.d.ts
export declare type ModuleType = {
  encode: (
    data: BufferSource,
    width: number,
    height: number,
    quality: number
  ) => Uint8Array | null;
};
declare const webp: () => Promise<ModuleType>;
export default webp;

TypeScript から呼び出せるように型を作ります。

完成

単純に WebP のエンコードを行うライブラリとしてならこれで完成です

import webp from "./webp";

webp().then(({ encode }) => {
  const result = encode(arrayBuffer, width, height); //画像データ,幅,高さ
});

のような形で呼び出すことが可能です。
ただこれだとメインスレッドでエンコードの処理が行われるので、その間は処理がブロックされ UI が止まります。

WebWorker の利用

重い処理を実行するときに役立つのが WebWorker です。別スレッドで処理できるので、その間にメインスレッドが止まることはありません。ということで WebWorker 化していきます。

https://www.npmjs.com/package/worker-lib

こちらを使用します。これを使うと Worker の処理を普通の非同期処理と同じように書くことが出来て便利です。

  • src/worker.ts
import { initWorker } from "worker-lib";
import webp, { ModuleType } from "./webp.js";

let webpModule: ModuleType;

const getModule = async () => {
  if (!webpModule) webpModule = await webp();
  return webpModule;
};
const encode = async (
  data: BufferSource,
  width: number,
  height: number,
  quality: number
): Promise<Uint8Array | null> => {
  return (await getModule()).encode(data, width, height, quality);
};

// Initialization process to make it usable in Worker.
const map = initWorker({ encode });
// Export only the type
export type WorkerWebp = typeof map;

別スレッドで処理する機能を作ります。

  • src/index.ts
import { createWorker } from "worker-lib";
import type { WorkerWebp } from "./worker.js";

const execute = createWorker<WorkerWebp>(
  () => new Worker(new URL("./worker", import.meta.url)),
  4 // Maximum parallel number
);

export const encode: {
  (
    data: BufferSource,
    width: number,
    height: number,
    quality?: number
  ): Promise<Uint8Array | null>;
  (data: ImageData, quality?: number): Promise<Uint8Array | null>;
} = async (
  data: BufferSource | ImageData,
  a?: number,
  b?: number,
  c?: number
) => {
  return data instanceof ImageData
    ? execute("encode", data.data, data.width, data.height, a || 100)
    : execute("encode", data, a as number, b as number, c || 100);
};

export default true;

先ほど作った機能を呼び出す部分になります。createWorkerで WebWorker の実行エンジンが作成され、指定した最大数だけ並列で処理を実行できます。今回は並列数 4 にしてあります。encodeは引数の内容に応じてパラメータを振り分けています。

完成、webp エンコーダ

こちらに npm パッケージ化したものを登録しました。

https://www.npmjs.com/package/@node-libraries/wasm-webp-encoder

一連のソースコードはこちらです

https://github.com/node-libraries/wasm-webp-encoder

実際に使ってみる

Next.js で画像を WebP に変換するプログラムを作ってみます。

  • 画像のドラッグドロップ
  • クリップボード内の画像貼り付け
  • ファイル選択

以上の三種類のアップロード方法に対応させました。受け取った画像を WebP に変換して表示しています。

表示された画像をクリックすると WebP 形式でダウンロードすることが出来ます。

  • サンプルソース

https://github.com/SoraKumo001/next-webp
  • Vercel での動作確認

https://next-webp.vercel.app/
  • src/pages/index.tsx
import React, { FC, useEffect, useRef, useState } from "react";
import { encode } from "@node-libraries/wasm-webp-encoder";
import styled from "./index.module.scss";

export const classNames = (...classNames: (string | undefined | false)[]) =>
  classNames.reduce(
    (a, b, index) => a + (b ? (index ? " " : "") + b : ""),
    ""
  ) as string | undefined;

export const convertWebp = async (blob: Blob) => {
  if (!blob.type.match(/^image\/(png|jpeg)/)) return blob;
  const src = await blob
    .arrayBuffer()
    .then(
      (v) => `data:${blob.type};base64,` + Buffer.from(v).toString("base64")
    );
  const img = document.createElement("img");
  img.src = src;
  await new Promise((resolve) => (img.onload = resolve));
  const canvas = document.createElement("canvas");
  [canvas.width, canvas.height] = [img.width, img.height];
  const ctx = canvas.getContext("2d")!;
  ctx.drawImage(img, 0, 0);
  const value = await encode(ctx.getImageData(0, 0, img.width, img.height));
  if (!value) return null;
  return new Blob([value], { type: "image/webp" });
};

const Page = () => {
  const ref = useRef<HTMLInputElement>(null);
  const [isDrag, setDrag] = useState(false);
  const [imageData, setImageData] = useState<string | undefined>();
  const convertUrl = async (blob: Blob | undefined | null) => {
    if (!blob) return undefined;
    return (
      `data:image/webp;base64,` +
      Buffer.from(await blob.arrayBuffer()).toString("base64")
    );
  };
  useEffect(() => {
    const handle = () => {
      navigator.clipboard.read().then((items) => {
        for (const item of items) {
          item.getType("image/png").then(async (value) => {
            const v = await convertWebp(value);
            convertUrl(v).then(setImageData);
          });
        }
      });
    };
    addEventListener("paste", handle);
    return () => removeEventListener("paste", handle);
  }, []);
  return (
    <div
      className={classNames(styled.root, isDrag && styled.dragover)}
      onDragOver={(e) => {
        e.preventDefault();
        e.stopPropagation();
      }}
      onClick={(e) => {
        ref.current?.click();
        e.stopPropagation();
      }}
      onDragEnter={() => setDrag(true)}
      onDragLeave={() => setDrag(false)}
      onDrop={(e) => {
        for (const item of e.dataTransfer.files) {
          convertWebp(item).then((blob) => {
            convertUrl(blob).then(setImageData);
          });
        }
        e.preventDefault();
      }}
    >
      {imageData ? (
        <>
          <span
            className={styled.clear}
            onClick={() => {
              setImageData(undefined);
            }}
          ></span>
          <img
            src={imageData}
            onClick={() => {
              const node = document.createElement("a");
              node.download = "download.webp";
              node.href = imageData;
              node.click();
            }}
          />
        </>
      ) : (
        <>
          <input
            ref={ref}
            type="file"
            accept=".jpg, .png, .gif"
            onChange={(e) => {
              const blob = e.currentTarget.files?.[0];
              if (blob) {
                convertUrl(blob).then(setImageData);
              }
            }}
          />
        </>
      )}
    </div>
  );
};
export default Page;

受け取った画像を Canvas で展開してから、WebP エンコーダーで変換します。convertWebp は無駄に Blob に変換しているように見えますが、他の用途を考えてこうなっています。

まとめ

WebAssembly と WebWorker はこういう用途以外だとなかなか使う機会がありません。滅多に使わないものだと、必要になったときに腰が重くなりがちです。しかし実際にやってみるとそう難しいものではないので、必要になったらサクッと使えるようになっておくと選択の幅が広がります。

GitHubで編集を提案

Discussion

ログインするとコメントできます