1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
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);
}
|