3行まとめ
- 画像をブラウザ完結で圧縮するツールを作った。サーバーに送らず、JPEG/PNG/WebP/GIF 対応
- 圧縮の指定を「品質 80%」ではなく 「目標ファイルサイズ(何MB以下)」 で行う設計にした。
browser-image-compressionが反復再エンコードで目標サイズに収束させてくれる - サイズを稼ぐにはエンコード品質だけでなく 解像度のダウンスケール併用が効く。
useWebWorkerでメインスレッドも止めない
画像圧縮ツールは星の数ほどある。ただ多くは「品質: 80%」みたいなスライダーが出てきて、結局それを何%にすればメール添付に収まるのか分からない。ユーザーが本当に知りたいのは「画質パラメータ」ではなく「何KBになるか」だ。
ぱんだツールズに作った画像圧縮ツールは、そこを「目標ファイルサイズで指定する」方向に振った。中身は browser-image-compression 一本だが、設定の組み方とブラウザ完結ならではの作法にいくつか勘所があるのでまとめておく。
「品質%指定」と「目標サイズ指定」はUXが根本的に違う
JPEG の圧縮は本来「品質パラメータ(0〜100)」で制御する。Canvas の toBlob('image/jpeg', quality) の第2引数がまさにそれだ。だがこの方式には弱点がある。
- 同じ「品質70%」でも、写真の内容によって出力サイズは数倍ぶれる(のっぺりした画像は小さく、ディテールが多い画像は大きくなる)
- ユーザーは「70%だと何KBになるか」を事前に予測できない
- 「2MBのファイルを1MB以下にしたい」というよくある要求に、品質%では一発で答えられない(試行錯誤が要る)
そこで browser-image-compression の maxSizeMB を使う。これは「出力をこのサイズ以下に収めて」という目標サイズ指定で、ライブラリ側が品質を少しずつ下げながら再エンコードを反復し、目標サイズに近づける(最低品質まで下げても届かない画像はベストエフォートで可能な範囲まで)。ユーザーは「中品質(だいたい0.8MB以下)」を選ぶだけでよく、画質パラメータの意味を知らなくていい。
実装側では、3段階の品質プリセットを「目標サイズ+最大解像度」の組に落としている。
import imageCompression from 'browser-image-compression'
export type CompressQuality = 'low' | 'medium' | 'high'
const qualityConfigs: Record<
CompressQuality,
{ maxSizeMB: number; maxWidthOrHeight: number; useWebWorker: boolean }
> = {
low: { maxSizeMB: 0.3, maxWidthOrHeight: 1920, useWebWorker: true },
medium: { maxSizeMB: 0.8, maxWidthOrHeight: 2560, useWebWorker: true },
high: { maxSizeMB: 2, maxWidthOrHeight: 3840, useWebWorker: true },
}
UI で見せているのは「低品質(ファイルサイズ優先)/中品質(バランス)/高品質(画質優先)」の3択だけ。裏ではこれが maxSizeMB 0.3 / 0.8 / 2MB に対応している。「品質を選ぶ」と言いながら、実際にユーザーが選んでいるのは目標サイズだ、というのがこの設計の肝。
サイズ削減はエンコード品質だけでは足りない — 解像度ダウンスケールを併用する
maxSizeMB だけだと、目標サイズに収めるためにエンコード品質をどんどん下げることになり、ブロックノイズだらけになる。そこで併用しているのが maxWidthOrHeight。
これは「長辺がこのピクセル数を超えていたら、超えないように縮小する」指定。スマホ写真は長辺4000px超が当たり前だが、Web 表示やメール添付に4000pxは過剰なことが多い。解像度を落とすとピクセル数自体が減るので、エンコード品質を無理に下げなくてもサイズが大きく削れる。
- low: 長辺 1920px まで(Full HD 相当。ファイルサイズ最優先)
- medium: 長辺 2560px まで(WQHD 相当。バランス)
- high: 長辺 3840px まで(4K 相当。画質優先で解像度はほぼ保つ)
「圧縮率を上げる=品質を下げる」だけだと頭打ちになるが、解像度とエンコード品質の2軸で攻めると、見た目を保ったままサイズを大幅に減らせる。この2軸の組み合わせをプリセット化したのが上の qualityConfigs。
呼び出し側はプリセットを渡すだけ。
export async function compressImage(
file: File,
quality: CompressQuality
): Promise<CompressImageResult> {
const supportedTypes = ['image/jpeg', 'image/png', 'image/webp', 'image/gif']
if (!supportedTypes.includes(file.type)) {
throw new Error('対応していない画像形式です。JPEG・PNG・WebP・GIFに対応しています。')
}
const config = qualityConfigs[quality]
const originalSize = file.size
const compressed = await imageCompression(file, config)
return {
blob: compressed,
originalSize,
compressedSize: compressed.size,
}
}
imageCompression(file, config) がライブラリ本体の呼び出し。実コードではこの手前に「未指定なら弾く」「入力は最大20MBまで」のガードも入れているが、上のスニペットでは本筋を見せるため省いている。あとは元サイズと圧縮後サイズを返して、UI 側で Math.round((1 - compressedSize / originalSize) * 100) を「○%削減」として表示している(削減できなかった場合は「変化なし」を出す)。
useWebWorker: true でメインスレッドを止めない
地味だが効くのが useWebWorker: true。画像の再エンコードは CPU を食う処理で、メインスレッドで同期的にやると数MBの画像で UI が固まる(ボタンが効かない・プログレスバーが動かない)。
browser-image-compression は useWebWorker: true を渡すと、圧縮処理を Web Worker 側に逃がしてくれる。メインスレッドはブロックされないので、処理中もプログレス表示やキャンセル UI が生きたまま動く。ブラウザ完結ツールで「重い処理を Worker に逃がす」のは、体感品質に直結する定番の一手。
プリセット全段で useWebWorker: true にしているのはそのため。
ブラウザ完結ツールの作法:Object URL を撒き散らさない
圧縮自体はライブラリ任せだが、プレビュー表示まわりで気をつけているのが Object URL の後始末。
元画像と圧縮後画像をプレビューするために URL.createObjectURL(blob) で URL を作るが、これは明示的に revokeObjectURL しないとメモリに残り続ける。連続で何枚も圧縮するツールだと、解放を怠ると地味にリークする。
そこで useRef で直前の URL を保持しておき、(1) 新しい画像を読むとき・(2) コンポーネントのアンマウント時、の両方で古い URL を revoke している。
const prevOriginalUrl = useRef<string | null>(null)
const prevResultUrl = useRef<string | null>(null)
// アンマウント時にまとめて解放
useEffect(() => {
return () => {
if (prevOriginalUrl.current) URL.revokeObjectURL(prevOriginalUrl.current)
if (prevResultUrl.current) URL.revokeObjectURL(prevResultUrl.current)
}
}, [])
// 新しいファイルを読むとき、前の URL を先に解放してから作り直す
const handleFile = useCallback((f: File) => {
if (prevOriginalUrl.current) {
URL.revokeObjectURL(prevOriginalUrl.current)
prevOriginalUrl.current = null
}
// ...result 側も同様に解放...
const url = URL.createObjectURL(f)
prevOriginalUrl.current = url
setOriginalUrl(url)
}, [])
state ではなく useRef に直前 URL を持たせているのは、revoke のタイミングで「ひとつ前の URL」を確実に参照したいから。state だと再レンダリングのタイミング依存で取りこぼしやすい。クリーンアップ専用の値は ref に置くのが扱いやすい。
可逆圧縮と非可逆圧縮 — PNG と JPEG/WebP で振る舞いが変わる
対応形式は JPEG・PNG・WebP・GIF。ここで前提として、圧縮の効きは形式で大きく変わる。
-
非可逆圧縮(JPEG・WebP): 人間の目で気づきにくい情報を捨ててサイズを稼ぐ。
maxSizeMB指定が素直に効くのはこちら。品質を落としてもサイズを大きく削れる -
可逆圧縮(PNG): 完全に元へ戻せる代わり、削減幅は小さい。
maxSizeMBを厳しくしても、非可逆ほどは縮まない
なので「PNG のスクショを思いっきり軽くしたい」場合は、PNG のまま圧縮するより WebP/JPEG への変換を挟んだほうが効く。逆に透過を保ちたい・劣化を一切許容できないなら PNG のまま。このあたりは形式の性質を理解したうえで選ぶと無駄がない。
まとめ
ブラウザ完結で画像圧縮を作るときの要点はこのあたり。
- 圧縮指定は「品質%」より 「目標ファイルサイズ(
maxSizeMB)」 のほうがユーザーの欲しい答えに近い。browser-image-compressionが反復再エンコードで収束させてくれる - サイズ削減は エンコード品質 × 解像度ダウンスケール(
maxWidthOrHeight)の2軸で攻める。解像度を落とすのが効く -
useWebWorker: trueで重いエンコードを Worker に逃がし、メインスレッド(=UI)を止めない - Object URL は
useRef+ クリーンアップで確実に revoke。連続処理でリークさせない - PNG(可逆)と JPEG/WebP(非可逆)で圧縮の効きが違う。思い切り軽くしたいなら形式変換も選択肢
「品質を何%にすればいいか分からない」を「だいたいこのサイズに収めて」に置き換えるだけで、画像圧縮ツールはぐっと使いやすくなる。実装は browser-image-compression に乗るだけなので、自前ツールに組み込むのも軽い。
ぱんだツールズ では他にも画像の形式変換・リサイズ・モザイク・PDF・CSV など、開発者向けのブラウザ完結ツールを多数公開中。全部無料・登録不要・ファイルはサーバーに送られない。
https://sakutto-panda.com
この記事は Zenn にも同じ内容を投稿しています。