/** * Photograph the engine, for the pages that describe it. * * node scripts/brand-assets/shots.mjs # everything, full size * node scripts/brand-assets/shots.mjs --only fidi-night,office-floor * node scripts/brand-assets/shots.mjs --preview # fast, 1x, PNGs to look at * node scripts/brand-assets/shots.mjs --manifest-only # captions only, no browser * * `capture.mjs` next door makes the two share cards. This makes the product * imagery: the pictures on lumbridgecorp.com/simulate that show what the thing * looks like. Same harness, different framing — see `harness.mjs`. * * ### Why these are renders and not illustrations * * Every image this writes is a screenshot of the built `dist/` in this repo at * the commit named in the manifest. Nothing is composited, retouched, or drawn. * That is the whole point: /simulate/tera claims the sun is computed from your * clock and the city lights come on as it sets, and the honest way to * illustrate that claim is a photograph of it happening, taken by a script * anyone can re-run. `og.png` on lumbridgecorp.com spent a month advertising a * headline the site had stopped using, because it was a hand-made picture of a * page rather than a render of one. This is that lesson applied to the art. * * ### The two outputs * * **The site.** `--site ` (default: the sibling `lumbridge-v4` checkout) * gets web-sized WebP into `apps/web/public/shots/` and a generated TypeScript * manifest into `apps/web/src/data/shots.ts`. The captions travel *with* the * images, in the manifest, so a page cannot show a caption for a picture that * was reframed underneath it. If the directory is not there — which is the * normal case for anyone who cloned this repo on its own — the step is skipped * with a note, because the site is Lumbridge's consumer of this script and not * a dependency of it. * * **The archive.** A dated folder under `shots/` keeps the full-resolution * frame of every shot from every run. Nothing reads it. It is there because the * cities keep changing and a render of the Bay from August is not recoverable * once the heightfield moves — the manifest can be regenerated, the past * cannot. */ import { fileURLToPath } from "node:url"; import { dirname, join, resolve } from "node:path"; import { mkdir, writeFile, access } from "node:fs/promises"; import { execFileSync } from "node:child_process"; import { serve, launch, clockShim, FURNITURE, hide } from "./harness.mjs"; const HERE = dirname(fileURLToPath(import.meta.url)); const ROOT = join(HERE, "..", ".."); // ---- The shots -------------------------------------------------------------- /** * `chapter` is an index into the chapter list, because that is all the DOM * offers — `renderLegend()` in `main.ts` builds the buttons from the city pack's * array and binds each to `flyToIndex(i)`, with no id on the element. `expect` * is the `shortLabel` that index is supposed to be, asserted before the shutter * opens. A city pack is data, and data gets reordered; without the assertion a * reordered pack silently reframes every picture on the site and captions them * with the old names. * * Every shot is taken **twice from the same camera**, once by day and once at * night, and the site loads whichever suits the theme the reader is in — a dark * page gets the daylit frame and a light page gets the night one, because a * night render on a dark page dissolves into it and a hazy noon render on a * white page washes out. One camera and two clocks also happens to be the * cheapest possible demonstration of the thing these pages claim hardest: the * sun is computed from a real time, not painted on. * * Times are local to the city and are chosen, not defaulted. Midday is the * worst light a heightfield ever gets — the sun is behind the camera, nothing * casts, and 83k buildings read as gravel. The daylight frames are taken in the * morning or late afternoon for the same reason a photographer would; the night * frames sit around sixteen degrees below the horizon, which is dark enough for * the window lights to carry the picture and not so dark that the ground has * gone. */ const SHOTS = [ { id: "bay-relief", door: "tera", city: "sf", chapter: 0, expect: "Whole Board", place: "San Francisco Bay Area", caption: "Everything else in the engine stands on this heightfield: 83 named hills, four octaves of value noise, and overlapping peaks combined as tallest-plus-35%-of-the-rest — the one rule that makes a run of summits read as a ridge instead of stacking into a fictional mountain.", day: { at: "2026-08-06T07:40:00-07:00", note: "Twenty to eight, sun fifteen degrees up: low enough that the hills rake shadow across the flats and the relief reads as relief.", alt: "The San Francisco Bay Area seen from above in low morning light, the peninsula and the East Bay hills raking shadows across the water.", }, night: { at: "2026-08-06T21:35:00-07:00", note: "Twenty-five to ten, and the same board is a scatter of lit ground between two dark ranges — the shape of the region is now where the cities are, not where the hills are.", alt: "The same view of the Bay Area at night, the built-up ground picked out in clusters of light between dark hills and darker water.", }, }, { id: "soma", door: "tera", city: "sf", chapter: 2, expect: "SoMa", place: "SoMa, San Francisco", caption: "South of Market, where the grid turns forty-six degrees and the blocks get long. About 83,000 buildings are on screen here, drawn as instanced geometry on street bearings the city pack declares per district.", day: { at: "2026-08-06T17:20:00-07:00", note: "Twenty past five.", alt: "Downtown San Francisco south of Market in afternoon light, towers clustered on a street grid turned at an angle to the rest of the city.", }, night: { at: "2026-08-06T21:35:00-07:00", note: "Twenty-five to ten. Nothing about the geometry changed — only the hour the page was asked for.", alt: "The same towers south of Market at night, windows lit in a scatter across every face.", }, }, { id: "fidi", door: "tera", city: "sf", chapter: 3, expect: "FiDi", place: "Financial District, San Francisco", caption: "The old money and the venture money, stacked between Montgomery and the Embarcadero under the pyramid, with the Bay Bridge running out to the right.", day: { at: "2026-08-06T10:10:00-07:00", note: "Ten past ten in the morning, sun forty-four degrees up.", alt: "The San Francisco financial district from above in mid-morning light, the pyramid among the towers and the Bay Bridge crossing to the right.", }, night: { at: "2026-08-06T21:40:00-07:00", note: "Twenty to ten, sun sixteen degrees below the horizon. The lights are not a night theme — they are emissive window faces chosen from a seeded RNG as the computed sun goes down. The moonlight carrying the ground is about five orders of magnitude brighter than the real moon-to-sun ratio, deliberately: the honest number renders a black rectangle.", alt: "The San Francisco financial district at night, tower windows lit from within and the ground reading dark blue rather than black.", }, }, { id: "peninsula", door: "tera", city: "sf", chapter: 6, expect: "Peninsula", place: "The Peninsula", caption: "Twenty miles of city between the water and the ridge, never more than four wide. The camera is about 32 km back — the city scene's orbit limit — which is why the region ships as five chapters rather than one impossible wide shot.", day: { at: "2026-08-06T09:20:00-07:00", note: "Twenty past nine, with an aircraft on approach drawing the line across the bay.", alt: "The San Francisco peninsula from above in morning light, the Santa Cruz mountains on one side, the bay on the other, and an aircraft trail crossing the water.", }, night: { at: "2026-08-06T21:35:00-07:00", note: "Twenty-five to ten, and the ridge is legible only as the edge the lights stop at.", alt: "The San Francisco peninsula at night, a band of lit ground between dark water and a darker mountain ridge.", }, }, { id: "socal-dtla", door: "tera", city: "socal", chapter: 1, expect: "DTLA", place: "Downtown Los Angeles", caption: "The second city pack, on the same engine and the same renderer. A city is pure data — coastlines, hills, district street bearings, landmarks and camera chapters in one file with no code in it — so adding one is a reviewable contribution rather than a fork. The camera is square to downtown's grid, which is why everything else in the frame looks crooked.", day: { at: "2026-08-06T18:10:00-07:00", note: "Ten past six, the San Gabriels standing behind the basin.", alt: "Downtown Los Angeles from above in late afternoon light, the river running past the tower cluster and the San Gabriel mountains behind the basin.", }, night: { at: "2026-08-06T21:20:00-07:00", note: "Twenty past nine. The basin is the one place on either board where the lit ground runs to the edge of the frame in every direction.", alt: "Downtown Los Angeles at night, the tower cluster lit and the surrounding basin covered in light to the horizon.", }, }, { id: "office-floor", door: "office", chapter: 0, expect: "The Floor", place: "Lumbridge HQ", caption: "The same renderer from inside: thirty-four metres by eighteen, one storey, glazed along the north edge. An Office is one building's interior and a Space is a room within it — and this cannot share a THREE.Scene with the city, because San Francisco puts one scene unit at about 94 metres and this is one unit to the metre.", day: { at: "2026-08-06T11:20:00-07:00", note: "Twenty past eleven in the morning.", alt: "A whole office interior rendered in 3D as an open-topped model, walls, desks and meeting rooms laid out across one floor, with its plan drawn beside it.", }, night: { at: "2026-08-06T21:30:00-07:00", note: "Half past nine. The office keeps its own light after the sun has gone, which is the difference between a room and a landscape.", alt: "The same office model at night, lit from within against a dark ground.", }, }, { id: "office-desks", door: "office", chapter: 2, expect: "Desks", place: "Lumbridge HQ", caption: "Thirty-six seats in four benches. Stepping in here does not throw the city away — the city scene is retained and paused rather than disposed, because rebuilding the Bay's heightfield costs about 2.3 seconds and nobody should pay that to walk back out of a door.", day: { at: "2026-08-06T11:20:00-07:00", note: "Twenty past eleven, daylight through the glazing on the north edge.", alt: "A closer view inside the office: rows of desks with chairs and monitors, people seated at them, bookshelves and planting behind a partition.", }, night: { at: "2026-08-06T21:30:00-07:00", note: "Half past nine, and the windows have stopped being the light source.", alt: "The same desks at night, lit by the office's own lighting with darkness beyond the windows.", }, }, ]; /** The two frames every shot is taken in, in the order they are rendered. */ const VARIANTS = ["day", "night"]; // ---- Sizes ------------------------------------------------------------------ /** * Shot at 2x and delivered at two widths, because the pages that use these run * a picture at about 720 CSS px on a desktop and full-bleed on a phone. * * WebP rather than PNG, and by a wide margin: these frames are photographs of a * continuous-tone render, and PNG spends about 650 KB on what WebP says in 90. * The encoder is the browser that is already open — this box has no `sharp`, no * ImageMagick and no `rsvg-convert`, which is the same constraint that made the * share cards a screenshot in the first place. */ const VIEWPORT = { width: 1440, height: 900 }; const SIZES = [ { suffix: "1600", width: 1600, quality: 0.82 }, { suffix: "800", width: 800, quality: 0.84 }, ]; /** The archive frame: full sensor, near-lossless, kept forever. */ const ARCHIVE_QUALITY = 0.94; // ---- Arguments -------------------------------------------------------------- function flag(name, fallback = null) { const i = process.argv.indexOf(`--${name}`); return i > -1 && process.argv[i + 1] && !process.argv[i + 1].startsWith("--") ? process.argv[i + 1] : fallback; } const preview = process.argv.includes("--preview"); /** * Rewrite the site's manifest from the shot list without opening a browser. * * A caption is prose and gets edited like prose — several times, in a row, to * hear how it reads. Charging three minutes of software rasterisation for each * pass is how a caption ends up shipped in the first wording anybody tried, * which is the same failure the share-card README describes. The images this * points at are the ones already on disk, and the commit it stamps is today's; * both are true, because the pictures did not change. */ const manifestOnly = process.argv.includes("--manifest-only"); const only = flag("only")?.split(",").map((s) => s.trim()); const siteDir = resolve(flag("site", join(ROOT, "..", "lumbridge-v4"))); const archiveRoot = resolve(flag("archive", join(ROOT, "shots"))); const wanted = only ? SHOTS.filter((s) => only.includes(s.id)) : SHOTS; if (only) { const unknown = only.filter((id) => !SHOTS.some((s) => s.id === id)); if (unknown.length) { console.error(`unknown shot(s): ${unknown.join(", ")}`); process.exit(1); } } // ---- Provenance ------------------------------------------------------------- const git = (...args) => execFileSync("git", args, { cwd: ROOT, encoding: "utf8" }).trim(); const commit = git("rev-parse", "--short", "HEAD"); /** * Recorded, and shown on the site. A shot taken from a working tree with * uncommitted changes is a picture of a build that exists on exactly one * machine, and the manifest should say so rather than name a commit that would * render something else. */ const dirty = git("status", "--porcelain").length > 0; const today = new Date().toISOString().slice(0, 10); // ---- Capture ---------------------------------------------------------------- /** * Re-encode a PNG buffer to WebP at a target width, using the browser as the * codec. `height` follows from the source aspect so a size table only has to * carry widths. */ async function encode(page, png, width, quality) { const dataUrl = await page.evaluate( async ({ b64, w, q }) => { const img = new Image(); img.src = `data:image/png;base64,${b64}`; await img.decode(); const canvas = document.createElement("canvas"); canvas.width = w; canvas.height = Math.round((img.naturalHeight / img.naturalWidth) * w); const ctx = canvas.getContext("2d"); ctx.imageSmoothingEnabled = true; ctx.imageSmoothingQuality = "high"; ctx.drawImage(img, 0, 0, canvas.width, canvas.height); return canvas.toDataURL("image/webp", q); }, { b64: png.toString("base64"), w: width, q: quality }, ); return Buffer.from(dataUrl.slice(dataUrl.indexOf(",") + 1), "base64"); } /** * Read a chapter button's label, and optionally press it. * * The index is all the DOM offers, so `expect` is the guard. `main.ts` renders * the number into its own span, hence the prefix strip. */ async function chapter(page, index, expect, { click = false } = {}) { const label = await page.evaluate( ({ i, press }) => { const button = [...document.querySelectorAll("#chapters .chapter")][i]; if (!button) return null; if (press) button.click(); return button.textContent.replace(/^\d+/, "").trim(); }, { i: index, press: click }, ); if (label === null) throw new Error(`no chapter at index ${index}`); if (label !== expect) { throw new Error( `chapter ${index} is "${label}", not "${expect}" — a city pack was reordered, ` + `so the shot list and its captions need re-checking before anything ships`, ); } } async function shoot(browser, shot, frame) { const host = shot.door === "office" ? "office.lumbridgecorp.com" : "tera.lumbridgecorp.com"; const query = shot.city ? `?city=${shot.city}` : ""; const page = await browser.newPage({ viewport: VIEWPORT, deviceScaleFactor: preview ? 1 : 2, /** * The camera cuts instead of flying, and this is the difference between a * shot list that takes three minutes and one that takes twelve — or worse, * one that quietly photographs the camera mid-flight. * * `scenekit.ts` eases a chapter change over about two seconds of *scene* * time, and `stage.ts` clamps `dt` to 50 ms a frame. Under SwiftShader on a * box with no GPU the app runs at roughly three frames a second, so those * two seconds take about twenty of wall clock, and any fixed wait short of * that lands somewhere over the bay. Reduced motion is the app's own * documented answer to "I only clicked a name in a list": `flyTo` calls * `setPose` and the pose is simply true on the next frame. */ reducedMotion: "reduce", }); const problems = []; page.on("pageerror", (e) => problems.push(String(e))); try { await page.addInitScript(clockShim(frame.at)); await page.goto(`http://${host}:5210/${query}`, { waitUntil: "networkidle" }); /** * Wait for the app to say it is up rather than for a number of seconds. * The terrain is built in a worker and the city is ~83k instances, and how * long that takes on software GL depends on the city, the machine and what * else is running — a fixed sleep is either wrong or wasteful, and here it * would have been both. */ await page.waitForFunction( () => document.getElementById("boot")?.hidden === true && document.querySelectorAll("#chapters .chapter").length > 0, { timeout: 180_000 }, ); // Boot hiding means the scene exists, not that it has drawn a full frame. await page.waitForTimeout(preview ? 3000 : 6000); await chapter(page, shot.chapter, shot.expect, { click: shot.chapter > 0 }); if (shot.chapter > 0) await page.waitForTimeout(2500); await hide(page, [...FURNITURE.transient, ...FURNITURE.CLUTTER]); const png = await page.screenshot({ timeout: 120_000, animations: "disabled" }); if (problems.length) { throw new Error(`the page threw while being photographed: ${problems[0]}`); } return png; } finally { await page.close(); } } // ---- Run -------------------------------------------------------------------- const exists = (p) => access(p).then(() => true, () => false); const publicDir = join(siteDir, "apps", "web", "public", "shots"); const dataDir = join(siteDir, "apps", "web", "src", "data"); const haveSite = () => exists(join(siteDir, "apps", "web")); const noSite = () => console.log( `site skipped — no checkout at ${siteDir}\n` + ` (pass --site ; the archive is complete either way)`, ); if (manifestOnly) { if (!(await haveSite())) { noSite(); } else { await mkdir(dataDir, { recursive: true }); await writeFile(join(dataDir, "shots.ts"), manifest()); console.log(`manifest → ${join(dataDir, "shots.ts")} (no images re-rendered)`); } process.exit(0); } const app = await serve(join(ROOT, "dist"), 5210, { spa: true }); const browser = await launch(); const results = []; try { // One page for encoding, reused. Spinning up a browser context per re-encode // costs more than the encode does. const codec = await browser.newPage(); await codec.goto("about:blank"); const archiveDir = join(archiveRoot, `${today}-${commit}${dirty ? "-dirty" : ""}`); if (!preview) await mkdir(archiveDir, { recursive: true }); for (const shot of wanted) { for (const variant of VARIANTS) { const stem = `${shot.id}-${variant}`; const png = await shoot(browser, shot, shot[variant]); if (preview) { await mkdir(join(ROOT, "shots", "preview"), { recursive: true }); await writeFile(join(ROOT, "shots", "preview", `${stem}.png`), png); console.log("preview", stem); continue; } await writeFile( join(archiveDir, `${stem}.webp`), await encode(codec, png, VIEWPORT.width * 2, ARCHIVE_QUALITY), ); const sizes = {}; for (const size of SIZES) { sizes[size.suffix] = await encode(codec, png, size.width, size.quality); } results.push({ stem, sizes }); console.log( "shot ", stem.padEnd(22), SIZES.map((s) => `${s.suffix}w ${Math.round(sizes[s.suffix].length / 1024)}kB`).join(" "), ); } } if (preview) { console.log(`\npreviews in ${join(ROOT, "shots", "preview")}`); } else { console.log(`\narchive ${archiveDir}`); if (!(await haveSite())) { noSite(); } else { await mkdir(publicDir, { recursive: true }); await mkdir(dataDir, { recursive: true }); for (const { stem, sizes } of results) { for (const size of SIZES) { await writeFile(join(publicDir, `${stem}-${size.suffix}.webp`), sizes[size.suffix]); } } // Regenerating the manifest from a partial run would drop every shot the // run did not take, so `--only` writes images and leaves the manifest be. if (only) { console.log(`site ${results.length} frames → ${publicDir}`); console.log(" manifest left alone (partial run; re-run without --only to rewrite it)"); } else { await writeFile(join(dataDir, "shots.ts"), manifest()); console.log(`site ${results.length} frames → ${publicDir}`); console.log(` manifest → ${join(dataDir, "shots.ts")}`); } } } } finally { await browser.close(); app.close(); } // ---- The generated manifest ------------------------------------------------- function manifest() { const aspect = VIEWPORT.width / VIEWPORT.height; const frame = (id, variant) => { const f = SHOTS.find((s) => s.id === id)[variant]; return `{ at: ${JSON.stringify(f.at)}, src: ${JSON.stringify(`/shots/${id}-${variant}-1600.webp`)}, srcSmall: ${JSON.stringify(`/shots/${id}-${variant}-800.webp`)}, alt: ${JSON.stringify(f.alt)}, note: ${JSON.stringify(f.note)}, }`; }; const entries = SHOTS.map( (s) => ` { id: ${JSON.stringify(s.id)}, door: ${JSON.stringify(s.door)}, place: ${JSON.stringify(s.place)}, width: ${SIZES[0].width}, height: ${Math.round(SIZES[0].width / aspect)}, caption: ${JSON.stringify(s.caption)}, day: ${frame(s.id, "day")}, night: ${frame(s.id, "night")}, },`, ).join("\n"); return `/** * Generated. Do not edit — \`scripts/brand-assets/shots.mjs\` in the tera repo * rewrites this file wholesale, and the captions below live next to the camera * poses that framed them so the two cannot drift apart. * * To change a picture or its caption: edit \`SHOTS\` in that script, run * \`node scripts/brand-assets/shots.mjs\`, and commit both repos. A caption-only * change can use \`--manifest-only\` and skip the render entirely. * * Every image is a screenshot of tera's built \`dist/\` at the commit named * below. Nothing here is an illustration. */ /** * A union rather than \`string\`, so a page asking for a picture that is not in * this list fails \`npm run typecheck\` — which the pre-push hook runs — instead * of rendering a hole a reader finds later. */ export type ShotId = ${SHOTS.map((s) => ` | ${JSON.stringify(s.id)}`).join("\n")}; /** One camera, one clock. Every shot has two of these and differs only in the hour. */ export interface Frame { /** The instant the engine's own clock was set to. The sun follows from it. */ at: string; src: string; srcSmall: string; alt: string; /** What this hour in particular is showing, appended to the shot's caption. */ note: string; } export interface Shot { id: ShotId; /** Which door the engine was showing: the city, or an office interior. */ door: "tera" | "office"; /** Where this is, in words a reader would use. */ place: string; width: number; height: number; /** True of both frames — the place and the mechanism, never the light. */ caption: string; day: Frame; night: Frame; } /** The tera commit these were rendered from. */ export const SHOTS_COMMIT = ${JSON.stringify(commit)}; /** True if that commit is not the whole story — the tree had uncommitted work. */ export const SHOTS_DIRTY = ${dirty}; /** The day the shutter opened, ISO. */ export const SHOTS_CAPTURED = ${JSON.stringify(today)}; export const SHOTS: Shot[] = [ ${entries} ]; /** Total, because \`ShotId\` cannot name a shot that is not in \`SHOTS\`. */ export const byId = (id: ShotId): Shot => SHOTS.find((s) => s.id === id)!; `; }