WebAssemblyとWebWorkerで作る、ブラウザで動くWebPエンコーダー
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
1#include <emscripten/bind.h>2#include <emscripten/val.h>3#include "src/webp/encode.h"45using namespace emscripten;67val encode(std::string img_in, int width, int height, float quality) {8 uint8_t* img_out;9 size_t size = WebPEncodeRGBA((uint8_t*)img_in.c_str(), width, height, width * 4, quality, &img_out);10 val result = size ? val::global("Uint8Array").new_(typed_memory_view(size, img_out)) : val::null();11 WebPFree(img_out);12 return result;13}1415EMSCRIPTEN_BINDINGS(my_module) {16 function("encode", &encode);17}
サンプルは C 言語で書かれていましたが、bind と val を使うために C++に直しています。こちらの方法を使うと、リソースの管理や関数コードの JavaScript への引き継ぎが簡単に行えます。
コンパイル
Makefile
1SHELL=/bin/bash2webp: src/webp.cpp3 emcc -O3 --bind -msimd128 \4 -s WASM=1 -s ALLOW_MEMORY_GROWTH=1 -s ENVIRONMENT=web,worker -s EXPORT_ES6=1 -s DYNAMIC_EXECUTION=0 -s MODULARIZE=1 \5 -I libwebp src/webp.cpp -o dist/webp.js \6 libwebp/src/{dsp,enc,utils}/*.c
必要なオプションを設定して emcc でコンパイルをかけます。SIMD 対応にして libwebp のソースから必要な部分のみをチョイスしています。
コンパイルを行うと、webp.wasmとwebp.jsが出力されます。
TypeScript の型を作成
src/webp.d.ts
1export declare type ModuleType = {2 encode: (3 data: BufferSource,4 width: number,5 height: number,6 quality: number7 ) => Uint8Array | null;8};9declare const webp: () => Promise<ModuleType>;10export default webp;
TypeScript から呼び出せるように型を作ります。
完成
単純に WebP のエンコードを行うライブラリとしてならこれで完成です
1import webp from "./webp";23webp().then(({ encode }) => {4 const result = encode(arrayBuffer, width, height); //画像データ,幅,高さ5});
のような形で呼び出すことが可能です。
ただこれだとメインスレッドでエンコードの処理が行われるので、その間は処理がブロックされ UI が止まります。
WebWorker の利用
重い処理を実行するときに役立つのが WebWorker です。別スレッドで処理できるので、その間にメインスレッドが止まることはありません。ということで WebWorker 化していきます。
https://www.npmjs.com/package/worker-lib
こちらを使用します。これを使うと Worker の処理を普通の非同期処理と同じように書くことが出来て便利です。
src/worker.ts
1import { initWorker } from "worker-lib";2import webp, { ModuleType } from "./webp.js";34let webpModule: ModuleType;56const getModule = async () => {7 if (!webpModule) webpModule = await webp();8 return webpModule;9};10const encode = async (11 data: BufferSource,12 width: number,13 height: number,14 quality: number15): Promise<Uint8Array | null> => {16 return (await getModule()).encode(data, width, height, quality);17};1819// Initialization process to make it usable in Worker.20const map = initWorker({ encode });21// Export only the type22export type WorkerWebp = typeof map;
別スレッドで処理する機能を作ります。
src/index.ts
1import { createWorker } from "worker-lib";2import type { WorkerWebp } from "./worker.js";34const execute = createWorker<WorkerWebp>(5 () => new Worker(new URL("./worker", import.meta.url)),6 4 // Maximum parallel number7);89export const encode: {10 (11 data: BufferSource,12 width: number,13 height: number,14 quality?: number15 ): Promise<Uint8Array | null>;16 (data: ImageData, quality?: number): Promise<Uint8Array | null>;17} = async (18 data: BufferSource | ImageData,19 a?: number,20 b?: number,21 c?: number22) => {23 return data instanceof ImageData24 ? execute("encode", data.data, data.width, data.height, a || 100)25 : execute("encode", data, a as number, b as number, c || 100);26};2728export 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 での動作確認
src/pages/index.tsx
1import React, { FC, useEffect, useRef, useState } from "react";2import { encode } from "@node-libraries/wasm-webp-encoder";3import styled from "./index.module.scss";45export const classNames = (...classNames: (string | undefined | false)[]) =>6 classNames.reduce(7 (a, b, index) => a + (b ? (index ? " " : "") + b : ""),8 ""9 ) as string | undefined;1011export const convertWebp = async (blob: Blob) => {12 if (!blob.type.match(/^image\/(png|jpeg)/)) return blob;13 const src = await blob14 .arrayBuffer()15 .then(16 (v) => `data:${blob.type};base64,` + Buffer.from(v).toString("base64")17 );18 const img = document.createElement("img");19 img.src = src;20 await new Promise((resolve) => (img.onload = resolve));21 const canvas = document.createElement("canvas");22 [canvas.width, canvas.height] = [img.width, img.height];23 const ctx = canvas.getContext("2d")!;24 ctx.drawImage(img, 0, 0);25 const value = await encode(ctx.getImageData(0, 0, img.width, img.height));26 if (!value) return null;27 return new Blob([value], { type: "image/webp" });28};2930const Page = () => {31 const ref = useRef<HTMLInputElement>(null);32 const [isDrag, setDrag] = useState(false);33 const [imageData, setImageData] = useState<string | undefined>();34 const convertUrl = async (blob: Blob | undefined | null) => {35 if (!blob) return undefined;36 return (37 `data:image/webp;base64,` +38 Buffer.from(await blob.arrayBuffer()).toString("base64")39 );40 };41 useEffect(() => {42 const handle = () => {43 navigator.clipboard.read().then((items) => {44 for (const item of items) {45 item.getType("image/png").then(async (value) => {46 const v = await convertWebp(value);47 convertUrl(v).then(setImageData);48 });49 }50 });51 };52 addEventListener("paste", handle);53 return () => removeEventListener("paste", handle);54 }, []);55 return (56 <div57 className={classNames(styled.root, isDrag && styled.dragover)}58 onDragOver={(e) => {59 e.preventDefault();60 e.stopPropagation();61 }}62 onClick={(e) => {63 ref.current?.click();64 e.stopPropagation();65 }}66 onDragEnter={() => setDrag(true)}67 onDragLeave={() => setDrag(false)}68 onDrop={(e) => {69 for (const item of e.dataTransfer.files) {70 convertWebp(item).then((blob) => {71 convertUrl(blob).then(setImageData);72 });73 }74 e.preventDefault();75 }}76 >77 {imageData ? (78 <>79 <span80 className={styled.clear}81 onClick={() => {82 setImageData(undefined);83 }}84 >85 ✖86 </span>87 <img88 src={imageData}89 onClick={() => {90 const node = document.createElement("a");91 node.download = "download.webp";92 node.href = imageData;93 node.click();94 }}95 />96 </>97 ) : (98 <>99 <input100 ref={ref}101 type="file"102 accept=".jpg, .png, .gif"103 onChange={(e) => {104 const blob = e.currentTarget.files?.[0];105 if (blob) {106 convertUrl(blob).then(setImageData);107 }108 }}109 />110 </>111 )}112 </div>113 );114};115export default Page;
受け取った画像を Canvas で展開してから、WebP エンコーダーで変換します。convertWebp は無駄に Blob に変換しているように見えますが、他の用途を考えてこうなっています。
まとめ
WebAssembly と WebWorker はこういう用途以外だとなかなか使う機会がありません。滅多に使わないものだと、必要になったときに腰が重くなりがちです。しかし実際にやってみるとそう難しいものではないので、必要になったらサクッと使えるようになっておくと選択の幅が広がります。