me

@hosi_mo

Ryosuke Nishida

テクニカルディレクター @ WFS
C++ / C# | Game Development

料理と相撲と風呂がすき

Astro | ブログ画像をpre-commit時に圧縮する


このブログの画像のほとんどはデジタルカメラで撮影したもので、オリジナルサイズはそれなりに大きい。
台湾の旅行記(1,2,3)では写真が59枚、現像したての生のjpegで500MB弱(!)


githubのrepositoryにアップロードするにはファイルサイズが大きいので、gitのhookでimagemagickを叩いて簡易的にリサイズさせてはいたが、もう少しモダンなやり方に変更したい。

そもそもimagemagickは遅いし、環境依存も解消したいところ。
AIに実装と構成の相談をしつつ、全体的に見直しをしてみた

圧縮の流れ

  • 現像後の高解像度写真: デジタルカメラやLightroomから書き出したJPEG。1枚5–15MBほど
  • pre-commit hook: gitへのcommit時に sharpmozjpegを通して画像を圧縮。200KB–1MBほどにサイズ減
  • 配信時: Astroのビルドにより、CloudFlareのデプロイ時にWebPに変換して70–500KBにサイズダウン


全部npm経由でインストールできるので、git clone からの npm installだけでセットアップが完結する。
別マシンにImageMagick を入れて回す、といった環境依存が発生しなくなる構成となった

sharp

Node.js の画像処理ライブラリ。ImageMagick より速い
Astroのビルド時のWebP変換にも内部で使われているらしい?

mozjpeg

Mozilla製のJPEGエンコーダ。ファイルサイズ20–30%減が見込める
sharp に mozjpeg: true を渡すだけで切り替えられる。
ブログのレイアウト的に横幅2560px、qualityはclaude codeがおすすめする82とした。

sharp(input, { failOn: 'truncated' })
  .rotate()
  .resize({ width: 2560, height: 2560, fit: 'inside', withoutEnlargement: true })
  .keepExif()
  .keepIccProfile()
  .jpeg({ quality: 82, mozjpeg: true, progressive: true })
  .toBuffer();

lefthook

Goで書かれたgit hookランナー。設定は lefthook.ymlを書くだけ
gitのhookは今まで生で書いていたけど、これはたしかに便利かもしれない

pre-commit:
  parallel: true
  skip:
    - merge
    - rebase
  jobs:
    - name: optimize-images
      glob: "src/assets/**/*.{jpg,jpeg,png,JPG,JPEG,PNG}"
      run: node scripts/optimize-images.mjs --max-dimension 2560 --jpeg-quality 82 {staged_files}
      stage_fixed: true

stage_fixed: true で、スクリプトが書き換えたファイルが stagingに反映される。
package.json"prepare": "lefthook install" を入れておくだけなので環境依存もなし

結果

台湾旅行の写真 59 枚: 441MB → 22MB (-95%)
さらにAstroビルド時にWebPに変換されるので、配信時は全体で14MB。1枚あたり70–500KBほどにサイズダウン。
commit時の処理で圧縮時間は約8秒ほど。
imagemagickよりも劇的に速くなった

おまけ

ブログにまぎれていたiPhone撮影の写真にGPSタグがまるごと入っていた(!)
Twitterなどはアップロード時に自動で消してくれるけど、自前のブログだとこの辺り自分で消さなければならないので注意。
(pre-commit時にGPS情報も一括で消すようにスクリプトを仕込んでおいた



Astroで自前運用している方々は、過去投稿を一度チェックすることをおすすめする。



スクリプトのソースコード

scripts/optimize-images.mjs
#!/usr/bin/env node
//
// 画像最適化スクリプト
//
// sharp + mozjpeg で画像を最大辺 N px に縮小・再エンコードする。
// JPEG の GPS タグはデフォルトで除去 (privacy 対策)。
// pre-commit hook と手動バッチ実行の両用途を想定。
//
// 使い方:
//   node scripts/optimize-images.mjs [options] [path...]
//
//   path:
//     ファイルを指定したら、そのファイルだけ処理 (lefthook の {staged_files} 用)
//     ディレクトリを指定したら再帰的に走査
//     何も指定しなければ --scan-dir のデフォルトを走査
//
// Options:
//   --max-dimension <px>    最大辺 px (default: 2560)
//   --jpeg-quality <0-100>  JPEG 品質 (default: 82)
//   --png-quality <0-100>   PNG 品質 (default: 80)
//   --no-mozjpeg            mozjpeg ではなく libjpeg-turbo を使う
//   --keep-gps              JPEG の GPS タグを残す (default: 除去)
//   --scan-dir <path>       path 引数なし時の走査対象 (default: src/assets)
//   --concurrency <n>       並列度 (default: min(4, CPU 数))
//   --dry-run               書き換えずに結果のみ表示
//   -h, --help              ヘルプを表示
//
// 動作:
//   - 最大辺 N px に縮小 (拡大はしない)
//   - JPEG は mozjpeg / progressive で再エンコード
//   - PNG は imagequant による lossy 圧縮 + 最大圧縮
//   - EXIF Orientation を実画素に焼き込み
//   - JPEG メタデータ: EXIF と ICC を保持、XMP/IPTC は削除、GPS は --keep-gps 指定がない限り削除
//   - 再エンコード後のサイズが大きくなる場合は書き換えない (累積劣化防止)
//

import sharp from 'sharp';
import piexif from 'piexifjs';
import { readFile, writeFile, readdir, stat } from 'node:fs/promises';
import path from 'node:path';
import os from 'node:os';
import { parseArgs } from 'node:util';
import { fileURLToPath } from 'node:url';

const TARGET_EXTENSIONS = new Set(['.jpg', '.jpeg', '.png']);
const DEFAULTS = {
	maxDimension: 2560,
	jpegQuality: 82,
	pngQuality: 80,
	scanDir: 'src/assets',
};

const __dirname = path.dirname(fileURLToPath(import.meta.url));
const projectRoot = path.resolve(__dirname, '..');

function printHelp() {
	console.log(`画像最適化スクリプト

使い方:
  node scripts/optimize-images.mjs [options] [path...]

Options:
  --max-dimension <px>    最大辺 px (default: ${DEFAULTS.maxDimension})
  --jpeg-quality <0-100>  JPEG 品質 (default: ${DEFAULTS.jpegQuality})
  --png-quality <0-100>   PNG 品質 (default: ${DEFAULTS.pngQuality})
  --no-mozjpeg            mozjpeg ではなく libjpeg-turbo を使う
  --keep-gps              JPEG の GPS タグを残す
  --scan-dir <path>       path 引数なし時に走査するディレクトリ (default: ${DEFAULTS.scanDir})
  --concurrency <n>       並列度 (default: min(4, CPU 数))
  --dry-run               書き換えずに結果のみ表示
  -h, --help              ヘルプを表示`);
}

function parseIntInRange(raw, name, min, max) {
	const n = Number.parseInt(raw, 10);
	const ok = Number.isFinite(n) && n >= min && (max === undefined || n <= max);
	if (!ok) {
		const range = max === undefined ? `${min} 以上` : `${min}-${max}`;
		console.error(`エラー: --${name} は ${range} の整数を指定してください`);
		process.exit(2);
	}
	return n;
}

function parseConfig() {
	const { values, positionals } = parseArgs({
		options: {
			'max-dimension': { type: 'string', default: String(DEFAULTS.maxDimension) },
			'jpeg-quality':  { type: 'string', default: String(DEFAULTS.jpegQuality) },
			'png-quality':   { type: 'string', default: String(DEFAULTS.pngQuality) },
			'no-mozjpeg':    { type: 'boolean', default: false },
			'keep-gps':      { type: 'boolean', default: false },
			'scan-dir':      { type: 'string', default: DEFAULTS.scanDir },
			'concurrency':   { type: 'string' },
			'dry-run':       { type: 'boolean', default: false },
			'help':          { type: 'boolean', short: 'h', default: false },
		},
		allowPositionals: true,
	});

	if (values.help) {
		printHelp();
		process.exit(0);
	}

	const defaultConcurrency = Math.min(4, Math.max(1, os.cpus().length));

	return {
		paths:        positionals,
		maxDimension: parseIntInRange(values['max-dimension'], 'max-dimension', 1),
		jpegQuality:  parseIntInRange(values['jpeg-quality'],  'jpeg-quality',  1, 100),
		pngQuality:   parseIntInRange(values['png-quality'],   'png-quality',   1, 100),
		mozjpeg:      !values['no-mozjpeg'],
		stripGps:     !values['keep-gps'],
		scanDir:      values['scan-dir'],
		concurrency:  values.concurrency
			? parseIntInRange(values.concurrency, 'concurrency', 1)
			: defaultConcurrency,
		dryRun:       values['dry-run'],
	};
}

function formatBytes(bytes) {
	if (bytes < 1024) return `${bytes}B`;
	if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)}KB`;
	return `${(bytes / 1024 / 1024).toFixed(2)}MB`;
}

function isTarget(filePath) {
	return TARGET_EXTENSIONS.has(path.extname(filePath).toLowerCase());
}

async function collectFromDirectory(dirPath) {
	const entries = await readdir(dirPath, { recursive: true, withFileTypes: true });
	const files = [];
	for (const entry of entries) {
		if (!entry.isFile()) continue;
		if (!isTarget(entry.name)) continue;
		const parent = entry.parentPath ?? entry.path;
		files.push(path.join(parent, entry.name));
	}
	return files;
}

// JPEG バッファから GPS と古いサムネイルを除去 (EXIF 本体は保持)
function stripGpsFromJpegBuffer(buffer) {
	const binaryData = buffer.toString('binary');
	let exifObj;
	try {
		exifObj = piexif.load(binaryData);
	} catch {
		return { buffer, hadGps: false };
	}

	const hadGps = exifObj.GPS && Object.keys(exifObj.GPS).length > 0;
	delete exifObj.GPS;
	if (exifObj['0th']) {
		delete exifObj['0th'][piexif.ImageIFD.GPSTag];
		// rotate() で物理回転を焼き込むため Orientation は Normal に固定
		exifObj['0th'][piexif.ImageIFD.Orientation] = 1;
	}
	delete exifObj['1st'];
	delete exifObj.thumbnail;

	try {
		const exifBytes = piexif.dump(exifObj);
		const stripped = piexif.insert(exifBytes, binaryData);
		return { buffer: Buffer.from(stripped, 'binary'), hadGps };
	} catch {
		return { buffer, hadGps: false };
	}
}

async function optimizeFile(filePath, config) {
	const ext = path.extname(filePath).toLowerCase();
	const input = await readFile(filePath);
	const beforeSize = input.length;

	let pipeline = sharp(input, { failOn: 'truncated' })
		.rotate()
		.resize({
			width: config.maxDimension,
			height: config.maxDimension,
			fit: 'inside',
			withoutEnlargement: true,
		});

	if (ext === '.png') {
		pipeline = pipeline.keepMetadata().png({
			quality: config.pngQuality,
			effort: 10,
			compressionLevel: 9,
		});
	} else {
		// JPEG: EXIF (撮影情報) と ICC (色) は残す。XMP / IPTC はドロップ
		pipeline = pipeline.keepExif().keepIccProfile().jpeg({
			quality: config.jpegQuality,
			mozjpeg: config.mozjpeg,
			progressive: true,
		});
	}

	let output = await pipeline.toBuffer();
	let gpsStripped = false;

	if (ext !== '.png' && config.stripGps) {
		const result = stripGpsFromJpegBuffer(output);
		output = result.buffer;
		gpsStripped = result.hadGps;
	}

	const afterSize = output.length;
	if (afterSize >= beforeSize) {
		return { status: 'skip', beforeSize, afterSize, gpsStripped };
	}

	if (!config.dryRun) {
		await writeFile(filePath, output);
	}
	return {
		status: config.dryRun ? 'dryrun' : 'optimized',
		beforeSize,
		afterSize,
		gpsStripped,
	};
}

async function processWithConcurrency(items, limit, worker) {
	let cursor = 0;
	const runners = Array.from({ length: limit }, async () => {
		while (true) {
			const index = cursor++;
			if (index >= items.length) return;
			await worker(items[index], index);
		}
	});
	await Promise.all(runners);
}

async function resolveInputs(paths, scanDir) {
	if (paths.length === 0) {
		const dir = path.resolve(projectRoot, scanDir);
		console.log(`${scanDir} を走査中...`);
		return collectFromDirectory(dir);
	}
	const out = [];
	for (const arg of paths) {
		const resolved = path.resolve(arg);
		try {
			const s = await stat(resolved);
			if (s.isDirectory()) {
				out.push(...(await collectFromDirectory(resolved)));
			} else if (isTarget(resolved)) {
				out.push(resolved);
			}
		} catch (err) {
			console.error(`✗ ${arg}: ${err.message}`);
		}
	}
	return out;
}

async function main() {
	const config = parseConfig();
	const files = await resolveInputs(config.paths, config.scanDir);

	if (files.length === 0) {
		console.log('対象画像なし');
		return;
	}

	console.log(
		`${files.length} 件の画像を処理${config.dryRun ? ' (dry-run)' : ''}します (並列度 ${config.concurrency})`
	);

	let totalBefore = 0;
	let totalAfter = 0;
	let optimizedCount = 0;
	let skipCount = 0;
	let errorCount = 0;

	await processWithConcurrency(files, config.concurrency, async (file) => {
		const rel = path.relative(projectRoot, file);
		try {
			const result = await optimizeFile(file, config);
			totalBefore += result.beforeSize;
			totalAfter += result.afterSize;
			const gpsTag = result.gpsStripped ? ' [GPS除去]' : '';
			if (result.status === 'skip') {
				skipCount++;
				console.log(
					`- ${rel}  スキップ (再圧縮で増加: ${formatBytes(result.beforeSize)})${gpsTag}`,
				);
				return;
			}
			optimizedCount++;
			const saved = result.beforeSize - result.afterSize;
			const pct = ((saved / result.beforeSize) * 100).toFixed(0);
			const prefix = result.status === 'dryrun' ? '~' : '✓';
			console.log(
				`${prefix} ${rel}  ${formatBytes(result.beforeSize)} → ${formatBytes(result.afterSize)}  (-${pct}%)${gpsTag}`,
			);
		} catch (err) {
			errorCount++;
			console.error(`✗ ${rel}  エラー: ${err.message}`);
		}
	});

	const totalSaved = totalBefore - totalAfter;
	const totalPct =
		totalBefore > 0 ? ((totalSaved / totalBefore) * 100).toFixed(1) : '0';
	console.log('');
	console.log(
		`完了: ${optimizedCount} 件最適化 / ${skipCount} 件スキップ / ${errorCount} 件エラー`,
	);
	console.log(
		`合計: ${formatBytes(totalBefore)} → ${formatBytes(totalAfter)} (-${totalPct}%)`,
	);

	if (errorCount > 0) process.exitCode = 1;
}

main().catch((err) => {
	console.error('致命的エラー:', err);
	process.exit(1);
});