// public/ の画像を安全に最適化するスクリプト。 // // 方針(背景): // Astro(sharo/libwebp) の既定は「写真向けの非可逆WebP」なので、既に最適化済みの // パレットPNG(ディザあり)やロスレスWebPをWebP化すると、かえって肥大化する。 // そのため public/ では次を行う: // - JPEG … 同一拡張子のまま再圧縮し、15%以上小さくなる場合だけ置換する(安全・冪等) // - PNG/WebP … 「WebP化でどれだけ小さくなるか」をレポートするだけ // (拡張子が変わると参照URLが壊れるため、自動では変換しない。Astro移行で対応する) // // 使い方: // node scripts/optimize-images.mjs # 判定のみ(書き換えない) // node scripts/optimize-images.mjs --apply # JPEGを実際に最適化 // // `npm run build` の prebuild で --apply が自動実行される。 import { readdirSync, statSync } from "node:fs"; import { readFile, writeFile } from "node:fs/promises"; import path from "node:path"; import sharp from "sharp"; const APPLY = process.argv.includes("--apply"); const ROOTS = ["public"]; const EXCLUDE_DIRS = [path.join("public", "bluebey-studio")]; const EXTS = new Set([".jpg", ".jpeg", ".png", ".webp"]); const MIN_SIZE = 15000; // 15KB未満は対象外 const MIN_GAIN = 0.15; // 15%以上小さくなる場合だけ置換 const WEBP_SUGGEST_GAIN = 0.2; // WebP移行候補として報告する閾値 function* walk(dir) { for (const e of readdirSync(dir, { withFileTypes: true })) { const p = path.join(dir, e.name); if (e.isDirectory()) { if (EXCLUDE_DIRS.includes(p)) continue; yield* walk(p); } else { yield p; } } } // Windows では sharp が入力ファイルのハンドルを保持すると、同一パスへの書き込みが // ロックされる。そのため、先にバッファへ読み込んでから変換する。 async function encodeJpeg(buf) { return sharp(buf, { failOn: "none" }).jpeg({ quality: 82, mozjpeg: true }).toBuffer(); } async function encodeWebp(buf) { return sharp(buf, { failOn: "none" }).webp({ quality: 80 }).toBuffer(); } let checked = 0; let changed = 0; let saved = 0; const suggestions = []; for (const root of ROOTS) { for (const file of walk(root)) { const ext = path.extname(file).toLowerCase(); if (!EXTS.has(ext)) continue; const size = statSync(file).size; if (size < MIN_SIZE) continue; checked++; try { const input = await readFile(file); // 形式変換(WebP)の効果を測る(自動適用はしない) if (ext !== ".webp") { const w = await encodeWebp(input); if (w.length < size * (1 - WEBP_SUGGEST_GAIN)) { suggestions.push( `${file} (${(size / 1024).toFixed(0)}K -> webp ${(w.length / 1024).toFixed(0)}K)`, ); } } // JPEGは同一拡張子で安全に再圧縮 if (ext === ".jpg" || ext === ".jpeg") { const j = await encodeJpeg(input); const gain = 1 - j.length / size; if (gain >= MIN_GAIN) { if (APPLY) await writeFile(file, j); changed++; saved += size - j.length; console.log( `${APPLY ? "OPT" : "would"} ${(size / 1024).toFixed(0)}K -> ${(j.length / 1024).toFixed(0)}K ${file}`, ); } } } catch (e) { console.log("ERR", file, e.message); } } } console.log( `\nchecked=${checked} jpegOptimized=${changed} saved=${(saved / 1024).toFixed(0)}KB${APPLY ? "" : " (dry-run)"}`, ); if (suggestions.length) { console.log( `\n[WebP移行候補 ${suggestions.length}件](拡張子が変わるため自動変換しません。Astro移行を検討)`, ); for (const s of suggestions) console.log(" " + s); }