はじめに
🤔「画像は WebP に変換してから保存しているし、軽いはず…」
😥「…なのに、なぜかこの写真だけ 5MB もある…?」
🫠「拡張子も Content-Type も WebP なのに、中身が PNG…?」
画像のアップロードでは、ブラウザ側で縮小・圧縮してから保存するのが定番です。
canvas.toBlob() で WebP にすれば、写真はかなり軽くなります。
個人開発している口コミサービス あじぴた でも、料理写真とアイコンをそうやって保存していました🍜
ところがある日、名前も Content-Type も WebP なのに、中身は PNG という写真が、本番に溜まっていることが分かりました。
しかも、エラーは 1 度も出ていませんでした。
この記事では、
-
canvas.toBlob()が、黙って PNG を返すことがある理由 - 返ってきた画像の中身の形式を確かめて、JPEG で作り直す直し方
- 本番に溜まった画像を、バックアップを取ってから変換し直した方法
を書きます🖼️
この記事は、個人開発サービス「あじぴた」の設計を書くシリーズの 1 本です。
単体で読めるように書いていますが、サービスの全体像はハブ記事にまとめています。
気づいたきっかけ:写真が載らないカード
あじぴたには、口コミから **Instagram 用の画像(カード)**を作る機能があります。
料理の写真とコメントを、1 枚の画像にまとめるものです。
ある日、運営の画面でカードを作ると、写真があるはずの口コミなのに、写真なしのカードが出てきました。
もう一度作ると、今度は写真入りで出てきます。
調べると、カードを作る処理は、写真を縮小してから載せていて、4 秒で間に合わなければ写真なしにする作りでした。
1 回目は写真の縮小が 4 秒に間に合わず、2 回目はキャッシュが効いて間に合った、というわけです。
では、なぜ縮小にそれほど時間がかかったのか。
元の写真を確かめると、こうなっていました。
| 項目 | 値 |
|---|---|
| ファイル名 | ….webp |
| Content-Type | image/webp |
| 実際の中身 | PNG(1600×1600・透過あり) |
| サイズ | 約 5MB |
同じ写真を JPEG(画質 0.8)にすると、約 0.86MB です。
6 倍近い大きさの PNG が、WebP の名前で保存されていました😱
原因:toBlob は、作れない形式を頼まれると PNG を返す
アップロード前の変換は、こんな作りでした。
// Before: WebP を頼み、返ってきたものを WebP として保存していた
canvas.toBlob(
(blob) => {
// blob の中身が本当に WebP かは、確かめていない
upload(new File([blob], 'photo.webp', { type: 'image/webp' }))
},
'image/webp',
quality,
)
canvas.toBlob() は、指定した形式をブラウザが作れないとき、エラーにせず PNG を返します。
これはバグではなく、HTML の仕様どおりの動きです。
そして、WebKit 系のブラウザ(iPhone の Safari など)は、canvas から WebP を作れません。
つまり、iPhone から投稿された写真は、ずっと PNG のままだったのです🫠
PNG になると、圧縮の仕組みがまるごと空回りする
困るのは、形式が違うことだけではありません。
写真を 1MB 以内に収めるために、画質を少しずつ下げて作り直す処理を入れていました。
ところが、PNG には画質の指定が効きません(PNG は劣化しない形式なので、画質という考え方がありません)。
画質 0.9 で作る → PNG で 5MB → 1MB を超えている
画質 0.8 で作る → PNG で 5MB → まだ超えている(画質が効かないので、何度やっても同じ)
…(0.7、0.6 も同じ)
画質 0.5 で作る → PNG で 5MB → 下限まで来たので、そのまま保存する
画質を下げているつもりで、同じ 5MB を何度も作っていただけでした。
結果、写真はアップロードの上限(5MB)近くまで膨らんでいました。
| 影響 | 内容 |
|---|---|
| ストレージ | 1 枚あたり、本来の約 5 倍の容量を使う |
| 転送量 | 表示のたびに、約 5 倍の量を送る |
| Instagram 用のカード | 写真の縮小が間に合わず、初回は写真なしになる |
直し方:返ってきた中身の形式を確かめる
直し方はシンプルです。
返ってきた Blob の type を確かめて、WebP でなければ JPEG で作り直します。
JPEG はどのブラウザでも作れて、画質の指定も効きます👍
// After: 返ってきた形式を確かめ、WebP でなければ JPEG で作り直す
export const encodeCanvas = async (canvas, qualities, targetBytes) => {
let format = WEBP
let source = canvas
let blob = await canvasToBlob(source, format.mimeType, qualities[0])
if (blob.type !== 'image/webp') {
format = JPEG
source = flattenOnWhite(canvas) // 透過を白で埋める(後述)
blob = await canvasToBlob(source, format.mimeType, qualities[0])
// JPEG でも型が合わなければ、取り違えたまま保存せずに止める
if (blob.type !== 'image/jpeg') {
throw new Error(`canvas produced ${blob.type} for image/jpeg`)
}
}
// 形式は最初の 1 回で決め、ここからは画質だけを下げていく
for (const quality of qualities.slice(1)) {
if (blob.size <= targetBytes) break
blob = await canvasToBlob(source, format.mimeType, quality)
}
return { ...format, blob } // 呼び出し側は、この形式でファイル名と型を決める
}
ポイントは 3 つあります。
① ファイル名と型を、中身に合わせる
保存するファイル名(.webp / .jpg)と Content-Type は、頼んだ形式ではなく、実際に返ってきた形式から決めるようにしました。
「名乗り」と「中身」が食い違う状態を、そもそも作らないためです✅
② JPEG にする前に、透過を白で埋める
JPEG は透過を持てません。
透過のある画像をそのまま JPEG にすると、透過していた部分が黒く塗られます。
透過 PNG のアイコンなどが、真っ黒な背景になってしまうわけです。
そこで、JPEG で作り直すときだけ、白い下地の canvas に描き写してから変換しています。
const flattenOnWhite = (source: HTMLCanvasElement) => {
const canvas = document.createElement('canvas')
canvas.width = source.width
canvas.height = source.height
const ctx = canvas.getContext('2d')!
ctx.fillStyle = '#ffffff'
ctx.fillRect(0, 0, canvas.width, canvas.height)
ctx.drawImage(source, 0, 0)
return canvas
}
③ 形式は最初の 1 回で決める
WebP を作れないブラウザは、画質を変えても WebP を作れるようにはなりません。
そこで、形式は最初の 1 回で決め、そのあとは同じ形式のまま画質だけを下げるようにしました。
テストで「黙って PNG を返すブラウザ」を再現する
このバグは、テストを書いていても見つかりませんでした。
テストの環境では、WebP を頼めば WebP が返ってくるからです。
そこで、WebP を頼まれても PNG を返す toBlob をテストの中で作り、次のことを確かめています🧪
- JPEG で作り直すこと
- ファイル名と型が、中身と一致すること
- 画質の引き下げも、JPEG で行うこと
- 白い下地に描き写してから JPEG にすること
型の確認を外すと、5 件のテストが落ちることも確かめました。
直したつもりの確認が、本当に効いていることを確かめるためです。
ついでに見つかった、小さなバグがもう 1 つありました。
アイコンの画質を「0.9 から 0.1 ずつ下げて、0.4 まで」と計算で作っていたところ、
浮動小数点の誤差で 0.4 をわずかに下回り、0.3 まで試していました。
料理写真と同じく、[0.9, 0.8, 0.7, 0.6, 0.5, 0.4] のように値をそのまま書く形に直しています。
本番に溜まった画像を、変換し直す
変換の処理を直しても、すでに保存された PNG はそのままです。
そこで、本番の画像を一度だけ変換し直すスクリプトを書きました。
中身の形式は、先頭のバイトで判定する
ファイル名や Content-Type は、まさに今回信用できなかったものです。
そこで、**ファイルの先頭のバイト(マジックナンバー)**で、実際の形式を判定しました🔍
| 形式 | 先頭のバイト |
|---|---|
| PNG |
89 50 4E 47(\x89PNG) |
| WebP |
52 49 46 46 …… 57 45 42 50(RIFF....WEBP) |
| JPEG | FF D8 FF |
ファイル全体ではなく、先頭の 16 バイトだけを読みにいくので、数が多くても軽く確かめられます。
対象は「名前は .webp なのに、中身が PNG」のものだけです。
同じ場所に上書きする
変換した WebP は、元と同じパスに上書きします。
パスが変わらないので、データベースに保存している画像の URL を書き換える必要がありません。
上書きは元に戻せない。だから、バックアップを必須にする
同じパスへの上書きは、元に戻せない操作です。
そこで、スクリプトは「間違えようがない」形にしました🛡️
| 仕組み | 理由 |
|---|---|
| 何も指定しなければ、ドライラン | 対象の件数と、変換したら何 MB 減るかだけを出す。書き込まない |
--apply には --backup-dir が必須 |
指定しないと、その場で止まる。元のファイルを手元に保存してから上書きする |
--limit で件数を絞れる |
まず数件だけ試して、表示を確かめてから全件に進む |
| 小さくならないものは飛ばす | 変換して逆に大きくなる場合や、上限を超える場合は上書きしない |
| 何度流しても同じ結果 | 2 回目は対象が 0 件になる。途中で止まっても、そのまま流し直せる |
「バックアップを取ってから実行してください」と手順に書くだけでは、いつか忘れます。
バックアップの場所を言わないと実行できない形にしておけば、忘れようがありません。
順番:直してから、変換し直す
実行の順番にも気をつけました。
1. 変換の修正を本番に出す
2. ドライランで、件数とサイズを確かめる
3. --limit 3 で数件だけ変換し、表示を確かめる
4. 全件を変換する
先に変換し直しても、修正を出す前なら、iPhone からの新しい投稿で同じ PNG がまた増えてしまいます。
蛇口を締めてから、こぼれた水を拭く順番です🚰
結果
本番で流した結果は、こうなりました。
| 項目 | 料理写真 | アイコン |
|---|---|---|
| 対象の件数 | 57 件 | 1 件 |
| 変換前の合計 | 224.38 MB(1 枚あたり平均 3.94 MB) | 0.44 MB |
| 変換後の合計 | 23.84 MB | 0.04 MB |
| 削減 | 89.4% | 91.7% |
約 224MB あった写真が、約 24MB になりました🎉
変換後の写真は大きいものでも 741KB で、目標の 1MB に全件収まっています。
1 枚あたり 4MB 近くあった写真が、400KB 前後になりました。
画像を縮小して配信するときに、元の写真を取りにいく量も、同じだけ小さくなります。
Instagram 用のカードで縮小が 4 秒に間に合わなかったのも、元がこの重さだったからでした。
なお、同じパスに上書きしても、CDN や画像最適化のキャッシュには古い中身がしばらく残ります。
表示が軽くなるのは、キャッシュが切れてからです。
サービスが駆け出しの今で、よかった
この問題は、投稿が増えるほど、黙って大きくなっていく種類のものでした。
今回の対象は 57 枚でした。
1 枚あたり、本来より 3.5MB ほど余計に場所を使っていた計算です。
仮に投稿が 1 万枚に増えてから気づいていたら、余計な分だけで 35GB 近くになっていたことになります😱
数が少ないうちに見つかったことで、直すのも楽でした。
- 変換し直すのは 58 件だけで、数件ずつ表示を確かめながら進められた
- バックアップも 224MB で、手元のパソコンにそのまま保存できた
- 影響を受けた人も、まだ少なかった
数が増えてからだと、変換にかかる時間も、バックアップの置き場所も、確かめる手間も、すべて桁が変わります。
サービスがまだ小さい今のうちに見つかって、本当によかったと思っています🍀
まとめ
-
canvas.toBlob()は、作れない形式を頼まれると、エラーにせず PNG を返す。iPhone の Safari では WebP を作れない📱 - 返ってきた Blob の
typeを確かめ、WebP でなければ JPEG で作り直す。ファイル名と型は、中身に合わせる - JPEG にするときは、透過を白で埋めてから。そうしないと透過が黒くなる
- 本番の画像を上書きするスクリプトは、ドライランが既定・バックアップが必須の形にする🛡️
「頼んだ形式で返ってくる」は、思い込みでした。
名乗りではなく中身を確かめるのは、画像に限らず、外から受け取るものすべてに言えることだと思います💡
シリーズの他の記事も、よろしければ📚
このシリーズでは、個人開発サービス「あじぴた」の設計をテーマごとに書いています。
サービスの全体像や、ほかの記事の一覧はハブ記事にまとめています👇
🔗 「低評価を公開しない」口コミサービスを個人開発した話 — 4,300コミット・6リポジトリの全体像
これまでに公開した記事です。
🔗 Google Places APIで月$1,440の請求が来る前に — 個人開発で従量課金を「呼ばない」8層の防壁
🔗 Next.js 16 で Web Vitals を測って直した実録!loading.tsx で LCP が 2 倍になった罠と関数リージョン
🔗 「全部見せるためのRLS」は書かない。Supabaseで管理画面だけRLSをバイパスした理由
🔗 ディレクトリ構成は「AIへの指示書」になる。Next.js App Routerで自分の設計論を答え合わせした話
🔗 漏洩してもエラーは出ない。非公開データをアプリではなくDBで守る、SupabaseのRLSを選んだ理由
🔗 RLSが壊れてもテストは緑のまま。漏れても気づけない非公開データを、pgTAPで「落ちるテスト」にする
🔗 CI全緑でも、マージしてはいけないPRがあった。GitHub Actionsは「どこで止めるか」で設計する
🔗 新人が初日に迷うことは、AIも毎回迷う。だから暗黙のルールを、すべて言葉にした
🔗 「失敗した」より「何もしていない」が怖い。Supabaseのpg_cronとpg_netで作る、静かな失敗に気づく監視
次の記事では、Instagram のアプリ内ブラウザから来た人が、ログインを完了できなかった話を書きました📲
🔗 広告から人は来るのに、登録がない。Instagramのアプリ内ブラウザとSupabaseのPKCEの相性を解いた話
設計の中身を通して読みたい方には、解剖ドキュメントも公開しています🔬
🔗 あじぴたの内側(ajipita-inside)
参考になれば幸いです🙏