1
0

feat(brand): re-shoot everything at the new engine, and make it one command

**Seven new shots**, because the world grew the most photogenic things in it
after the last pass: `sfo`, `lax`, `golden-gate`, `bay-bridge`, `freeway`,
`california-relief` and `pacific-sea`. All nine existing ids are unchanged — the
manifest emits a `ShotId` union that lumbridge-v4 imports, so ids are added and
never renamed.

**A shot can now aim itself.** The chapter list has no camera for SFO, LAX, the
bridges, the freeway or the open sea, and the engine has no `?pose=` back door,
so a shot points itself by driving the app's own inputs: a click on the plan view
slides the orbit target to a lat/lng while keeping the chapter's stance, wheel
notches set the standoff, and a drag sets azimuth and elevation. The plan view's
pixel-to-coordinate map is solved at runtime from three hovers of
`#minimap-readout` rather than hard-coded, so it survives a board resize or a
restyle of the widget.

**The tera share card was a picture of the wrong thing, and had been.** Its art
came from `keyboard.press("2")`, which had landed on the California board's drive
mode once the default board changed — so the card under the headline "Cities from
above." was a chase camera on US-101, showing metre-scale cars driving between
kilometre-wide buildings, with the DRIVE readout and the mode pill baked into the
art. It renders, it looks deliberate, and it is why an unguarded key press has no
place in a capture script. `capture.mjs` now clicks an indexed chapter and
asserts its `shortLabel` the way `shots.mjs` does, waits on `#boot` and
`#chapters` instead of sleeping twenty seconds, and gives each card its own hour.

**`npm run refresh` is the durable half.** One command: build, stills, cards,
films, both manifests, and a hashed before/after diff of every deliverable. It
fails loudly and specifically on the two conditions that otherwise produce
confident wrong output — the renderer coming up as SwiftShader, and a chapter
`expect` guard firing. `--stills-only` / `--cards-only` / `--films-only` compose,
`--dry-run` lists the plan without opening a browser, and
`shots.mjs --list` prints the whole shot plan — board, chapter, expect, aim, both
hours — which is what to run first when a guard does fire.

It also re-stamps `PROVENANCE.json`, narrowly: only entries whose origin is
`repository-generated` and whose `generator` names a script the run actually
executed, by literal hash substitution rather than re-serialising the file.
Without that, every legitimate card re-shoot leaves `npm run provenance` red.

**Every film re-shot.** They were at `9c9e78f`, captured 2026-08-07, and predated
the tone mapping, the reflective sea, the sky dome, terrain shadows, the rebuilt
California board, SFO, LAX, both bridges and the moving aircraft.

Tests 1137, typecheck, build, eight budget cells and every provenance and licence
check pass.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-08-22 12:49:06 -07:00
parent f81d5218d4
commit 2aa4049258
12 changed files with 1419 additions and 115 deletions
+108 -15
View File
@@ -32,21 +32,98 @@ const ROOT = join(HERE, "..", "..");
const PUBLIC = join(ROOT, "public");
/**
* Midday, fixed.
* 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 NOON = "2026-08-06T12:40:00-07:00";
const AFTERNOON = "2026-08-06T17:30:00-07:00";
const LATE_MORNING = "2026-08-06T11:20: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.
/**
* 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: { width: 1400, height: 900 },
viewport,
deviceScaleFactor: 2,
/**
* Without this the card was framed differently every run, and had been
@@ -63,7 +140,7 @@ async function shootApp(browser, url, file, { key = null, settle = 20000 } = {})
*
* 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 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
@@ -72,14 +149,12 @@ async function shootApp(browser, url, file, { key = null, settle = 20000 } = {})
*/
reducedMotion: "reduce",
});
await page.addInitScript(clockShim(NOON));
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 (key) {
await page.mouse.move(700, 450);
await page.keyboard.press(key);
await page.waitForTimeout(4000);
}
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.
@@ -115,8 +190,26 @@ 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 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", "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");