#!/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; the JPEG is * only the fallback and is never served to a current browser. */ 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. The schoolhouse occupies roughly x 1180–1540, * y 300–520, so this keeps it whole with headroom above the flag and enough * hillside below to read as landscape rather than as a cutout. */ const BAND = { left: 700, top: 260, width: 972, height: 380 }; const WIDE_WIDTHS = [1672, 1200, 900]; const BAND_WIDTHS = [972, 700, 500]; 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}`); 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: 1200 }) .flatten({ background: '#FDF9F3' }) .jpeg({ quality: 82, mozjpeg: true }) .toFile(path.join(OUT, 'hero-wide-1200.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: 700 }) .flatten({ background: '#FDF9F3' }) .jpeg({ quality: 82, mozjpeg: true }) .toFile(path.join(OUT, 'hero-band-700.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); });