0
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

名前はWebP、中身はPNG。Safariで写真が5MBに膨らむ罠と、バックアップ必須のスクリプトで本番を直すまで

0
Last updated at Posted at 2026-10-21

はじめに

🤔「画像は 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)

参考になれば幸いです🙏

0
1
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?