95 lines
3.7 KiB
JavaScript
95 lines
3.7 KiB
JavaScript
#!/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 <img> 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 <img> 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);
|
|||
|
|
});
|