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/capture.mjs
T
karti 6fc0b2b60e feat: land on a real place, and let a wheel notch cross the seam
The owner has now asked four times why there are three boards, and the last
answer missed the point: reconciling the packs made them draw one California,
but you still *arrived* on the coarsest tier the product owns and still changed
boards by picking a name off a list. This changes both.

**The behaviour was already built and behind a second flag defaulted off.**
`handover()` in `ladder.ts` — promote/demote by camera stand-off, hysteresis at
0.9/1.15, a drag guard so a board never swaps under a finger — was pure, tested
and shipped a round ago, with `?handover=1` as the only way to see it. It is on
now, `?handover=0` turns it off. Measured on the deployed bundle first:
promotion into the Bay Area fired at the sixteenth wheel notch in from the state
pose, arrival clean, no boot card, no tab, no click on a name.

**And it did not work, for a reason worth writing down.** Landing on the Bay
with the flag on bounced straight back to the state board with no input at all.
Two wrong diagnoses on the way, both from reasoning instead of measuring:

  1. "sf's ceiling equals its own widest pose, so the band is too tight." It is
     not — `chapterStandoffMetres` puts the resting pose at 71.2 km against a
     demote threshold of 81.9 km.
  2. "the arrival flight carries the camera through the threshold, so guard on
     `arriving()`." Right about the cause, wrong about the mechanism: the guard
     went in and the bounce survived it.

A trajectory log settled it in one run. The handover tick arrived *before* the
first `arriving=true` sample, at 108,316 m — 1.5x sf's resting stand-off, which
is `arrivalStart`'s own offset. `arrive()` read:

    kit.setPose(from);
    arrival = { from, to: rest, elapsed: 0 };

`setPose` drives `OrbitControls`, which fires `change` **synchronously**, and
`main.ts` listens on that event. So the listener ran on the line *between* those
two statements: camera already 1.5x out, `arriving()` still false. A one-frame
ordering race, and the guard could not fire because the flag it reads was set one
statement too late. The assignment now goes first.

The guard stays, because the inequality behind it is structural rather than
incidental: `ARRIVAL_STANDOFF` is 1.5 and `DEMOTE` is 1.15, both global, so the
opening frame of *every* board sits outside that board's own retention band.
`handoverArrivalGuard.test.ts` asserts that relation and drives the real rule
through the opening stand-off to watch it demote, so neither the guard nor either
constant can be quietly simplified.

**The landing board is `DEFAULT_CITY_ID`, and it is the Bay Area.** It was
`CITIES[0]` in three places, which meant the state tier: seventeen districts over
1063x930 km at 1,919 m per unit, no city legible, and a left column whose first
offer is somewhere else to go. The detailed boards carry 52 and 47 districts at
94 and 391 m per unit. With free handover on, the state tier stops being a
destination and becomes what you get when you pull back — the role it is good at,
since it is the only board drawing 97.4% of California. A named constant rather
than reordering `CITIES`, because that array's order is the `?city=` fallback and
is read positionally by other consumers.

**Which caught a silent break in the capture harness, and this is the part that
would have cost a week.** `shots.mjs` and `films.mjs` built `?city=` only when a
shot declared one — and 4 of 21 shots and 1 of 4 films declare none, so they
inherited the app's default. Moving that default would have re-pointed five
pieces of marketing imagery at a different place while every filename, caption
and alt text stayed as it was. Both harnesses now name `california` themselves.
`performance-budget.mjs`'s `california` and `california-drive` cells had no query
at all for the same reason; `signature` would have failed them loudly rather than
mismeasuring, which is the harness working, but a harness that depends on an app
default reports someone else's change as its own flake.

Every harness also pins `handover=0`. A planted pose wider than a board's
retention band would otherwise demote to the coarser tier while the shutter is
open, and the frame that comes back is a real photograph of the wrong board.

Verified: bare URL lands on the Bay Area and stays there; `?city=california` and
`?city=socal` still deep-link; `?handover=0` stays put; zooming out from the Bay
demotes to the state tier at the second notch. 1,694 tests pass. All ten budget
cells pass with no cap raised, and every cell now measures the board it names.

Still true and now a decision rather than a doubt: 97.4% of California has no
board below 242 km of stand-off, so zooming into the middle of the state lands on
coarse ground. That picture has been looked at. Authoring Sacramento, Fresno and
the Central Valley is what retires it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-24 00:13:23 -07:00

221 lines
9.7 KiB
JavaScript

/**
* 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");
/**
* 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 AFTERNOON = "2026-08-06T17:30:00-07:00";
const LATE_MORNING = "2026-08-06T11:20:00-07:00";
/**
* 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,
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 and `stage.ts` clamps `dt` to 50 ms a frame — so a flight is about
* forty frames however fast they are drawn, and the four seconds 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. It was worst under software GL, where forty frames took twenty
* seconds; on the GPU it is a shorter race and still a race.
*
* 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 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
* 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(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 (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.
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 shoots the running city 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 whole app pass 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", {
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&handover=0", "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");
} finally {
await browser.close();
app?.close();
assets.close();
}