ブラウザだけで動画や画像を変換するツールを作っていると、最初にぶつかるのが「変換中に画面が固まる」問題です。ボタンが反応しない、プログレスバーが動かない、Chromeから「ページが応答しません」と言われる。原因はほぼ例外なく、重い処理をメインスレッドで回していることです。
ここでは、実際に動画圧縮ツールをWorker化したときに気をつけた点をまとめます。
1. 重い処理は丸ごとWorkerへ
wasm(ffmpeg.wasmなど)の初期化も含めて、全部Worker側に置きます。初期化だけメインで行うと、数十MBのwasmのコンパイルで一瞬止まります。
// main.js
const worker = new Worker(new URL('./convert.worker.js', import.meta.url), { type: 'module' });
2. Transferableで「コピー」ではなく「移動」する
postMessage はデフォルトで構造化複製(structured clone)なので、500MBのArrayBufferを渡すと500MBコピーされます。第2引数で転送リストを指定すると、所有権の移動になりコピーが発生しません。
const buf = await file.arrayBuffer();
worker.postMessage({ type: 'start', buf }, [buf]);
// ここで buf.byteLength は 0 になる(送信側では使えなくなる)
結果を返すときも同様です。
// worker側
self.postMessage({ type: 'done', out: out.buffer }, [out.buffer]);
ちなみに File オブジェクト自体はそのまま postMessage で渡せます(中身は読み込まれません)。Worker側で slice() しながら読めるなら、そもそも arrayBuffer() を呼ばないのが一番省メモリです。
3. 進捗は間引いて送る
ffmpegのログやフレームごとのコールバックをそのまま毎回 postMessage すると、メインスレッドがメッセージ処理で忙しくなり、結局UIがカクつきます。100msに1回程度に間引くのがちょうどよかったです。
let last = 0;
function report(ratio) {
const now = performance.now();
if (now - last > 100 || ratio === 1) {
self.postMessage({ type: 'progress', ratio });
last = now;
}
}
4. キャンセルは terminate が確実
wasmの処理は途中で止める仕組みがないことが多いです。フラグを立てて抜けるのは、ループの切れ目がないと効きません。確実なのは Workerごと破棄することです。
cancelBtn.onclick = () => {
worker.terminate();
worker = createWorker(); // 次の変換用に作り直す
};
wasmのメモリは一度増えると縮まないので、大きなファイルを処理した後に作り直すのはメモリ解放の意味でも有効です。
5. SharedArrayBufferは必要なときだけ
マルチスレッド版のwasmを使うには SharedArrayBuffer が必要で、ページを crossOriginIsolated にする(COOP/COEPヘッダー)必要があります。ただしこれを有効にすると、外部の埋め込みや一部の広告・解析スクリプトが読み込めなくなることがあります。シングルスレッドで十分速い処理なら、無理に有効にしない判断もありです。
まとめ
- wasmの初期化からWorkerへ
- 大きなバッファは転送リストで移動
- 進捗は間引く
- キャンセルは terminate+作り直し
Nibboの動画圧縮のように、ファイルをアップロードせずブラウザ内だけで変換するツールでは、端末の処理能力がすべてです。だからこそ、変換中もUIが反応し続ける作りが体験を大きく左右します。
— Vitalii