/** * 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 { chromium } from "playwright"; import { fileURLToPath } from "node:url"; import { dirname, join } from "node:path"; import { createServer } from "node:http"; import { readFile } from "node:fs/promises"; import { extname } from "node:path"; 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"; const MIME = { ".html": "text/html; charset=utf-8", ".js": "text/javascript", ".css": "text/css", ".png": "image/png", ".svg": "image/svg+xml", ".webmanifest": "application/manifest+json", }; /** A static server over one directory, with the SPA fallback the app expects. */ function serve(dir, port, { spa = false } = {}) { const server = createServer(async (req, res) => { const path = decodeURIComponent((req.url ?? "/").split("?")[0]); for (const candidate of [path, `${path}/index.html`, spa ? "/index.html" : null]) { if (!candidate) continue; try { const body = await readFile(join(dir, candidate)); res.writeHead(200, { "content-type": MIME[extname(candidate)] ?? "application/octet-stream" }); res.end(body); return; } catch { /* try the next candidate */ } } res.writeHead(404).end("not found"); }); return new Promise((resolve) => server.listen(port, "127.0.0.1", () => resolve(server))); } const CHROME_ARGS = [ "--no-sandbox", "--disable-dev-shm-usage", // Software GL, so this runs on a box with no display and no GPU. "--use-gl=angle", "--use-angle=swiftshader", // The app reads its own hostname to decide which door it is. Resolving both // names at the local server is what makes one `dist/` produce both shots. "--host-resolver-rules=MAP office.lumbridgecorp.com 127.0.0.1, MAP tera.lumbridgecorp.com 127.0.0.1", ]; const clockShim = `{ const target = new Date(${JSON.stringify(NOON)}).getTime(); const skew = target - Date.now(); const Real = Date; globalThis.Date = class extends Real { constructor(...a) { super(...(a.length ? a : [Real.now() + skew])); } static now() { return Real.now() + skew; } }; }`; 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, }); await page.addInitScript(clockShim); 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. await page.evaluate(() => { for (const sel of ["#panel", "#panel-toggle", "#corner", "#rail", "#source", "#tier", "#boot", "#scrim"]) { document.querySelectorAll(sel).forEach((el) => (el.style.display = "none")); } }); await page.waitForTimeout(600); 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 chromium.launch({ channel: "chrome", args: CHROME_ARGS }); 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(); }