変換ボタンを押した直後、ページが固まることがあります。動いていたはずの画面がぴたりと止まり、1 秒足らずで元に戻ります。エラーも出ません。商品画像をブラウザーの中で AVIF(JPEG や WebP より新しい画像形式)にして書き出せないか調べていて、自分のテストページでこの止まり方を数字にしてみました。Worker に移せば速くなるだろう、と最初は思っていました。測ってみると、変わったのは別のところでした。
先に前提を書きます。画像ファイルを作る処理をエンコード、それを行うプログラムをエンコーダーと呼びます。canvas には toBlob という、描いた内容を画像ファイルとして取り出すメソッドがあります。ここに image/avif を指定しても、私が試した Chromium 149、Chrome 155、Firefox 151 では PNG が返ってきました。AVIF が返ったのは WebKit 26.5(macOS)だけです。そこで、エンコーダーを自分で持ち込むことにしました。使ったのは @jsquash/avif 2.1.1 で、libavif v1.0.1 というエンコーダーを WASM(ブラウザーの中で動かせるバイナリ形式)にしたものです。マシンは Apple M4 の Mac(macOS)1 台だけです。スマホのブラウザー、Windows、Safari の正式版、Edge では試していません。以下のミリ秒はすべてこの 1 台での値です。画像はウィキメディア・コモンズにある Jakub Fryš 氏の湖の写真(CC BY-SA 4.0)を 1738×1150、約 2 MP に縮めたものを使いました。設定は既定値のままです。品質が 50、speed が 6 になります。speed はエンコードにかける手間の段階で、数字が大きいほど速く終わります。
メインスレッドではエンコード中ずっと画面が止まった
メインスレッドは、ページの描画やクリックの処理を受け持つ、ブラウザーの中の 1 本の流れです。JavaScript も普通はここで動きます。ここで重い計算を始めると、終わるまで描画の順番が回ってきません。止まっていた長さは、requestAnimationFrame で測りました。次に画面を描く直前に関数を呼んでもらう API で、何も詰まっていなければ短い間隔で呼ばれ続けます。呼ばれた時刻の差を取り続けて、いちばん長かった間隔を残します。
let prevTick = performance.now(), worstGap = 0;
function watchFrames() {
const now = performance.now();
worstGap = Math.max(worstGap, now - prevTick);
prevTick = now;
requestAnimationFrame(watchFrames);
}
requestAnimationFrame(watchFrames);
何もしていないときの最大間隔は 17〜25 ms でした。この状態でエンコードをメインスレッドから直接呼ぶと、Chromium 149 ではエンコードに 762 ms かかり、フレーム間隔の最大も 762 ms になりました。2 つの数字が揃ったのは、エンコードが終わるまで描画が一度も回らなかったからです。Chrome 155 はエンコード 776 ms に対して最大間隔 778 ms、WebKit 26.5 は 816 ms に対して 816 ms でした。3 つとも、エンコードにかかった時間だけページが止まっています。0.8 秒なら我慢できそうにも見えます。ただ、これは約 2 MP の写真 1 枚の話です。エンコード時間は画像が大きいほど長くなり、別に Worker の中で測った分では、同じ写真の約 12 MP が Chromium 149 で 3.8 秒ほどでした。大きい画像をメインスレッドで試してはいません。それでも、商品画像を何枚も続けて処理する画面にこのまま載せる気にはなれませんでした。
赤い棒がメインスレッドです。緑の棒は次の節で試す Worker です。棒の長さはエンコード中の最大フレーム間隔を表しています。ブラウザー名の下にある小さな数字がエンコード時間で、左がメインスレッド、右が Worker です。棒の長さは大きく違うのに、この小さな数字はほとんど同じです。
Worker に移すと最大フレーム間隔は 20 ms 前後に戻った
Worker は、メインスレッドとは別に JavaScript を動かせる裏方のスレッドです。画面には触れませんが、メッセージで画素データを受け取り、計算結果を送り返すことができます。エンコーダーの読み込みと encode() の呼び出しをまるごと Worker の側に置き、ページからは画素を渡して返事を待つだけにしました。
// メインスレッドで直接呼ぶ。戻ってくるまで描画は回らない
const avifBytes = avifCodec.encode(rgba, imgW, imgH, avifOptions);
// Worker に渡す。ページは返事のメッセージを待つだけ
const pixelCopy = rgba.buffer.slice(0);
avifWorker.postMessage({ pixelCopy, imgW, imgH }, [pixelCopy]);
同じ画像で測り直すと、エンコード中の最大フレーム間隔は Chromium 149 で 22 ms、Chrome 155 で 19 ms、WebKit 26.5 で 18 ms でした。どれも 20 ms 前後です。何もしていないときの 17〜25 ms と区別がつきません。エンコードの間に描画が回った回数も、Chromium 149 ではメインスレッドの 10 回に対して Worker は 67 回でした。この回数にはエンコード前後の短い待ち時間も含まれるので、桁を見るための数字です。Firefox 151 でも止まる・止まらないの傾向は同じで、Worker での最大間隔は 19 ms でした。ただ、私が使ったテスト用の Firefox 151 ではエンコード時間そのものがほかの 3 つと大きく違っていました。原因は調べておらず、正式リリース版でも確かめていません。図からは外してあります。
エンコード時間は 766 ms のままだった
期待が外れたのはここです。Worker の中で測った encode() の時間は、Chromium 149 で 766 ms でした。メインスレッドの 762 ms とほぼ同じです。Chrome 155 は 776 ms が 775 ms に、WebKit 26.5 は 816 ms が 830 ms になっただけでした。エンコードそのものにかかる時間は、どちらに置いても縮みませんでした。
荷物用のエレベーターにたとえると分かりやすいと思います。1 基しかないエレベーターで大きな荷物を運ぶと、そのあいだ人は乗れません。荷物用を別に 1 基用意すれば、人は待たずに済みます。けれども、荷物が上の階に着くまでの時間は同じです。Worker がしてくれるのは「人が乗れるようにする」ほうで、「荷物を速く運ぶ」ほうではありませんでした。
測り方には弱いところがあります。メインスレッドと Worker の組み合わせは、ブラウザーごとに 1 回ずつしか測っていません。762 と 766 の 4 ms の差に意味があるかどうかは、この回数では言えません。Worker に画素を渡す時間と、結果を受け取る時間も測定の外です。ここは次に測るつもりです。
既製のツールがこの待ち時間をどう見せているかも、一つだけ見ました。私が使ったのは、ブラウザーで画像を変換できる ImgIng(https://imging.ai/ )で、開いたのは Chromium 149 です。出力形式の AVIF には「On-demand WASM」というラベルが付いていて、エンコーダーを後から読み込む作りだと画面から分かります。変換を押すと、ボタンの文字は Downloading module…、Initialising core…、Reading image…、Encoding AVIF… の順に変わりました。いま何を待っているのかが、ボタンを見れば分かります。同じ手順を中国語版のサイトでも流しました。押してから完了の表示が出るまでは、同じ湖の写真の約 0.5 MP で 217 ms、約 8 MP で 1092 ms でした。この数字は、画像の読み込みや画面の更新まで含めて外から計ったものです。上に書いた encode() だけの時間とは測っている範囲が違うので、並べて比べることはできません。計ったのは各サイズ 1 回ずつです。内部の実装は見ていません。
英語版の画面です。AVIF のボタンの上に「On-demand WASM」のラベルがあり、赤いボタンの文字は「Downloading module…」になっています。
いまの自分の整理はこうです。私は先に、ページを止めたくないのか、待ち時間を縮めたいのかを分けて考えることにしました。止めたくないだけなら Worker で足ります。縮めたいなら別の手が要ります。画像を小さくするのか、設定を変えるのか、どちらを取るかはまだ決めていません。自分のページがどちらで困っているのかは、上の watchFrames を仕込んで、処理中の最大間隔を見れば分かります。

