// Image pipeline: img/projects sources -> dist/assets/img/projects// // Variants per photo: 800w + 1600w WebP, 1200w JPEG fallback. Never upscales. // Writes src/data/images.json manifest with final dimensions per variant. // Usage: npm run images (re-run only when photos change; build.js just reads the manifest) const fs = require('fs'); const path = require('path'); const sharp = require('sharp'); const ROOT = path.join(__dirname, '..'); const OUT = path.join(ROOT, 'dist', 'assets', 'img'); const projects = JSON.parse(fs.readFileSync(path.join(ROOT, 'src', 'data', 'projects.json'), 'utf8')); const VARIANTS = [ { width: 800, format: 'webp', options: { quality: 78 } }, { width: 1600, format: 'webp', options: { quality: 74 } }, { width: 1200, format: 'jpeg', options: { quality: 72, mozjpeg: true } }, ]; async function processOne(slug, srcRel, index) { const srcAbs = path.join(ROOT, srcRel); if (!fs.existsSync(srcAbs)) return { srcRel, error: 'missing source' }; const dir = path.join(OUT, 'projects', slug); fs.mkdirSync(dir, { recursive: true }); const base = sharp(srcAbs).rotate(); // respect EXIF orientation const meta = await base.metadata(); const srcW = meta.autoOrient ? meta.autoOrient.width : meta.width; const srcH = meta.autoOrient ? meta.autoOrient.height : meta.height; const name = String(index + 1).padStart(2, '0'); const entry = { source: srcRel, width: srcW, height: srcH, variants: [] }; for (const v of VARIANTS) { const w = Math.min(v.width, srcW); const file = `${name}-${v.width}.${v.format === 'jpeg' ? 'jpg' : v.format}`; const outPath = path.join(dir, file); if (!fs.existsSync(outPath)) { await base.clone().resize({ width: w, withoutEnlargement: true })[v.format](v.options).toFile(outPath); } const h = Math.round(srcH * (w / srcW)); entry.variants.push({ file: `assets/img/projects/${slug}/${file}`, width: w, height: h, format: v.format }); } return entry; } (async () => { const manifest = {}; let done = 0, failed = 0; const total = projects.reduce((n, p) => n + p.images.length, 0); for (const p of projects) { manifest[p.slug] = []; // limited parallelism: sharp saturates cores on its own thread pool const queue = p.images.map((src, i) => () => processOne(p.slug, src, i)); while (queue.length) { const batch = queue.splice(0, 4).map(fn => fn()); for (const r of await Promise.all(batch)) { if (r.error) { failed++; console.error('MISSING:', r.source || r.srcRel); } manifest[p.slug].push(r); done++; } } console.log(`${p.slug}: ${manifest[p.slug].length} images (${done}/${total})`); } fs.writeFileSync(path.join(ROOT, 'src', 'data', 'images.json'), JSON.stringify(manifest, null, 1)); console.log(`DONE. processed=${done} failed=${failed}`); })();