aboutsummaryrefslogtreecommitdiffhomepage
path: root/scripts
diff options
context:
space:
mode:
Diffstat (limited to 'scripts')
-rw-r--r--scripts/optimize-images.mjs100
1 files changed, 100 insertions, 0 deletions
diff --git a/scripts/optimize-images.mjs b/scripts/optimize-images.mjs
new file mode 100644
index 0000000..8e9194a
--- /dev/null
+++ b/scripts/optimize-images.mjs
@@ -0,0 +1,100 @@
+// 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);
+}