画像フォーマット変換はサーバーサイドの仕事だと思われがちだが、
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 では
nullBlob を返すか PNG フォールバックする。戻り値の.type検証が必要。 - カタログ駆動にすると、各フォーマットの構造的特性をテーブルで宣言できて、UI とテストが同じデータソースを読める。
- DOM-free な core.js と browser-only な convert.js を分離することで、ロジック層を 40 個の Node テストでカバーできる。
これは SEN 合同会社の OSS ポートフォリオ #258 です。https://sen.ltd/portfolio/
