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時に
sharpとmozjpegを通して画像を圧縮。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);
});