0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

画像フォーマット変換ツールをブラウザだけで作る — PNG vs JPEG vs WebP vs AVIF の構造的な違い

0
Posted at

画像フォーマット変換はサーバーサイドの仕事だと思われがちだが、canvas.toBlob を使えばブラウザだけで PNG / JPEG / WebP / AVIF を相互変換できる。500 行で実装した。実装中に「4 つのフォーマットは MIME 文字列が違うだけじゃない、構造的に違うものを扱っている」ことが見えてきた: JPEG はそもそも alpha チャンネルを持たない (透明背景は flatten される)、WebP の quality=1.0 は lossless モードに切り替わる、AVIF の encode は実は Chromium 系しかサポートしていない、PNG は quality 引数を完全に無視する。記事ではこの「フォーマットごとの特性」と、それを 1 つの UI でどう見せるかを書く。

🌐 デモ: https://sen.ltd/portfolio/image-convert/
📦 GitHub: https://github.com/sen-ltd/image-convert

スクリーンショット

ブラウザだけで全部できる

「画像 A を WebP に変換したい」というニーズに対する答えはこれまで:

  • ImageMagick / FFmpeg をサーバーで叩く
  • sharp の Node サーバを建てる
  • オンラインツール (Squoosh など) を使う

実は canvas.toBlob を使えばブラウザだけで完結する。各フォーマットの encoder はブラウザに組み込まれている (Chrome / Edge は PNG / JPEG / WebP / AVIF 全部、Safari は AVIF 以外、Firefox も AVIF encode はベータ)。

canvas.toBlob(
  (blob) => { /* blob は変換後の Blob */ },
  "image/webp",   // 出力 MIME
  0.8,            // quality (lossy フォーマットのみ有効)
);

これ 1 行で WebP に変換できる。

フォーマットごとの「構造的な違い」

ただし、コードを書いてみると MIME 文字列を変えるだけでは正しく動かないことが分かる。各フォーマットには構造的な制約がある。

1. PNG: quality 引数は無視される

PNG はロスレスなので quality の概念がそもそも無い。canvas.toBlob(cb, "image/png", 0.5) と書いても 0.5 は無視される。同じキャンバスから何度 PNG を出力してもバイト単位で同一になる (predictor filter + DEFLATE は決定的アルゴリズム)。

2. JPEG: alpha チャンネルが無い

これが一番ハマるポイント。JPEG にはアルファチャンネルが無い。1992 年の仕様策定時、写真用途に焦点を絞った結果。

// 透明 PNG を JPEG に変換 → 透明部分はどうなる?
const canvas = drawTransparentImage();
canvas.toBlob((blob) => {
  // blob は JPEG だが、透明部分は "黒" になる (ブラウザによっては白)
}, "image/jpeg", 0.8);

Chrome は透明領域をで塗りつぶす。Safari は。挙動が違うので、自前で「JPEG 変換時の背景色」を指定する UI が必要:

// JPEG だけ別の canvas を使い、背景色で塗りつぶしてから drawImage
const canvasOpaque = document.createElement("canvas");
canvasOpaque.width = w; canvasOpaque.height = h;
const ctx = canvasOpaque.getContext("2d");
ctx.fillStyle = userPickedBgColor;
ctx.fillRect(0, 0, w, h);
ctx.drawImage(bitmap, 0, 0, w, h);

// 他のフォーマット用には透明背景のままの canvas を使う
const canvasTransparent = document.createElement("canvas");
// ... (背景なし)

createImageBitmap で 1 回 decode したら、その bitmap を 2 つの canvas に描き分ければ済む。bitmap の使い回しで decoding コストを節約。

3. WebP: quality=1.0 で lossless モード

WebP は lossy と lossless の両方をサポート。MIME はどちらも image/webp両者を切り替えるトリックは quality 引数の値:

  • quality < 1.0 → lossy WebP (VP8 イントラフレーム)
  • quality = 1.0 → lossless WebP (color cache + LZ77 + Huffman)

明示的に「lossless モードで」と指定する標準 API は無い。quality = 1.0 に頼るしかない。本ツールでは「WebP (lossless)」を別フォーマットとして登録し、内部で forceQuality: 1.0 を強制:

export const FORMATS = [
  // ...
  {
    id: "webp",
    mime: "image/webp",
    label: "WebP",
    lossy: true,
    forceQuality: undefined, // user slider を尊重
  },
  {
    id: "webp-lossless",
    mime: "image/webp",
    label: "WebP (lossless)",
    lossy: false,
    forceQuality: 1.0, // 強制 lossless
  },
];

WebP lossless は PNG より通常 20-30% 小さい。color cache (繰り返し色の予約スロット) と LZ77 (バイト列の前方参照) の組み合わせが、PNG の predictor filter よりも一般的に効率的。

4. AVIF: encode サポートが限定的

AVIF は AV1 video codec の still-frame。同じ視覚品質で WebP よりさらに 30-50% 小さい。decode は Safari 16+, Firefox 93+, Chrome 85+ で広くサポートされている。

でも encode は Chromium 系しかサポートしてない。Firefox は decode のみ、Safari の AVIF encode は未対応 (2026 年現在)。

canvas.toBlob(cb, "image/avif", 0.5) を Safari で呼ぶと: 仕様上は「null Blob」を返すべきだが、実際はPNG にフォールバックすることがある。これを検出するには、戻り値の Blob の type プロパティを検証:

canvas.toBlob((blob) => {
  if (!blob) {
    return null; // 純粋に拒否されたケース
  }
  if (blob.type !== "image/avif") {
    return null; // PNG フォールバックを拒否
  }
  return blob;
}, "image/avif", 0.5);

ブラウザは「失敗を hide する」傾向があるので、明示的に検証しないとユーザーに「AVIF に変換した」と嘘をついてしまう。

カタログ駆動の設計

5 つのフォーマットを 1 つのテーブルにまとめる:

export const FORMATS = [
  { id: "png",            mime: "image/png",  lossy: false, alpha: true,  notes: "..." },
  { id: "jpeg",           mime: "image/jpeg", lossy: true,  alpha: false, notes: "..." },
  { id: "webp",           mime: "image/webp", lossy: true,  alpha: true,  notes: "..." },
  { id: "webp-lossless",  mime: "image/webp", lossy: false, alpha: true,  forceQuality: 1.0 },
  { id: "avif",           mime: "image/avif", lossy: true,  alpha: true,  notes: "..." },
];

UI 側は forEach で全フォーマット encode を走らせるだけ:

export async function encodeAll(canvas, quality) {
  const out = {};
  for (const fmt of FORMATS) {
    const q = fmt.forceQuality ?? quality;
    const blob = await canvasToBlob(canvas, fmt.id, q);
    out[fmt.id] = blob
      ? { blob, size: blob.size, supported: true }
      : { blob: null, size: 0, supported: false };
  }
  return out;
}

サポートされてないフォーマットは「N/A」として並べる。これによってユーザーに「あ、Safari は AVIF 出力できないんだ」と教えるインタフェースになる。

ファイルサイズ比較 UX

5 つのフォーマットの結果をカード形式で並べ、それぞれに元画像との差分 % を出す:

export function sizeDelta(after, before) {
  if (before === 0) return { pct: 0, label: "" };
  const pct = ((after - before) / before) * 100;
  const sign = pct > 0 ? "+" : "";
  return { pct, label: `${sign}${pct.toFixed(1)}%` };
}

色は smaller (-X%) を緑、larger (+X%) を赤で。これで「PNG → WebP で -45%」「PNG → AVIF で -67%」が一目で見える。

例 (本ツールで自作の 800×600 PNG を変換した結果):

フォーマット size 元との差分
元 PNG 32.3 KB (baseline)
PNG (再 encode) 9.2 KB -71.5%
JPEG (q=0.82) 18.4 KB -43.0%
WebP (q=0.82) 6.5 KB -79.9%
WebP (lossless) 11.2 KB -65.3%
AVIF (q=0.55) N/A — (headless Chrome 環境)

WebP は同じ写真品質で JPEG より圧倒的に小さいことが視覚化できる。同時に、PNG を再 encode するだけでも (Canvas の default compression が違う) 大きく縮むケースもある。

ダウンスケール: max-dim と aspect 比保持

「2048px 以下にしたい」みたいなニーズも encode 前に処理:

export function computeFitSize(srcW, srcH, maxDim) {
  if (!maxDim || (srcW <= maxDim && srcH <= maxDim)) return null;
  const ratio = srcW > srcH ? maxDim / srcW : maxDim / srcH;
  return {
    width: Math.round(srcW * ratio),
    height: Math.round(srcH * ratio),
  };
}

長辺基準でリサイズ + 縦横比保持。null を返すケース (resize 不要) を分けることで、上位の bitmap → canvas 描画ルーチンが「resize 不要なら native size でそのまま描く」とシンプルに書ける。

テスト 40 個

core.js は DOM 非依存なので Node の node:test で全部テストできる:

test("PNG is lossless with alpha", () => {
  const png = getFormatById("png");
  assert.equal(png.lossy, false);
  assert.equal(png.alpha, true);
});

test("JPEG is lossy without alpha", () => {
  const jpeg = getFormatById("jpeg");
  assert.equal(jpeg.alpha, false);
});

test("WebP-lossless has forceQuality 1.0", () => {
  assert.equal(getFormatById("webp-lossless").forceQuality, 1.0);
});

test("AVIF preset is the lowest (codec is more efficient)", () => {
  assert.ok(QUALITY_PRESETS.avif < QUALITY_PRESETS.jpeg);
});

test("makeFilename: png → webp swaps extension", () => {
  assert.equal(makeFilename("photo.png", "webp"), "photo.webp");
});

test("computeFitSize landscape: width drives ratio", () => {
  assert.deepEqual(computeFitSize(1000, 500, 500), { width: 500, height: 250 });
});

カタログ駆動の良さ: 各フォーマットの構造的特性 (lossy / alpha / forceQuality) がコード内のテーブルそのものになっているので、テストが「カタログを宣言として読む」形で書ける。

設計

core.js     ← フォーマットカタログ + 数値ヘルパー (DOM-free, 40 tests)
convert.js  ← Canvas pipeline (createImageBitmap + toBlob)
app.js      ← UI glue + drag-and-drop

core.js は完全に DOM 非依存。convert.js だけがブラウザ専用 (createImageBitmap, canvas, toBlob)。分離することでテスト可能なロジック層と、テスト不可能な I/O 層が明確に分かれる

試してみる

透明 PNG (アイコンとか) をドロップして JPEG に変換してみてほしい。透明部分がどう塗りつぶされるかが視覚的にわかる。WebP / AVIF が JPEG よりどれだけ小さいかも、同じ画像で並ぶと一目瞭然。

まとめ

  • canvas.toBlob(cb, mime, quality) でブラウザだけで全主要フォーマットに変換できる。
  • PNG は quality 引数を無視する。ロスレスだから当然。
  • JPEG には alpha チャンネルが無い。透明背景は flatten される (ブラウザによって色が違うので明示制御が必要)。
  • WebP の quality=1.0 は lossless モードに切り替わる暗黙の挙動。明示的に「lossless 指定」する標準 API は無い。
  • AVIF encode は Chromium のみ広くサポート。Safari/Firefox では null Blob を返すか PNG フォールバックする。戻り値の .type 検証が必要。
  • カタログ駆動にすると、各フォーマットの構造的特性をテーブルで宣言できて、UI とテストが同じデータソースを読める。
  • DOM-free な core.js と browser-only な convert.js を分離することで、ロジック層を 40 個の Node テストでカバーできる。

これは SEN 合同会社の OSS ポートフォリオ #258 です。https://sen.ltd/portfolio/

0
0
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
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?