多くのウェブアプリは、依然として画像のアップロードをブラックボックスとして扱っています。ユーザーがファイルをドロップし、ブラウザがそれを送信し、サーバーはペイロードを受け入れるか、誰も想定していない413エラーを投げます。ブラウザ側での圧縮は、この状況を一変させます。通信が発生する前にペイロードを縮小できるため、アップロードの高速化、帯域幅コストの削減、サーバーのタイムアウト減少につながります。しかし、この作業は一歩間違えると厄介です。「品質」というラベルの付いた魔法のスライダーのように圧縮を扱うと、壊れた画像、引き伸ばされたサムネイル、混乱を招くユーザー体験を生み出すことになります。より良いアプローチは、プロセス全体を「パイプライン」として扱うことです。

スライダーではなく、パイプラインとして考える

タスクを個別のステージに分割します。input要素からファイルを読み込み、ターゲットの寸法に合わせて画像を縮小し、新しいBlobをエンコードし、その結果をユーザーにレンダリングします。各ステージは一つのことだけを行い、その出力を次のステージに渡します。この分離は、単にコードを整理するだけでなく、ユニットテストを容易にします。ファイル入力に触れることなく、既知のバッファをスケーリングステージに投入できます。サーバーとの往復を待たずに、エンコーダーが200KB以下のJPEGを出力するかどうかを確認できます。何かが壊れたとき、どのステップが失敗したのかを正確に特定できます。

これらの責務を分離しておくことで、アップロード中の予期せぬ事態も防げます。スケーリングとエンコーディングを一つの複雑な関数にまとめると、途中でデコードエラーが発生した際に、アップロードキューが不整合な状態になる可能性があります。パイプラインを採用すれば、すべての境界でバリデーション(検証)を強制できます。ファイルがデコードできない場合は、キャンバスを作成する前に検知できます。エンコードされたBlobが大きすぎる場合は、サーバーに保存を依頼する前に検知できます。

コードを書く前に規約を定義する

canvasの描画処理を書く前に、ルールを書き出し、チームで共有してください。受け入れるMIMEタイプを選択します。JPEG、PNG、WebP、AVIFのどれを許可しますか?それぞれ、アルファチャンネル、ブラウザのサポート、CPUコストへの影響が異なります。最大入力サイズを設定してください。フラッグシップ機の30MBの生写真は、メモリ内で完全にデコードしようとすると、古いノートPCをフリーズさせたりクラッシュさせたりする可能性があります。最大出力寸法を定義してください。UIで2048ピクセル以上の幅の画像を表示しないのであれば、6000ピクセル幅の写真をパイプラインに通す理由はありません。

最も重要なのは、デコードの失敗に備えることです。破損したファイル、特殊なカラープロファイル、または途切れたアップロードは、Imageコンストラクタでエラーを投げることがあります。パイプラインには、明確なcatchブロックと、人間が理解できるエラーメッセージが必要です。ブラウザが音もなく停止し、何も起きないままユーザーがスピナーを見つめ続けるような事態を避けてください。

画像を尊重する

画像の歪みは、素人っぽさを感じさせます。アスペクト比を維持し、長い方の辺を上限に設定してください。ターゲットのボックスが1024x1024ピクセルの場合、4000x3000の写真は1024x1024ではなく、1024x768になるべきです。長い方の辺からスケール係数を計算し、短い方の辺をそれに従わせます。これにより、画像が奇妙な形に引き伸ばされるのを防げます。

実際の書き出しには、canvasのtoBlobメソッドを使用してください。これにより、出力フォーマットと品質設定を直接制御でき、非同期で実行されるためメインスレッドをブロックしません。オフスクリーンキャンバスを作成し、リサイズした画像をそこに描き、好みのタイプと品質値を指定してcanvas.toBlobを呼び出します。その新しいBlobを、アップロードロジックやストレージAPIに渡します。

結果を可視化する

圧縮は目に見えない作業です。数値を提示しなければ、ユーザーはそのプロセスを信頼しません。元の画像と結果を比較できるインターフェースを構築してください。元のファイルサイズ、新しいファイルサイズ、新しい寸法、および最終的なフォーマットタイプを表示します。4.2MBのスマートフォンの写真が380KBのWebPに減少するのを目にすれば、ユーザーは「画像がこっそり改ざんされているのではないか」という不安を感じることはありません。

この透明性はトラブルシューティングにも役立ちます。アップロードに失敗したというユーザーの苦情があった場合、最初に確認するのは、出力寸法がサーバーの制限を超えていないか、あるいはフォーマットがPNGからJPEGに変わってアルファチャンネルが失われていないか、といった点です。ユーザーがサポートチケットを切る前に自分で問題を診断できるよう、そのデータをUIに表示しておきましょう。

再圧縮よりもプリセットを活用する

同じ画像を二度圧縮してはいけません。非可逆エンコーダーを何度も通すたびに、ディテールが失われ、ブロック状のアーティファクトが発生します。ユーザーが繰り返し「最適化」ボタンを押せるようにすると、3世代目の画像はコピーのコピーのような見た目になってしまいます。代わりに、すべての出力を元のソースファイルから生成し、以下のようなプリセットを提供してください。

  • ファイルサイズを小さく: サムネイルや高速プレビュー用に、品質を下げ、寸法を大幅に制限します。
  • バランス型: ソーシャルフィードやギャラリーに適した、適切な寸法と中程度の品質レベルを目指します。
  • 詳細重視: 写真、アートワーク、または印刷プレビュー用に、高い品質と大きな寸法を維持します。

ユーザーが生成を繰り返すことによる劣化の蓄積を避けてプリセットを切り替えられるよう、元のBlobをメモリに保存しておきます。常にソースから生成し、前回の出力から生成してはいけません。

ユーザーのアップロード環境を想定してテストする

光回線と32 GBの RAM を備えた開発用マシンは、現実の環境ではありません。実際のユーザーが扱うファイルでテストしてください。iOSやAndroidのスマホ写真は、メタデータの向きが異なっていたり、HEICソースから生成されていたりすることがあります。ロゴやアイコンなどの透過アセットは、JPEGがアルファチャンネルをサポートしていないため、JPEG変換時に挙動が変わります。巨大なファイルは、2 GBの RAM を搭載したデバイスでのメモリ制限を露呈させます。低速なモバイル CPU は、toBlob の呼び出しに実際どれくらいの時間がかかるかを正確に明らかにします。

Chrome DevTools を使用して、CPU とネットワークのスロットリングを行ってください。5年前の Android 端末で試してみるのも良いでしょう。エンコード中にパイプラインが UI を3秒間ロックしてしまう場合は、インターフェースの応答性を維持するために、重い処理を Web Worker に移動させる必要があります。

まずは基本機能をリリースする

あらゆるフォーマットをサポートしたくなるものですが、