canvas.toBlob(blob => {...}, 'image/webp') を呼んで、コールバックは正常に発火し、blob も null ではなく、サイズも妥当。それでも中身が WebP とは限りません。
私はこれをユーザーからの「ファイルが開けない」という報告で知りました。ダウンロードされたファイルを16進で覗くと、先頭が 89 50 4E 47 — PNG のマジックナンバーでした。
HTML 仕様にはこう書かれています。要求された型をユーザーエージェントがサポートしない場合、PNG で作成しなければならない。エラーも警告も出ません。黙って差し替わります。
この記事は、画像処理をブラウザ内で完結させようとして踏んだ落とし穴と、そこから決まっていった設計の話です。
なぜブラウザ内でやろうと思ったか
同僚が商品画像を扱う様子を見たのがきっかけでした。やりたいことは単純で、十数枚の写真を切り抜いて背景を差し替え、規定のサイズまで落とす。
Photoshop は入っているけれど、十数枚のために起動して読み込みを待つ気になれない。オンラインの AI ツールは、アップロード → 待ち行列 → ダウンロードで一枚あたり数十秒。混雑すればさらに延びるし、切り抜きが甘ければもう一周やり直しです。
彼女の言葉はこうでした。「早く終わらせたいだけ。そのためにソフトを入れたくないし、待ちたくもない」
技術用語はひとつも出てきませんが、問題は明確です。既存の選択肢は重いか遅いか。そしてそのどちらも、アルゴリズムの問題ではありません。
能力は「調べる」のではなく「測る」
処理をローカルに置くと決めた瞬間、最初にやることはブラウザが実際に何をできるかの把握です。冒頭の落とし穴がここに直結します。
UA で判定すればいい、とはなりません。iOS ではすべてのブラウザが WebKit ですし、WebView のバージョンは OS に追従します。ユーザーが「デスクトップ用サイトを表示」を有効にすれば UA は macOS になります。UA が答えるのは「あなたは誰か」であって、私が知りたい「いまこの形式をエンコードできるか」ではありません。その間にはカーネルのバージョン、OS のバージョン、ホストアプリ、ビルドオプションが挟まっています。
さらに厄介なことに、canvas のエンコードには能力照会の API が存在しません。音声・動画には MediaRecorder.isTypeSupported() がありますが、画像側には何もない。
つまり、実際に一度エンコードして戻り値の型を突き合わせるしかありません。
const blob = await new Promise(r => canvas.toBlob(r, mime));
const ok = !!blob && blob.type === mime; // 後半が本質
デスクトップ Chrome で一通り試した結果は次の通りでした。
| 形式 | 結果 |
|---|---|
| WebP | ネイティブ出力 |
| JPEG | ネイティブ出力 |
| AVIF / HEIC / TIFF / GIF | すべて PNG にフォールバック |
フォールバックした 4 形式は、返ってきた blob のサイズが完全に一致していました。同じ 1 枚の PNG なので当然です。blob が null かどうかだけを見ていたら、6 形式すべてが「成功」になります。
読み込み側も同様で、デコードできることはエンコードできることを意味しません。Safari 16 は AVIF をデコードできますが、エンコードはできません。そこで基準はこうしました。入力がデコードでき、出力がエンコードできる。両方成立して初めてローカル処理に回す。
UI 上の「即時 / ローカル(要ダウンロード)/ サーバー」という表示は、この実測から導かれたものです。ブラウザの型番で表を引いているわけではありません。
速い実装が、正しい実装とは限らない
PNG を 256 色に減色する処理で、これを痛感しました。
160 万ピクセル × パレット 256 色 = 約 4.1 億回の距離計算。素直に総当たりで書くと実測 958 ミリ秒。ブラウザ上ではフリーズと同義です。
教科書的な解法は上位ビット量子化のルックアップテーブルで、12 ミリ秒まで落ちます。77 倍速い。ただし正しさを検証したところ、14.57% のピクセルが最適でない色を選んでいました。テーブル自体が近似なので当然といえば当然です。
そこで画像そのものを数え直しました。160 万ピクセルのうち、ユニークな色は 18 万色しかない。計算の 89% が重複作業だったわけです。
完全な RGB 値でキャッシュする方式に変えた結果が 153 ミリ秒、誤差ゼロ。総当たりの 6 倍速で、品質の妥協はありません。
必要だったのは賢いデータ構造ではなく、入力の性質を測ることでした。この順番を間違えると、速いけれど間違っている実装に落ち着きます。
ローカルでできる範囲は、想定より広かった
当初は形式変換と圧縮だけのつもりでした。
アニメーション画像は予想外でした。GIF / APNG / アニメーション WebP / アニメーション AVIF の 4 形式について、最終的にエンコーダを自前で実装しています。フレーム単位の編集(並び替え、表示時間の調整、削除)も可能で、既存のアニメーションを圧縮する際はフレームごとの表示時間とループ設定を読み取って保持します。
AI 切り抜きは最も懐疑的だった部分です。ブラウザで推論モデルを動かすのは玩具だろう、と。実際には、モデルはオンデマンドでダウンロードされ(軽量版で約 42 MB、ほかに 219 MB と 447 MB の選択肢)、推論は WebGPU を優先し、非対応環境では WASM にフォールバックします。ダウンロード後はブラウザにキャッシュされるため、2 回目以降は秒単位で終わります。
ここで冒頭の「遅い」という不満が回収されます。経路にアップロードも待ち行列もなく、2 回目からはモデルの取得すら不要です。
できなかったこと
境界を隠すと、ユーザーは二度使えば気づきます。そのとき失われるのはツール全体の信頼です。なので明示しています。
HEIC は読めるが書けない。 読み込みは libheif の WASM 版でローカル完結します。書き込みは HEVC エンコードの特許問題が明確な壁で、利用可能なエンコーダを WASM にビルドするとサイズも大きい。使用頻度の高くない出力形式のために全員へ数 MB を課すのは割に合いません。ここはサーバー側に投げ、UI にもそう表示しています。
PDF 圧縮はフォントのサブセット化を行いません。 そのため文字主体の PDF では削減率が 5〜20% 程度にとどまります。この種のファイルは専用の組版ツールのほうが強い。こちらの主戦場はスキャン文書と図版混在のレポートです。
PDF → HTML は元より大きくなることがあります。 ページを固定レイアウトの SVG として再構築するのでテキスト選択は可能ですが、静的 SVG に展開した結果として元ファイルを超える場合がある。ワークベンチ側で実際のサイズを提示して判断してもらう作りにしています。
副次的に得られた 2 つのこと
画像がアップロードされない。 これは出発点ではなく、処理をローカルに置いた結果として自然に得られたものです。一般的な形式の変換・圧縮・切り抜きの過程で画像のアップロードリクエストは発生しません。DevTools の Network パネルで確認できます — WASM のコーデックや AI モデルがダウンロードされてくるのは見えますが、画像が出ていく通信は見えません。方向が逆です。
(HEIC 書き出しなど一部の専門形式はサーバー処理なのでアップロードが発生します。そこは UI で明示しています。)
サーバーコストが発生しない。 処理は利用者のマシンで走り、こちらは静的ファイルを配信しているだけです。1 枚変換されようが 1000 枚変換されようがコストは変わりません。だから回数制限も透かしも会員登録も設ける理由がない。アップロード型のツールが無料にしにくいのは、変換のたびに実際の CPU 時間が出ていくからです。
結局のところ
画像処理ツールは確かに成熟しています。ただ成熟したのは機能であって、機能を手に入れる経路ではありません。
Photoshop の能力は圧倒的ですが、対価はインストールと習熟です。オンライン AI ツールのモデルも優秀ですが、対価は画像を送って待つことです。「たまに十数枚を処理する人」にとっては、どちらも割に合わない。機能が足りないのではなく、その機能を使うために払う代価が高すぎるのです。
処理をブラウザに持ち込むことは、その代価をほぼゼロに近づける作業でした。ページを開くことがインストールの全工程で、待ち行列もアップロードもない。能力の上限がブラウザに縛られるのは、この選択に付いてくる対価です。だから上の各節で、その境界を具体的に書きました。
ここまでの実装は https://imging.ai で動かしています。