/** * Regenerate the share cards. * * node scripts/brand-assets/capture.mjs * * Two passes, because the cards are backed by the running app rather than by a * drawing of it. First it shoots the city and the office out of a built `dist/`; * then it renders `og.html` over those shots at exactly 1200x630 and writes the * two PNGs into `public/`, from where Vite copies them verbatim. * * Rasterising with headless Chromium rather than a converter is the convention * `lumbridge-v4/scripts/brand-assets/README.md` already set on this box, for the * reason it gives: there is no ImageMagick, no `rsvg-convert` and no `sharp` * here, and a browser renders the CSS the card was designed in anyway. * * ### Why the art is a screenshot and not an illustration * * Because the thing is worth looking at, and because an illustration of it goes * stale silently. The card that shipped on lumbridgecorp.com was a viewport * screenshot of a marketing page that had since been rewritten, so the preview * advertised a positioning the site no longer used and nothing noticed for a * month. A card regenerated from `dist/` by one command is a card that can be * kept true by running that command. */ import { fileURLToPath } from "node:url"; import { dirname, join } from "node:path"; import { serve, launch, clockShim, FURNITURE, hide } from "./harness.mjs"; const HERE = dirname(fileURLToPath(import.meta.url)); const ROOT = join(HERE, "..", ".."); const PUBLIC = join(ROOT, "public"); /** * The hour is chosen per card, and it is not midday. * * The sun is real — `observe()` computes it from `new Date()` — so a card * regenerated at two in the morning is an honest photograph of a black * rectangle. The clock is shifted rather than frozen because the app drives * everything else off `requestAnimationFrame`, and a stopped clock stalls the * frame loop the screenshot is waiting on. * * Both cards used to be shot at 12:40, which is the worst light a heightfield * ever gets: the sun is behind the camera, nothing casts, and the state board * reads as one flat sheet of sand. Late afternoon rakes the Sierra and the Basin * and Range and separates the Central Valley from both. The office keeps a * late-morning sun because an interior wants light coming *through* the glazing, * and at 17:30 an office lit from one low angle is half a photograph of a wall. */ const AFTERNOON = "2026-08-06T17:30:00-07:00"; const LATE_MORNING = "2026-08-06T11:20:00-07:00"; /** * Wait for the app rather than for a clock. * * `#boot` hidden **and** `#chapters` populated. Terrain is built in a worker and * the Bay is ~83k instances; how long that takes depends on the machine and what * else is running, so the twenty-second sleep this replaced was either wrong or * wasteful and was usually both. */ async function ready(page) { await page.waitForFunction( () => document.getElementById("boot")?.hidden === true && document.querySelectorAll("#chapters .chapter").length > 0, undefined, { timeout: 180_000 }, ); } /** * Fly to a chapter by index, having checked it is the chapter we mean. * * The card used to get here with `keyboard.press("2")`, which is an unguarded * index into pack data: when the default board became California, "2" stopped * being a city and became the US-101 corridor in drive mode, and the card that * shipped for a fortnight was a chase camera on a freeway running through * kilometre-wide buildings under a headline that reads "Cities from above." * Nothing failed. It rendered, and it looked deliberate. * * So this asserts the short label the same way `shots.mjs` does, and for the same * reason: a reordered pack must stop the run, not requantify the marketing. */ async function flyTo(page, index, expect) { const found = await page.evaluate((i) => { const button = [...document.querySelectorAll("#chapters .chapter")][i]; if (!button) return null; button.click(); const spans = [...button.querySelectorAll("span")]; const label = spans.length > 1 ? spans[spans.length - 1] : null; return { id: button.getAttribute("data-view"), label: (label?.textContent ?? "").trim() }; }, index); if (found === null) throw new Error(`no chapter at index ${index} — the card cannot be framed`); if (found.label !== expect) { throw new Error( `chapter ${index} is "${found.label}" (${found.id ?? "no id"}), not "${expect}" — ` + `a city pack was reordered, so the share card would be a picture of somewhere else`, ); } await page.waitForTimeout(2500); } /** * The default sensor. 2x, so the art is still sharp when a timeline shows the * card at 600px wide on a retina screen. * * A card is 1200x630 and the art is `cover`-cropped into it, so the *aspect* of * this frame decides which axis gets cropped and therefore which axis * `background-position` can move the subject along. At 1400x900 — aspect 1.56 * against the card's 1.90 — the crop is entirely vertical, the horizontal * position does nothing at all, and a subject centred in the app frame is * centred under the headline no matter what the CSS asks for. The state board is * shot on a wider sensor for exactly that reason; `three.js` holds the *vertical* * field of view, so a wider frame is more world either side and the board comes * out the same height. */ const SENSOR = { width: 1400, height: 900 }; async function shootApp( browser, url, file, { at, chapter = null, expect = null, settle = 6000, viewport = SENSOR } = {}, ) { const page = await browser.newPage({ viewport, deviceScaleFactor: 2, /** * Without this the card was framed differently every run, and had been * since the day it was written. * * `scenekit.ts` eases a chapter change over about two seconds of scene * time and `stage.ts` clamps `dt` to 50 ms a frame — so a flight is about * forty frames however fast they are drawn, and the four seconds this * waited caught the camera partway across the bay at a different point * each time. Two consecutive captures of an unchanged repo produced two * different cards, neither of them the chapter the key press had asked * for. It was worst under software GL, where forty frames took twenty * seconds; on the GPU it is a shorter race and still a race. * * Reduced motion is the app's own answer for "somebody clicked a name in a * list": `flyTo` sets the pose outright, so the shutter opens on the pose * the shot names. * * The *framing* is what this fixes, not the bytes. Aircraft are still * crossing and cloud shadow is still drifting, so two runs differ by a few * pixels and `git diff` will always show the PNG as changed. That is the * app being alive, and worth far less than the framing being on purpose. */ reducedMotion: "reduce", }); await page.addInitScript(clockShim(at)); await page.goto(url, { waitUntil: "networkidle" }); await ready(page); // Boot hiding means the scene exists, not that it has drawn a full frame. await page.waitForTimeout(settle); if (chapter !== null) await flyTo(page, chapter, expect); // The chrome comes off. The card supplies its own typography, and the app's // panels shrunk to card size are unreadable furniture. A product screenshot // wants the opposite — see `shots.mjs`, which keeps the instruments. await hide(page, [...FURNITURE.transient, ...FURNITURE.BARE]); await page.screenshot({ path: join(HERE, file), timeout: 120_000, animations: "disabled" }); console.log("art ", file); await page.close(); } async function renderCard(browser, which, out) { const page = await browser.newPage({ viewport: { width: 1200, height: 630 }, deviceScaleFactor: 1 }); await page.goto(`http://127.0.0.1:8799/og.html?card=${which}`, { waitUntil: "networkidle" }); // The art is a background image, so `networkidle` is not proof it has decoded. await page.evaluate(() => document.fonts.ready); await page.waitForTimeout(1200); await page.screenshot({ path: join(PUBLIC, out) }); console.log("card ", out); await page.close(); } /** * `--cards-only` re-renders the two PNGs from art already on disk. * * The app pass shoots the running city and the card pass is two seconds, and * every iteration on a headline needs only the second. Without the flag, tuning * a line of copy costs a whole app pass each time, which is how a card ends up * shipped with the first wording anybody tried. */ const cardsOnly = process.argv.includes("--cards-only"); const app = cardsOnly ? null : await serve(join(ROOT, "dist"), 5210, { spa: true }); const assets = await serve(HERE, 8799); const browser = await launch(); try { if (!cardsOnly) { await shootApp(browser, "http://office.lumbridgecorp.com:5210/", "art-office.png", { at: LATE_MORNING, }); /* * The state board, and the chapter is asserted. * * `og:image:alt` on the tera door already promises "California rendered from * above, with Los Angeles and San Francisco joined by the US-101 and I-5 * corridors", and the headline on the card says "Cities from above." This is * the frame both of those sentences describe. Chapter 0 is where the board * opens, so no flight is needed — but the label is checked anyway, because * "the chapter I did not click" is exactly as reorderable as the one I did. */ await shootApp(browser, "http://tera.lumbridgecorp.com:5210/?city=california&handover=0", "art-tera.png", { at: AFTERNOON, chapter: 0, expect: "State", // Wide, so the whole state survives the crop: see `SENSOR`. viewport: { width: 2000, height: 900 }, }); } await renderCard(browser, "tera", "og-tera.png"); await renderCard(browser, "office", "og-office.png"); } finally { await browser.close(); app?.close(); assets.close(); }