WebAssemblyとWebWorkerで作るブラウザで動くWebPエンコーダー
※こちらでも同じ記事を書いています
WebP エンコーダーの必要性
Web 上で使われる画像形式として WebP の利用頻度が上がっています。その他のフォーマットに対して、データサイズ的に有利に働くからです。よく行われるのは、アップロードした画像をサーバ側で WebP に変換してクライアントに配信されるという流れです。しかし根本的に考えるとアップロードする前に WebP にしてしまえば、いろいろな無駄が省けます。ということでアップロード前にブラウザ上で WebP に変換すれば問題解決です。
ブラウザで WebP のエンコードをするには
ブラウザの標準機能だと WebP のデコードは可能ですが、エンコードする機能は Chrome だけにしか存在しません。つまり汎用的な対応を考えた場合、その機能は自分で何とかする必要があります。
こちらに webp を扱うためのライブラリがあり、C 言語から wasm で出力も出来るようになっているので利用します。この際に必要になるのがコンパイラです。
wasm を出力する C コンパイラ
emsdk をダウンロードしてインストールします
emcc コマンドが通るようになれば OK です
WebP エンコーダーの作り方
こちらにサンプルプログラムが載っています
これを元にプログラムを書いてみます
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.wasm
とwebp.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 化していきます。
こちらを使用します。これを使うと 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 パッケージ化したものを登録しました。
一連のソースコードはこちらです
実際に使ってみる
Next.js で画像を WebP に変換するプログラムを作ってみます。
- 画像のドラッグドロップ
- クリップボード内の画像貼り付け
- ファイル選択
以上の三種類のアップロード方法に対応させました。受け取った画像を WebP に変換して表示しています。
表示された画像をクリックすると WebP 形式でダウンロードすることが出来ます。
- サンプルソース
- Vercel での動作確認
- 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 はこういう用途以外だとなかなか使う機会がありません。滅多に使わないものだと、必要になったときに腰が重くなりがちです。しかし実際にやってみるとそう難しいものではないので、必要になったらサクッと使えるようになっておくと選択の幅が広がります。
Discussion