[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フリーズ・エラー通知漏れ)の解決策をまとめる。
スクリーンショット
全体アーキテクチャ
ブラウザのメインスレッド(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 で resultPtr が nullptr や outSize == 0 を返した際の else 判定が存在せず、メインスレッドへの postMessage 通信が発生しなかったため。
対策
Worker 側に type: 'ERROR' の明確な例外返送分岐を追加し、メインスレッド側の onmessage に error 変数を正しく渡して受取処理(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 および locateFile で self.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 間のメモリコピーのオーバーヘッドを無くすことができる。