/** * 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"); /** * Midday, fixed. * * 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. */ const NOON = "2026-08-06T12:40:00-07:00"; async function shootApp(browser, url, file, { key = null, settle = 20000 } = {}) { // 2x, so the art is still sharp when a timeline shows the card at 600px wide // on a retina screen. const page = await browser.newPage({ viewport: { width: 1400, height: 900 }, 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, `stage.ts` clamps `dt` to 50 ms a frame, and SwiftShader on this * box draws about three frames a second — so the flight below takes some * twenty seconds of wall clock, and the four 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. * * 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 key 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(NOON)); await page.goto(url, { waitUntil: "networkidle" }); await page.waitForTimeout(settle); if (key) { await page.mouse.move(700, 450); await page.keyboard.press(key); await page.waitForTimeout(4000); } // 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 is a minute of software rasterisation 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 minute 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"); await shootApp(browser, "http://tera.lumbridgecorp.com:5210/", "art-tera.png", { key: "2" }); } await renderCard(browser, "tera", "og-tera.png"); await renderCard(browser, "office", "og-office.png"); } finally { await browser.close(); app?.close(); assets.close(); }