aboutsummaryrefslogtreecommitdiffhomepage
path: root/scripts/optimize-images.mjs
blob: 8e9194a78e73837bde7bff523d8ccd8b4d5eed9c (plain)
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);
}