[JavaScript] libjxlをWASM+SIMD化してWeb Workerで動かす ― ブラウザ完結JPEG XLコンバーター構築記

[JavaScript] libjxlをWASM+SIMD化してWeb Workerで動かす ― ブラウザ完結JPEG XLコンバーター構築記

この記事の目的

次世代画像フォーマットである JPEG XL(JXL)を、サーバーサイドに頼らずブラウザ完結で高解像度(2048×2688 px 等)の画像から高速エンコードするWebツールを構築した。

本記事では、C++の公式ライブラリ libjxl を Emscripten で SIMD 対応 WASM へビルドし、Web Worker 経由で Astro UI に組み込むまでの完全な実装フローと、開発中に発生したトラブル(処理遅延・UIフリーズ・エラー通知漏れ)の解決策をまとめる。

スクリーンショット

[JavaScript] libjxlをWASM+SIMD化してWeb Workerで動かす ― ブラウザ完結JPEG XLコンバーター構築記 [JavaScript] libjxlをWASM+SIMD化してWeb Workerで動かす ― ブラウザ完結JPEG XLコンバーター構築記 [JavaScript] libjxlをWASM+SIMD化してWeb Workerで動かす ― ブラウザ完結JPEG XLコンバーター構築記

全体アーキテクチャ

ブラウザのメインスレッド(UI)を一切フリーズさせず、重い画像エンコード処理を実行するため、処理を3層に分離している。


[ Main Thread (Astro UI) ]
│  1. CanvasからRGBAピクセル取得 / UIパラメータ(Distance, Effort)収集
│  2. postMessage(Transferable Objects)

[ Web Worker (worker.js) ]
│  3. ccall 経由で C++ 側の関数を呼び出し
│  4. WASMリニアメモリの割り当てと解放

[ WebAssembly (libjxl + C++ Bridge) ]
│  5. JxlEncoder によるエンコード処理 (SIMD加速)
└─► 6. 圧縮データ(Uint8Array)を Worker 経由で Main Thread へ返却

1. C++ ブリッジの作成 ( jxl_bridge.cpp )

C++ 側のエントリーポイントとなる関数を作成する。JxlEncoder の BasicInfo、ColorEncoding(sRGB)、Distance(画質)、Effort(計算コスト)を構成し、RGBAピクセル配列を JXL バイナリへ変換する。

#include <emscripten/emscripten.h>
#include <jxl/encode.h>
#include <jxl/encode_cxx.h>
#include <vector>
#include <cstdlib>
#include <cstring>
#include <cstdio>

extern "C" {

EMSCRIPTEN_KEEPALIVE
uint8_t* encode_jxl(
    const uint8_t* rgba,
    size_t width,
    size_t height,
    float distance,
    int effort,           // 1 (最速・Falcon) 〜 9 (最高圧縮・Tortoise)
    size_t* out_size
) {
    auto enc = JxlEncoderMake(nullptr);
    if (!enc) {
        printf("[JXL Error] Failed to create JxlEncoder\n");
        *out_size = 0;
        return nullptr;
    }

    // 1. 基本情報 (BasicInfo) の設定
    JxlBasicInfo basic_info;
    JxlEncoderInitBasicInfo(&basic_info);
    basic_info.xsize = width;
    basic_info.ysize = height;
    basic_info.bits_per_sample = 8;
    basic_info.exponent_bits_per_sample = 0;
    basic_info.uses_original_profile = JXL_FALSE;
    basic_info.num_color_channels = 3;
    basic_info.num_extra_channels = 1; // Alpha
    basic_info.alpha_bits = 8;
    basic_info.alpha_exponent_bits = 0;

    if (JXL_ENC_SUCCESS != JxlEncoderSetBasicInfo(enc.get(), &basic_info)) {
        printf("[JXL Error] JxlEncoderSetBasicInfo failed\n");
        *out_size = 0;
        return nullptr;
    }

    // 2. カラーエンコーディング (sRGB) の設定
    JxlColorEncoding color_encoding = {};
    JxlColorEncodingSetToSRGB(&color_encoding, JXL_FALSE);
    if (JXL_ENC_SUCCESS != JxlEncoderSetColorEncoding(enc.get(), &color_encoding)) {
        printf("[JXL Error] JxlEncoderSetColorEncoding failed\n");
        *out_size = 0;
        return nullptr;
    }

    // 3. フレーム設定の生成と Effort / Distance の指定
    JxlEncoderFrameSettings* settings = JxlEncoderFrameSettingsCreate(enc.get(), nullptr);

    // Effort 値を動的に渡す
    JxlEncoderFrameSettingsSetOption(settings, JXL_ENC_FRAME_SETTING_EFFORT, effort);

    if (distance <= 0.0f) {
        JxlEncoderSetFrameLossless(settings, JXL_TRUE);
    } else {
        JxlEncoderSetFrameDistance(settings, distance);
    }

    // 4. 画像フレームの追加
    JxlPixelFormat format = {4, JXL_TYPE_UINT8, JXL_LITTLE_ENDIAN, 0};
    if (JXL_ENC_SUCCESS != JxlEncoderAddImageFrame(settings, &format, rgba, width * height * 4)) {
        printf("[JXL Error] JxlEncoderAddImageFrame failed\n");
        *out_size = 0;
        return nullptr;
    }

    JxlEncoderCloseInput(enc.get());

    // 5. 出力バッファ処理
    std::vector<uint8_t> compressed(64 * 1024);
    uint8_t* next_out = compressed.data();
    size_t avail_out = compressed.size();

    JxlEncoderStatus status = JXL_ENC_NEED_MORE_OUTPUT;
    while (status == JXL_ENC_NEED_MORE_OUTPUT) {
        status = JxlEncoderProcessOutput(enc.get(), &next_out, &avail_out);
        if (status == JXL_ENC_NEED_MORE_OUTPUT) {
            size_t offset = next_out - compressed.data();
            compressed.resize(compressed.size() * 2);
            next_out = compressed.data() + offset;
            avail_out = compressed.size() - offset;
        }
    }

    if (status != JXL_ENC_SUCCESS) {
        printf("[JXL Error] JxlEncoderProcessOutput failed with status %d\n", status);
        *out_size = 0;
        return nullptr;
    }

    size_t final_size = next_out - compressed.data();
    uint8_t* result = (uint8_t*)malloc(final_size);
    if (result) {
        memcpy(result, compressed.data(), final_size);
        *out_size = final_size;
    } else {
        *out_size = 0;
    }

    return result;
}

EMSCRIPTEN_KEEPALIVE
void free_result(uint8_t* ptr) {
    if (ptr) free(ptr);
}

}

2. Emscripten による SIMD ビルド

ビルドスクリプトでは、-msimd128 フラグを有効化してベクトル演算の並列化を行い、MODULARIZE=1 で外部ローダー化する。

emcc jxl_bridge.cpp -o public/jxl/jxl_encoder.js \
  -I/usr/local/include \
  -L/usr/local/lib -ljxl -ljxl_threads -lhwy -lbrotlienc -lbrotlidec -lbrotlicommon \
  -O3 \
  -msimd128 \
  -s WASM=1 \
  -s MODULARIZE=1 \
  -s EXPORT_NAME='createJxlModule' \
  -s ALLOW_MEMORY_GROWTH=1 \
  -s EXPORTED_FUNCTIONS='["_encode_jxl", "_free_result", "_malloc", "_free"]' \
  -s EXPORTED_RUNTIME_METHODS='["ccall", "getValue"]'

3. Web Worker 実装 ( worker.js )

メインスレッドのレスポンスを落とさないよう、WASM のインスタンス化と実行はすべて Web Worker 内で完結させる。

importScripts(self.location.origin + '/jxl/jxl_encoder.js');

let Module;

createJxlModule({
  locateFile: (path) => self.location.origin + '/jxl/' + path
}).then((initializedModule) => {
  Module = initializedModule;
  postMessage({ type: 'READY' });
});

onmessage = async (e) => {
  const { type, rgbaData, width, height, distance, effort } = e.data;

  if (type === 'ENCODE') {
    if (!Module) return;

    // 1. RGBA入力用メモリをWASMヒープ上に確保
    const numBytes = rgbaData.length;
    const ptr = Module._malloc(numBytes);
    Module.HEAPU8.set(rgbaData, ptr);

    // 2. 出力サイズ(size_t)受取用のポインタ確保
    const outSizePtr = Module._malloc(4);
    const startTime = performance.now();

    // 3. C++ 関数の呼び出し (ccall)
    const resultPtr = Module.ccall(
      'encode_jxl',
      'number',
      ['number', 'number', 'number', 'number', 'number', 'number'],
      [ptr, width, height, distance, effort, outSizePtr]
    );

    const elapsedTime = performance.now() - startTime;
    const outSize = Module.getValue(outSizePtr, 'i32');

    if (resultPtr && outSize > 0) {
      const jxlData = new Uint8Array(outSize);
      jxlData.set(Module.HEAPU8.subarray(resultPtr, resultPtr + outSize));

      // Transferable Objects でメインスレッドへ即時返送
      postMessage({ type: 'COMPLETE', jxlData, elapsedTime }, [jxlData.buffer]);
      Module._free_result(resultPtr);
    } else {
      // エラー発生時は必ずメインスレッドへ理由を通知
      postMessage({ type: 'ERROR', error: 'Encoding failed in WASM' });
    }

    // 4. 入力用バッファの解放
    Module._free(ptr);
    Module._free(outSizePtr);
  }
};

4. Astro Frontend 統合 ( jxl-converter.astro )

UI 側からユーザーが Distance(画質)と Effort(計算強度)を選択し、Worker 経由で非同期実行する。

<!-- Encode Options 部分抜粋 -->
<div class="setting-row">
  <label for="opt-quality">Distance / Quality (Lossless ~ Fast)</label>
  <input type="range" id="opt-quality" min="0" max="15" value="1" step="0.5">
  <span id="val-quality">1.0</span>

  <label for="effortSelect">Encode Effort / Speed:</label>
  <select id="effortSelect">
    <option value="1">1 - Lightning (最速)</option>
    <option value="3" selected>3 - Falcon (高速・推奨)</option>
    <option value="7">7 - Squirrel (標準)</option>
    <option value="9">9 - Tortoise (最高圧縮)</option>
  </select>
</div>

<button id="btn-convert">▶ CONVERT TO JXL</button>
<script>
  // Worker インスタンス化
  const blob = new Blob([workerCode], { type: 'application/javascript' });
  const worker = new Worker(URL.createObjectURL(blob));

  const qualityInput = document.getElementById('opt-quality') as HTMLInputElement;
  const effortSelect = document.getElementById('effortSelect') as HTMLSelectElement;
  const btnConvert = document.getElementById('btn-convert') as HTMLButtonElement;
  const progressOverlay = document.getElementById('progress-overlay');

  btnConvert.addEventListener('click', () => {
    if (!rawImageData) return;

    progressOverlay.classList.add('active');
    btnConvert.disabled = true;

    const rgbaArray = new Uint8Array(rawImageData.data.buffer);
    const distance = parseFloat(qualityInput.value);
    const effort = parseInt(effortSelect.value, 10);

    worker.postMessage({
      type: 'ENCODE',
      rgbaData: rgbaArray,
      width: rawImageData.width,
      height: rawImageData.height,
      distance: distance,
      effort: effort
    });
  });

  // Worker レスポンス受取
  worker.onmessage = (e) => {
    const { type, jxlData, elapsedTime, error } = e.data;

    if (type === 'COMPLETE') {
      const jxlResultBlob = new Blob([jxlData], { type: 'image/jxl' });
      progressOverlay.classList.remove('active');
      btnConvert.disabled = false;
      // レンダリング・サイズ比較表示処理
    } else if (type === 'ERROR') {
      alert(error);
      progressOverlay.classList.remove('active');
      btnConvert.disabled = false;
    }
  };
</script>

トラブルシューティング & 実装のハマりどころ

1. 処理時間が15秒以上かかる(Effortの最適化)

症状

5.5 メガピクセル(2048 × 2688 px)の画像を処理した際、WASM+SIMD ビルドにもかかわらずエンコードに 15.16 秒 かかっていた。

原因

libjxl のデフォルトの Effort(計算努力値)が 7(Squirrel)に設定されていた。Effort 7 は圧縮率を極限まで絞り込むために探索空間を広く取るため、WASM 環境では重すぎる。

対策

JxlEncoderFrameSettingsSetOption(settings, JXL_ENC_FRAME_SETTING_EFFORT, effort) を追加し、デフォルト値を 3(Falcon) に変更。

結果

  • Effort 7: 15.16秒 (ファイルサイズ: 670 KB)
  • Effort 3: 1.01秒 (ファイルサイズ: 686 KB / -88.5% 圧縮)

画質と圧縮比(5.8 MB → 686 KB)をほぼ維持したまま、処理時間を約 15 倍高速化 することに成功。

設定パターン処理時間出力サイズ圧縮率
Effort 9 (Tortoise)~35.2秒662 KB-88.6%
Effort 7 (Squirrel)15.16秒670 KB-88.4%
Effort 3 (Falcon)1.01秒686 KB-88.5%
Effort 1 (Lightning)0.42秒790 KB-86.3%

2. エラー発生時にプログレス表示(スピナー)が止まらない

症状

C++ 側で JxlEncoderAddImageFrame やメモリ確保に失敗した際、画面上の「Encoding…」プログレス表示が永遠に閉じない状態になった。

原因

Worker 側の JS で resultPtrnullptroutSize == 0 を返した際の else 判定が存在せず、メインスレッドへの postMessage 通信が発生しなかったため。

対策

Worker 側に type: 'ERROR' の明確な例外返送分岐を追加し、メインスレッド側の onmessageerror 変数を正しく渡して受取処理(progressOverlay.classList.remove('active'))を実行するように修正。


トラブルまとめ

現象・問題原因解決策
処理時間が15秒オーバーlibjxl デフォルトの Effort 7 による過剰探索C++ 側で Effort オプションを受け取り、標準値を 3 (Falcon) に変更
エラー時にスピナー停止不能Worker 側の成功時以外の通知処理漏れWorker の else 節で postMessage({ type: 'ERROR' }) を発行しメインスレッドで解除
TypeScript 型エラーgetElementById 戻り値の型不一致as HTMLSelectElement などの明示的キャストを追加
WASMのモジュール未読み込みWeb Worker 内でのパス解決失敗importScripts および locateFileself.location.origin からの絶対パスを指定

まとめ

  • Effort 値の選択が WASM エンコーダーの生命線: デフォルトの Effort 7 から Effort 3(Falcon)へ落とすだけで、実用十分な圧縮性能(-88.5%)を維持したまま 15.16 秒 → 1.01 秒 と 15 倍の高速化が達成できる。
  • Worker 側での例外ハンドリング: C++ 側のエラーステータス(ポインタおよびサイズチェック)を確実に捕捉し、Worker からメインスレッドへ失敗イベントを透過させないと UI 制御が破綻する。
  • Transferable Objects の活用: RGBA ピクセルバッファおよび生成された Uint8Array を postMessage の第2引数(Transfer List)に指定することで、メインスレッド・Worker 間のメモリコピーのオーバーヘッドを無くすことができる。