#!/usr/bin/env node /** * Derivatives for the landing hero artwork. * * node scripts/build-hero-images.js * * Source of truth is assets/hero-source.png (1672×941). It is committed so * these can be regenerated: never hand-edit anything in public/brand/hero-*, * change the source or the numbers here and re-run. * * TWO CROPS, ON PURPOSE * --------------------- * The hero slot is not one shape. On desktop the artwork sits behind the whole * panel at roughly 2.1:1 → 2.7:1; below the one-column breakpoint it collapses * to a band between 2.6:1 and 4.9:1. A single file under `object-fit: cover` * centre-crops, which at the extremes slices a strip through the middle of the * scene and loses the schoolhouse — the same failure the previous SVG hero had. * * So: * wide the full frame, including the empty cream area the artwork reserves * on the left for the headline. Used behind the desktop panel. * band a crop around the schoolhouse and the path, already near 2.6:1, so * the narrow band barely crops it at all. The school sits at ~65% * across, which is why the CSS pairs this with object-position: 65%: * squeezing further toward 4.9:1 crops the empty sides, not the subject. * * AVIF and WebP are both emitted. This is flat-shaded vector-style artwork, so * AVIF lands roughly an order of magnitude under the source PNG. Both crops * also get a JPEG: the wide one backs the , and the band one backs a * — without it a browser taking neither modern format would * fall through to the desktop frame on a phone, since cannot vary by * viewport. * * sharp is declared in devDependencies rather than leaned on as a transitive * dependency of next. It does currently arrive that way, but relying on that * means a Next upgrade can silently break this script — the same reasoning as * the outputFileTracingIncludes note in next.config.js. */ const path = require('path'); const fs = require('fs'); const sharp = require('sharp'); const SRC = path.join(__dirname, '..', 'assets', 'hero-source.png'); const OUT = path.join(__dirname, '..', 'public', 'brand'); /* * Band crop, in source pixels, measured against the artwork rather than * guessed: the schoolhouse spans roughly x 1420–1860, y 420–660, with its flag * up at y 330. * * The crop is flush to the right edge because it has to be. The school sits at * about 82% across the frame, so there is only ~140px of artwork to its right * — not enough to centre it. Anchoring right and taking 1050px back puts the * school at ~66% across the crop, which is what BAND_FOCUS_X below reports to * the CSS so the narrowest band crops the empty left side rather than the * subject. */ const BAND = { left: 656, top: 300, width: 1344, height: 420 }; /** Where the schoolhouse lands in the band crop, for CSS object-position. */ const BAND_FOCUS_X = Math.round(((1420 + 1860) / 2 - BAND.left) / BAND.width * 100); const WIDE_WIDTHS = [2000, 1400, 1000]; const BAND_WIDTHS = [1344, 900, 600]; async function emit(pipeline, name, width) { const base = pipeline.clone().resize({ width, withoutEnlargement: true }); await base.clone().avif({ quality: 62, effort: 6 }).toFile(path.join(OUT, `${name}-${width}.avif`)); await base.clone().webp({ quality: 80, effort: 6 }).toFile(path.join(OUT, `${name}-${width}.webp`)); return width; } async function main() { if (!fs.existsSync(SRC)) throw new Error(`missing source: ${SRC}`); fs.mkdirSync(OUT, { recursive: true }); const meta = await sharp(SRC).metadata(); console.log(`source ${meta.width}×${meta.height}`); console.log(`band focus x: ${BAND_FOCUS_X}% (keep .heroArt object-position in step)`); const wide = sharp(SRC); for (const w of WIDE_WIDTHS) await emit(wide, 'hero-wide', w); // Fallback for the itself. Flattened onto the artwork's own cream so a // JPEG (no alpha) can never composite against black. await sharp(SRC) .resize({ width: 1400 }) .flatten({ background: '#FEF2E1' }) .jpeg({ quality: 82, mozjpeg: true }) .toFile(path.join(OUT, 'hero-wide-1400.jpg')); const band = sharp(SRC).extract(BAND); for (const w of BAND_WIDTHS) await emit(band, 'hero-band', w); await sharp(SRC) .extract(BAND) .resize({ width: 900 }) .flatten({ background: '#FEF2E1' }) .jpeg({ quality: 82, mozjpeg: true }) .toFile(path.join(OUT, 'hero-band-900.jpg')); const files = fs.readdirSync(OUT).filter(f => f.startsWith('hero-')).sort(); let total = 0; for (const f of files) { const { size } = fs.statSync(path.join(OUT, f)); total += size; console.log(` ${f.padEnd(24)} ${(size / 1024).toFixed(1)} kB`); } console.log(`total ${(total / 1024).toFixed(1)} kB across ${files.length} files`); } main().catch(err => { console.error(err); process.exit(1); });