#!/usr/bin/env node /** * CRAY CRITTER — Responsive image optimizer * ============================================================ * Walks every source photo under ROOT and, for each one, writes * resized WebP variants alongside it: * * lips_1.jpg -> lips_1-320.webp * lips_1-480.webp * lips_1-640.webp * lips_1-960.webp * lips_1-1280.webp * lips_1-1600.webp * * Skips a variant if it already exists and is newer than the * source, so re-runs are cheap. Never touches the originals. * * Usage: * npm i -D sharp * node optimize-images.mjs # default ROOT below * node optimize-images.mjs ./public/assets/projects * ============================================================ */ import { readdir, stat, access } from "node:fs/promises"; import { constants } from "node:fs"; import path from "node:path"; import sharp from "sharp"; // Point this at the folder that actually holds /assets/projects on disk. const ROOT = process.argv[2] || "./assets/projects"; // Widths to emit. A card on mobile needs ~320–480; a modal on a // retina desktop wants up to ~1600. The browser picks per `sizes`. const WIDTHS = [320, 480, 640, 960, 1280, 1600]; const QUALITY = 74; // WebP quality — 70–78 is the sweet spot for photos const SOURCE_EXT = new Set([".jpg", ".jpeg", ".png"]); let made = 0; let skipped = 0; let failed = 0; async function isFresh(srcPath, outPath) { try { await access(outPath, constants.F_OK); const [s, o] = await Promise.all([stat(srcPath), stat(outPath)]); return o.mtimeMs >= s.mtimeMs; } catch { return false; } } async function processImage(srcPath) { const dir = path.dirname(srcPath); const base = path.basename(srcPath, path.extname(srcPath)); let meta; try { meta = await sharp(srcPath).metadata(); } catch (err) { console.error(` ✗ unreadable: ${srcPath} (${err.message})`); failed++; return; } for (const w of WIDTHS) { // Never upscale past the source's real width. if (meta.width && w > meta.width) continue; const outPath = path.join(dir, `${base}-${w}.webp`); if (await isFresh(srcPath, outPath)) { skipped++; continue; } try { await sharp(srcPath) .rotate() // respect EXIF orientation .resize({ width: w, withoutEnlargement: true }) .webp({ quality: QUALITY }) .toFile(outPath); made++; } catch (err) { console.error(` ✗ ${outPath}: ${err.message}`); failed++; } } } async function walk(dir) { let entries; try { entries = await readdir(dir, { withFileTypes: true }); } catch (err) { console.error(`Cannot read ${dir}: ${err.message}`); return; } for (const entry of entries) { const full = path.join(dir, entry.name); if (entry.isDirectory()) { await walk(full); } else if ( SOURCE_EXT.has(path.extname(entry.name).toLowerCase()) && // Don't re-process our own output. !/-\d+\.webp$/.test(entry.name) ) { console.log(`• ${full}`); await processImage(full); } } } console.log(`Optimizing images under: ${ROOT}\n`); await walk(ROOT); console.log( `\nDone. ${made} written, ${skipped} up-to-date, ${failed} failed.`, );