1
0
This repository has been archived on 2026-08-25. You can view files and clone it. You cannot open issues or pull requests or push a commit.
Files
tera/scripts/brand-assets/shots.mjs
T
karti 138a342c17 The frame matches the page's light rather than opposing it
Correcting the pairing this script documents: the daylit frame goes on the
light page and the night frame on the dark one. A reader in dark mode has asked
for a dark room, and a bright noon render is a hole punched in it; the hairline
frame around each figure is what keeps a night shot from dissolving into the
page, which is the border's job rather than the sun's.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-06 15:39:44 -07:00

579 lines
25 KiB
JavaScript

/**
* 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 <dir>` (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 the one that matches the theme the reader is in —
* the daylit frame on the light page, the night frame on the dark one, so the
* picture is in the same light as the page around it. 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 <dir>; 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)!;
`;
}