diff --git a/PROVENANCE.json b/PROVENANCE.json index 4b1eed4..75cb874 100644 --- a/PROVENANCE.json +++ b/PROVENANCE.json @@ -13,7 +13,7 @@ "kind": "generated-social-card", "origin": "repository-generated", "license": "Apache-2.0", - "sha256": "344cf9a20f27e5bd8e3530de548c877c44bc9c5150af954b3100ffc6418879d1", + "sha256": "f174ac7a626912b1a6db73232e1c5514fb8673b8872f6c70d99b76c3199b86c7", "generator": "scripts/brand-assets/capture.mjs", "inputs": ["scripts/brand-assets/capture.mjs", "scripts/brand-assets/og.html"], "intakeDate": "2026-08-22", @@ -24,7 +24,7 @@ "kind": "generated-social-card", "origin": "repository-generated", "license": "Apache-2.0", - "sha256": "3301293c446cd0ad1818649241ca2c318deaef9f13a7714ec049d79780b5707d", + "sha256": "60f514b421f7781b14807c44c3375848d52ea3fa3e68b9072ff6c4589b1ba3f1", "generator": "scripts/brand-assets/capture.mjs", "inputs": ["scripts/brand-assets/capture.mjs", "scripts/brand-assets/og.html"], "intakeDate": "2026-08-22", diff --git a/TODO.md b/TODO.md index 99034d9..7281cc3 100644 --- a/TODO.md +++ b/TODO.md @@ -3,10 +3,43 @@ Short, and only things that are decided but not done. Anything speculative belongs in an issue, not here. +## One command re-shoots the imagery: `npm run refresh` + +```sh +npm run refresh # build, stills, cards, films, both manifests +npm run refresh -- --dry-run # what it would shoot; no browser, no build +npm run refresh -- --stills-only # …--cards-only, --films-only; they compose +npm run refresh -- --rough # films at 24 frames, for looking at +npm run refresh -- --skip-build # you just built; do not build again +``` + +`scripts/brand-assets/refresh.mjs` exists because re-shooting used to be a +*procedure* — build first, because the scripts photograph `dist/` and not `src/`; +then three commands in an order that matters; then check the run actually got the +GPU. A procedure that lives in somebody's head gets half-run, and the evidence was +in this file: the stills were re-shot at one commit and the films at another two +weeks older, and nothing said so until a human compared two files. + +It prints a diff of the deliverables at the end — new, changed with the size delta, +byte-identical — and it **fails loudly and specifically** on the only two failures +that otherwise produce confident wrong output: + +- **SwiftShader.** Software rendering makes the same pictures fifteen times slower + and reports success. Any child that prints a software renderer stops the run. +- **A chapter `expect` guard firing.** That means a pack was reordered under the + shot list, so re-running cannot fix it. `node scripts/brand-assets/shots.mjs + --list` prints what the shot list believes. + +Two things it does **not** do, on purpose: it does not commit, and it does not +re-stamp the manifests afterwards. Commit tera first, then +`shots.mjs --manifest-only` and `films.mjs --manifest-only`, so the manifests name +a clean sha rather than a dirty tree. + ## Re-shoot the time-lapse films -The 18 product stills and both share cards were re-shot on 2026-08-22 at `2b2dd5c` -and are live. **The films were not.** `films.ts` in lumbridge-v4 records the tera +The 25 product stills and both share cards were re-shot on 2026-08-22 at the +airports-and-bridges commit and are current. **The films were not** — they were +last shot on 2026-08-07 at `9c9e78f`. `films.ts` in lumbridge-v4 records the tera commit each reel was shot at, so a stale reel is visually stale and no caption edit fixes it — check that commit against tera HEAD before assuming a reel is current. @@ -16,15 +49,15 @@ flat `MeshLambertMaterial` that had no specular term at all; a world-space sky d replacing the screen-space gradient, which at dusk painted the sunset band along the *bottom* of the picture, under the board; terrain that casts shadows; a California board with the Basin and Range, Death Valley, the Salton Sea and San Diego on it; -aircraft you can actually see; a rebuilt LA studio; the studio device hardware; and +SFO, LAX, both bridges and a freeway that reads as a road; aircraft that move, +carrying registration and type; a rebuilt LA studio; the studio device hardware; and a new UI chrome with a first-run flow. ```sh -npm run build -node scripts/brand-assets/films.mjs --frames 24 # rough cut first: ~20 s, and it is - # the cheapest way to find out a - # camera is pointed at the wrong thing -node scripts/brand-assets/films.mjs # ~90 s per 180-frame film on the Radeon +npm run refresh -- --films-only --rough # rough cut first: ~20 s a reel, and it is + # the cheapest way to find out a camera is + # pointed at the wrong thing +npm run refresh -- --films-only # ~90 s per 180-frame film on the Radeon ``` Worth knowing before starting (the rest is in `~/.claude/skills/tera-capture`): @@ -37,9 +70,14 @@ Worth knowing before starting (the rest is in `~/.claude/skills/tera-capture`): because the edge of the terrain plate showed against the sky. That seam no longer exists: the sea now runs eighteen board spans and ends past the fog's far plane. - After tuning the hero, run `npm run check:hero` in lumbridge-v4. -- `#onboarding-host` is in `FURNITURE.CLUTTER` now. Every *new* overlay has to be - added there too, because a frame with a stray card in it still renders and still - looks deliberate. +- `#onboarding-host` is in `FURNITURE.CLUTTER` now, and `#mode-dock` and + `#play-hud` are in `FURNITURE.BARE`. Every *new* overlay has to be added to one + of those two lists on the day it lands, because a frame with a stray card in it + still renders and still looks deliberate. The mode dock proved it: it shipped + inside the tera share card for a fortnight and nobody noticed. +- **The two capture scripts both serve on port 5210.** Two of them at once is + `EADDRINUSE` a minute into a run, so do not start a film while a stills pass is + going — or give one of them `--site`-style isolation first. - Commit tera first, then re-run, so the manifest records a clean sha. ## The Bay Area board drops a frame in twenty, on desktop only diff --git a/package.json b/package.json index 048905a..dbe90cd 100644 --- a/package.json +++ b/package.json @@ -23,7 +23,8 @@ "test": "node --test \"src/test/*.test.ts\" \"src/test/**/*.test.ts\"", "typecheck": "tsc --noEmit", "preview": "vite preview", - "performance": "node scripts/performance-budget.mjs" + "performance": "node scripts/performance-budget.mjs", + "refresh": "node scripts/brand-assets/refresh.mjs" }, "dependencies": { "satellite.js": "^7.1.0", diff --git a/public/og-office.png b/public/og-office.png index cf07c7f..8f7a773 100644 Binary files a/public/og-office.png and b/public/og-office.png differ diff --git a/public/og-tera.png b/public/og-tera.png index 70b0e04..8eb64f3 100644 Binary files a/public/og-tera.png and b/public/og-tera.png differ diff --git a/scripts/brand-assets/capture.mjs b/scripts/brand-assets/capture.mjs index 2b34871..d32d243 100644 --- a/scripts/brand-assets/capture.mjs +++ b/scripts/brand-assets/capture.mjs @@ -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"); diff --git a/scripts/brand-assets/films.mjs b/scripts/brand-assets/films.mjs index fc664b9..362a14c 100644 --- a/scripts/brand-assets/films.mjs +++ b/scripts/brand-assets/films.mjs @@ -4,6 +4,7 @@ * node scripts/brand-assets/films.mjs # every film * node scripts/brand-assets/films.mjs --only fidi-day * node scripts/brand-assets/films.mjs --only fidi-day --frames 24 # a rough cut + * node scripts/brand-assets/films.mjs --keep-frames # keep the PNGs * node scripts/brand-assets/films.mjs --manifest-only # captions only * node scripts/brand-assets/films.mjs --publish films/2026-08-06-abc1234 * @@ -66,14 +67,36 @@ const DELIVER_HEIGHT = Math.round((DELIVER_WIDTH * VIEWPORT.height) / VIEWPORT.w * The CRFs are measured, not guessed. A time-lapse is close to the worst case * for inter-frame compression: the camera never moves but every pixel changes * every frame as the light does, so the encoder has no static background to - * lean on. At CRF 22 the hero came out at 2.5 MB. Behind a scrim that is 38–96% - * opaque, CRF 28 is indistinguishable and 987 kB. + * lean on. At CRF 22 the hero came out at 2.5 MB; behind a scrim that is 38–96% + * opaque, CRF 28 was indistinguishable and 987 kB. + * + * **The hero CRFs moved in August 2026, and the number that was held constant + * was the size rather than the CRF.** The engine grew a specular sea with a + * swell normal map and sun glitter, which is high-frequency detail that changes + * every frame — the exact thing this encoder has no way to predict — so the same + * CRF 28 that bought 987 kB before now buys 1.31 MB of the same six seconds. + * The original decision was about a megabyte above the fold, not about the + * number 28, so the number moved to keep the megabyte: + * + * 1440w CRF 28 → 1.31 MB CRF 30 → 1004 kB CRF 31 → 892 kB + * 960w CRF 30 → 502 kB CRF 32 → 393 kB + * + * 30 and 32 are the ones that land back on the sizes the earlier pass measured. + * Checked rather than assumed: SSIM against a lossless re-encode of the master + * is 0.962 at CRF 28 and 0.952 at CRF 30, and the two were compared as pixels at + * 1:1 on frame 161 — the night frame, whose fine window-light speckle on dark + * ground is where blocking would show first — and are not tellable apart. Under + * the scrim there is nothing left to tell apart. + * + * The figures are untouched at CRF 22. They sit inline at 1280 with nothing over + * them, they are looked at deliberately rather than glanced past, and they do not + * download until they are on screen. */ const DELIVERABLES = { figure: [{ suffix: "", width: DELIVER_WIDTH, crf: 22 }], hero: [ - { suffix: "", width: 1440, crf: 28 }, - { suffix: "-sm", width: 960, crf: 30 }, + { suffix: "", width: 1440, crf: 30 }, + { suffix: "-sm", width: 960, crf: 32 }, ], }; const deliverablesFor = (spec) => (spec.chrome === "bare" ? DELIVERABLES.hero : DELIVERABLES.figure); @@ -102,6 +125,36 @@ const deliverSize = (spec) => { * other; for `office-dusk` it means a window narrow enough that the lights * coming on lasts half a second instead of one frame. `hero-soma` is the * exception, and argues for itself on its own entry. + * + * ### Why there are two posters and only one reel + * + * The stills are shot twice, day and night, because a still is one instant and + * the site serves the instant that matches the reader's theme. The obvious way + * to carry that over here is a day reel and a night reel per film, and it is the + * wrong answer twice over: it doubles the render, and it ships two crops of the + * same day to a reader who is allowed to see only one of them — for a film whose + * entire subject is that the light changes. + * + * Every reel below already spans both. `fidi-day` and `bay-relief-day` open at + * 04:40 in the dark and close at 22:40 in the dark; `office-dusk` starts in + * daylight and ends with the building lit from inside; `hero-soma` is a whole + * day by construction. Nothing about the moving picture is wrong on a dark page. + * + * What *is* wrong on a dark page is the frame that sits there before anybody + * presses anything. `Film` in `project-layout.tsx` ships `preload="none"`, so the + * poster is what most readers see and the only thing a reduced-motion reader ever + * sees — and `bay-relief-day`'s poster is ten to two in the afternoon, which on a + * black page is a lit rectangle. + * + * So: **one reel, two posters, both cut from that same reel.** `poster` is the + * daylight frame and `posterNight` the night one, each a fraction along the same + * render. It costs no extra filming at all — a poster is one PNG pulled out + * during encode — and it puts the figure at rest in the reader's own light, which + * is the thing the stills pipeline gets right and this one did not. + * + * Choosing `posterNight` by arithmetic and hoping is how you find out the frame + * was under a cloud. Shoot with `--keep-frames`, look at the PNGs, and move the + * fraction; see the flag's own note. */ const FILMS = [ { @@ -115,8 +168,16 @@ const FILMS = [ frames: 180, fps: 30, title: "Eighteen hours over the Financial District", - /** Which frame becomes the poster — the one worth stopping on. */ + /** + * Which frame becomes the poster — the one worth stopping on, twice. + * + * 0.86 is eight past eight in the evening, a few minutes before sunset: + * the last of the low sun down the length of Montgomery. 0.935 is half + * past nine, the hour the shot list uses for night, with the sun twelve + * degrees under and the tower windows carrying the frame. + */ poster: 0.86, + posterNight: 0.935, place: "Financial District, San Francisco", caption: "One camera, eighteen hours, six seconds. Nothing here is keyframed: every frame is the engine asked for a different instant, and the light, the shadows, the sky, the map in the corner and the window lights all follow from that one number. The readout at the top of the panel is the film captioning itself — the hour, how high the sun is, which band of twilight that puts it in, and how much of the moon is lit.", @@ -131,10 +192,38 @@ const FILMS = [ * column of interface underneath that headline is not atmosphere, it is * two interfaces arguing. * - * The camera is SoMa rather than the whole board, which was tried first: at - * that standoff the edge of the terrain plate is visible against the sky - * across the top of the frame, and midday is a lot of pale sand. Close in, - * the city fills the frame to every edge at every hour. + * The camera is SoMa rather than the whole board. **The original reason for + * that is dead and the conclusion survived it**, which is worth writing down + * so the question is not reopened a third time on the old evidence. + * + * Whole board was rejected in August 2026 because at that standoff the edge + * of the terrain plate showed against the sky across the top of the frame. + * That seam no longer exists: the sea now runs eighteen board spans and ends + * past the fog far plane, and the sky is a world-space dome rather than a + * screen-space gradient. So the camera was shot again — `--frames 24`, + * midnight to midnight, against `Whole Board`, `The Bay` and `Marin` — and + * all three lost, for reasons that have nothing to do with the old one: + * + * - **Whole Board** is now a weather picture, not a city one. The camera sits + * above the cloud deck and `atmosphere.ts` models the marine layer from its + * own diurnal curve, so most of the day is white. It is also, precisely, + * `bay-relief-day`'s camera — a hero that duplicates a figure two screens + * further down is a wasted first impression. + * - **The Bay** (chapter 11, three crossings ahead and two behind) is the + * handsomest of the four and still wrong for a hero: the sea is specular + * now, so the sun's glitter path lands as a blown white bloom across the + * middle third of the frame for most of the afternoon — which is exactly + * where the headline goes, and exactly what `npm run check:hero` measures. + * Both bridges are hairlines at 280 units of standoff. Keep it in mind as a + * *figure*, where nothing sits on top of it. + * - **Marin** is outside the pack's single `focusRegions` rectangle, so the + * terrain there is on the 450 m coarse lattice: a flat green blob for + * Tamalpais and a staircase for the coast. + * + * Close in, SoMa still fills the frame to every edge at every hour, and it + * gained the subject it used to lack — the Bay Bridge is a real suspension + * bridge in the right third of the frame now, with towers and cables, and + * the sea under it reflects. * * It is also the only one that runs **midnight to midnight**. The others * crop to the part of the day that moves; this one cannot, because the page @@ -155,8 +244,13 @@ const FILMS = [ fps: 30, viewport: HERO_VIEWPORT, chrome: "bare", - /** Whole-day, so this is the only film where the poster hour is a choice about light. */ + /** + * Whole-day, so this is the only film where the poster hours are a free + * choice rather than a crop of one. 0.33 is ten to eight in the morning — + * the relief hour — and 0.90 is half past nine at night. + */ poster: 0.33, + posterNight: 0.901, title: "San Francisco, one whole day", place: "SoMa, San Francisco", caption: @@ -204,9 +298,33 @@ const FILMS = [ * The poster moved off 0.20 for a related reason: 0.20 is twenty past eight * in the morning, which is now a white rectangle. 0.51 is ten to two, the * clearest the board gets. + * + * The night poster is the one place on this film where the cloud model has + * to be argued with rather than admired, and it is the reason + * `--keep-frames` exists at all. + * + * The arithmetic answer was 0.894 — a quarter to nine, after sunset and + * before the layer was expected to close. Looking at the kept PNGs said + * otherwise: by 20:44 the deck is already back over the board and the frame + * is a featureless purple wash, which is the old 0.20 failure with the + * colours inverted. Frames 148 through 172 were compared as pictures, and + * **every post-sunset frame on this camera is under cloud** — that is not a + * bad hour to have picked, it is what this film is about, and the caption + * already says the deck closes in again after sunset. + * + * So the night poster is chosen for what survives the cloud rather than for + * a clear sky that is not on offer. 0.96 is 21:57, sun 18.6° under: dark + * enough to belong on a black page, and late enough that the built ground + * reads through the gaps as speckled clusters — San Jose, the East Bay, the + * peninsula — which is the last line of the caption arriving as a picture. + * Five aircraft and their tracks are in it too. + * + * If the cloud model is ever retuned, re-shoot with `--keep-frames` and look + * again rather than trusting this number. */ title: "A day across the whole board", poster: 0.51, + posterNight: 0.96, place: "San Francisco Bay Area", caption: "Relief is easiest to read when the light moves, and this camera is high enough to read the weather the same way. With nobody to ask for an observation — which is what a fresh clone of this engine has, and what this camera had — the sky is modelled from the Pacific marine layer's own daily curve instead of left empty: the deck that covers the board before dawn burns back through the afternoon and closes in again after sunset. Under it, shadow runs the length of two mountain ranges and back — and at the end the thing the terrain never told you comes through the gaps, which is the cities, which are somewhere else entirely.", @@ -216,60 +334,93 @@ const FILMS = [ /** * The one film the stills cannot stand in for. * - * `office-floor` in `shots.mjs` is this exact camera at two hours — 11:20 and - * 21:30 — and that pair is a fair account of both of them. What a pair cannot - * show is the part in between, which is the part that is actually the claim: - * the building has no night theme, it has a sun going down, and the fittings - * come up as it does. An event with a duration wants the medium that has one. + * **This entry was rewritten from scratch in August 2026, because the + * building it described was demolished.** It used to be forty-eight metres + * by eighteen, two storeys with fourteen metres of interstitial between + * them, four Optimus units walking two floor plates — and the camera was + * `chapter: 0`, `expect: "The Floor"`. `The Floor` does not exist any more. + * Chapter 0 is `Front Door` now, and the `expect` guard is what stopped this + * script rather than letting it photograph an arrival pose and caption it + * with an open-plan office that is gone. That is the entire reason the guard + * is there; do not weaken it. + * + * What stands at `lumbridge-hq` today is a twelve by nine metre live/work + * studio on one level: a two-place agent bench, a demo lounge with a real + * sofa facing a shared wall, a kitchen island with stools, a sleeping alcove + * and a bath. The id is unchanged on purpose — journey snapshots, presence, + * media grants and self-hosted deployments all address it — so the film id + * stays `office-dusk` too, and nothing on the site has to be renamed. + * + * `office-floor` in `shots.mjs` is this exact camera at two hours — 11:20 + * and 21:30 — and that pair is a fair account of both of them. What a pair + * cannot show is the part in between, which is the part that is actually the + * claim: the building has no night theme, it has a sun going down, and the + * fittings come up as it does. An event with a duration wants the medium + * that has one. * * **The window is cut to the switch-over, not to the working day.** - * `luminaires.ts` ramps the house lights from nothing at six degrees of solar - * elevation to full at zero, and over this building's own coordinate on this - * date that band is 19:38 to 20:12 — thirty-four minutes. Spread across the - * eighteen-hour window the city films use it would be six frames and a - * blink. Across these six hours it is seventeen, a little over half a second, - * with another thirty-nine frames of twilight behind it in which the fittings - * hold the building on their own. Dawn does the same thing in reverse and is - * deliberately out of frame: one legible switch-over beats two unreadable - * ones, and a sixteen-hour window buys the second one by making both a blur. + * `luminaires.ts` ramps the house lights from nothing at six degrees of + * solar elevation to full at zero, and over this building's own coordinate + * — Transbay, 37.79N, which the rebuild did not move — on this date that + * band is 19:38 to 20:12: thirty-four minutes. Spread across the eighteen- + * hour window the city films use it would be six frames and a blink. Across + * these six hours it is seventeen, a little over half a second, with another + * thirty-nine frames of twilight behind it in which the fittings hold the + * room on their own. Dawn does the same thing in reverse and is deliberately + * out of frame: one legible switch-over beats two unreadable ones. * * The other end is chosen too. Solar noon here is 13:15 with the sun at 69°, * which is straight down into an open-topped model and flat on every surface - * — the interior version of the midday problem the shot list keeps away from. - * By half past three the sun is at 54° and falling, and the shadows on the - * two plates have somewhere to go for the whole reel. + * — the interior version of the midday problem the shot list keeps away + * from. By half past three the sun is at 54° and falling, and the shadows on + * the floor have somewhere to go for the whole reel. * * The last frame is 21:30, which is `office-floor`'s night frame exactly. So * the film ends on the still, and anyone who suspects one of them is lying * can put them side by side. * - * `chapter: 0` is the arrival pose and needs no click. It is also the only - * camera that holds all forty-eight metres of the plate and both storeys at - * once, which is what makes this read as a *building* changing state rather - * than as one room dimming. The Commons was the other candidate: it shows the - * void far better and the ceiling far worse, and the ceiling is the subject. + * `chapter: 1` — `The Whole Studio` — is the only camera that holds all + * twelve metres and every zone at once, which is what makes this read as a + * *building* changing state rather than as one lamp coming on. The Front + * Door is the arrival pose and needs no click, which is why it was tempting; + * it is also framed at 11.8 units on the threshold and cuts the kitchen off. * - * Lumbridge HQ rather than Frontier Valley, and the hangar is the better - * second film than first — it is one storey and two robots against two - * storeys and four, its lights are a single run of trusses, and reaching it - * needs the office-picker step this script does not have. See the note above + * Lumbridge HQ rather than Frontier Valley or the LA courtyard, and the + * hangar is still the better second film than first — reaching either needs + * the office-picker press this script does not have. See the note above * `FILMS`. */ id: "office-dusk", door: "office", - chapter: 0, - expect: "The Floor", + chapter: 1, + expect: "Studio", from: "2026-08-06T15:30:00-07:00", to: "2026-08-06T21:30:00-07:00", frames: 180, fps: 30, - /** Frame 147, twenty-five past eight: fittings at full, sky not yet black. */ - poster: 0.82, - title: "Six hours in the office, and the lights taking over", - place: "Lumbridge HQ", + /** + * Both picked off the kept PNGs rather than off the clock, and one of them + * moved because of it. + * + * 0.25 — five past five, sun at 34° — was the arithmetic answer and it is + * the flat one: the light is nearly down the camera's own axis, the walls + * are one value and the floor has no shadow on it. That is the midday + * problem in miniature, which is the thing the 15:30 start was chosen to + * avoid, arriving anyway because the poster was chosen separately from the + * window. 0.53 is twenty to seven with the sun at 13°, warm and raking the + * length of the floor, and the panel still reads `day` beside it. + * + * 0.90 is five to nine: sun 12° under, the sky behind the glass gone deep + * blue, and the room lit entirely by its own fittings. It is the same claim + * as the title, at rest. + */ + poster: 0.53, + posterNight: 0.9, + title: "Six hours in the studio, and the lights taking over", + place: "SF HQ · Studio, Transbay", caption: - "Forty-eight metres by eighteen, two storeys with fourteen metres of interstitial between them, and a sun that belongs to the building rather than to the picture: the pack declares where on the earth it stands, so this floor is lit by the real afternoon over Transbay and switches to its own fittings as that afternoon ends. The changeover is a ramp across six degrees of solar elevation rather than a theme, which is why it takes a film to show it at all. The four Optimus units walking the two floors brighten whatever fitting they pass under.", - alt: "A time-lapse of an office interior seen from outside as an open-topped model, both storeys visible at once and held a long way apart. Afternoon daylight rakes across the two floor plates and the sky behind the building warms to orange and then goes dark blue; the ceiling fittings on both levels come up as it does, until the building is lit entirely from within. Small figures sit at the desks and walk between them.", + "Twelve metres by nine, one level, and a sun that belongs to the building rather than to the picture: the pack declares where on the earth it stands, so this floor is lit by the real afternoon over Transbay and switches to its own fittings as that afternoon ends. The changeover is a ramp across six degrees of solar elevation rather than a theme, which is why it takes a film to show it at all. The panel beside it is reading the same clock, and the studio hardware under it says in as many words that its levels are simulated.", + alt: "A time-lapse of a small live/work studio seen from outside as an open-topped model: a two-place desk with monitors and task chairs, a glass-walled lounge with a sofa, a kitchen island, a bookshelf and a bed in an alcove, all on one floor. Afternoon daylight rakes across the floor and the sky behind the building warms to orange and then goes deep blue; the ceiling fittings come up as it does, until the room is lit entirely from within. Two humanoid robots move about the floor and a dog crosses it.", }, ]; @@ -293,6 +444,30 @@ const only = flag("only")?.split(",").map((s) => s.trim()); * something to publish by accident. */ const frameOverride = flag("frames") ? Number(flag("frames")) : null; +/** + * Leave the PNG frames on disk instead of deleting them after the encode. + * + * This exists because of one specific, expensive asymmetry. Everything about a + * film can be changed after the fact for the price of an ffmpeg run — the CRF, + * the delivered width, the caption, which files ship — because the master is a + * near-lossless archive and the deliverables come off it. **The poster fractions + * could not**, because the poster is cut from the PNGs and the PNGs were gone + * the moment `encode()` returned. Moving a poster cost a re-shoot. + * + * That was survivable with one poster and a fraction picked off a rough cut. It + * stopped being survivable with two, because a night poster cannot be chosen by + * arithmetic: `bay-relief-day` at half past nine is under a closed marine layer + * and looks like nothing, and you cannot know that from the clock. So: shoot + * once with `--keep-frames`, look at the candidate frames, and if a fraction is + * wrong, change it and re-run that one film. + * + * The frames are not small — measured on this pass, 180 PNGs of the FiDi camera + * at 1440x900 came to 122 MB, and the hero at 1600x900 rather more — which is + * why this is a flag and not the default. They live in `films/.frames//`, + * and the next run of the same film deletes them whether or not it was asked to + * keep its own, so a full set never accumulates twice. + */ +const keepFrames = process.argv.includes("--keep-frames"); const outRoot = resolve(flag("out", join(ROOT, "films"))); const siteDir = resolve(flag("site", join(ROOT, "..", "lumbridge-v4"))); /** Rewrite the site's manifest from the film list without filming anything. */ @@ -416,6 +591,24 @@ async function film(browser, spec, dir) { // itself, and the only thing on screen proving the light follows a real // time rather than a hand-keyed fade. A `bare` film is going behind // somebody else's headline and takes all of it off. + // + // Both lists come from `harness.mjs` rather than being extended here, and + // that is worth one line of defence because this pass nearly did extend + // them. `#mode-dock` — the View / Drive / Explore / Fly / Walk pill along + // the bottom of the canvas — arrived after those lists were written, and it + // was sitting in the bottom centre of the first hero rough cut of this pass: + // a picture that is meant to carry nothing but a headline, with a control + // pill in it, rendering perfectly and looking entirely deliberate. That is + // the hazard `FURNITURE`'s own comment names, and the answer to it is the + // one shared list, not a private patch in each script that photographs the + // app. It is in `FURNITURE.BARE` now, along with `#play-hud`. + // + // It is *not* in `CLUTTER`, so the figures keep it, and that is deliberate + // rather than an omission: the figures keep the panel because the dock and + // the panel are both true and readable instruments — the dock says this + // board can be driven, flown and walked, which is a claim the page makes in + // words a couple of paragraphs away. `CLUTTER` drops what is untrue outside + // the capture or unreadable inside it, and the dock is neither. await hide(page, [ ...FURNITURE.transient, ...(spec.chrome === "bare" ? FURNITURE.BARE : FURNITURE.CLUTTER), @@ -459,14 +652,18 @@ async function film(browser, spec, dir) { * a good deal of Android will not decode the file at all, and without the * second the index sits at the end and playback waits for the whole download. * - * The poster comes off the PNG, not off the master, because it is a still and - * has no reason to inherit a video codec's compromises. The price of that is - * worth knowing before you go looking for a flag that does not exist: the frames - * are deleted the moment this returns, so **moving a `poster` fraction costs a - * re-shoot**, where moving a CRF costs ten seconds. Everything else about a film - * can be changed for free; that one cannot. + * The posters come off the PNGs, not off the master, because they are stills and + * have no reason to inherit a video codec's compromises. There are two of them — + * a daylight frame and a night one, cut from the same reel, so that the figure at + * rest is in the reader's own light; the note above `FILMS` argues for that over + * a second reel. + * + * The price of cutting them from the PNGs used to be that the frames were deleted + * the moment this returned, so **moving a poster fraction cost a re-shoot** where + * moving a CRF costs ten seconds. `--keep-frames` is the answer to that; see its + * note. Everything else about a film can still be changed for free. */ -function encode(dir, out, fps, frames, posterAt, deliverables) { +function encode(dir, out, fps, frames, posters, deliverables) { const ff = (...args) => execFileSync("ffmpeg", ["-y", "-loglevel", "error", ...args]); const master = `${out}-master.mp4`; ff( @@ -490,16 +687,36 @@ function encode(dir, out, fps, frames, posterAt, deliverables) { `${out}${d.suffix}.mp4`, ); } - const posterFrame = Math.min(frames - 1, Math.round((frames - 1) * posterAt)); - ff( - "-i", join(dir, `f-${String(posterFrame).padStart(4, "0")}.png`), - "-vf", `scale=${deliverables[0].width}:-2:flags=lanczos`, - "-quality", "82", - `${out}-poster.webp`, - ); - return posterFrame; + const cut = (at, suffix) => { + const frame = Math.min(frames - 1, Math.max(0, Math.round((frames - 1) * at))); + ff( + "-i", join(dir, `f-${String(frame).padStart(4, "0")}.png`), + "-vf", `scale=${deliverables[0].width}:-2:flags=lanczos`, + "-quality", "82", + `${out}${suffix}.webp`, + ); + return frame; + }; + return { + day: cut(posters.day, "-poster"), + night: cut(posters.night, "-poster-night"), + }; } +/** + * Every file a film ships, in one place. + * + * `publish()` and the end-of-run copy both walk this, so a new deliverable — + * the night poster was one — cannot be added to the encoder and forgotten in + * the copy, which would leave the manifest naming a `` that 404s while + * every other check passed. + */ +const shippedNames = (spec) => [ + ...deliverablesFor(spec).map((d) => `${spec.id}${d.suffix}.mp4`), + `${spec.id}-poster.webp`, + `${spec.id}-poster-night.webp`, +]; + // ---- Run -------------------------------------------------------------------- const exists = (p) => access(p).then(() => true, () => false); @@ -528,23 +745,40 @@ async function publish(fromDir) { await mkdir(publicDir, { recursive: true }); let copied = 0; const kept = []; + /** + * Every file, not just the first one. + * + * This used to test `names[0]` — the main mp4 — and take its presence as proof + * the whole set was there. That was true for exactly as long as the set never + * changed. The moment the night poster was added, an older render directory or + * an already-published film from before the change satisfied `names[0]` + * perfectly and was waved through **missing a file the manifest was about to + * name**, which is a 404 behind an `` that nothing else in this pipeline + * would have caught. A render that predates a deliverable has to be re-shot, + * and the way to find that out is here rather than in a browser. + */ + const complete = async (dir, names) => { + for (const name of names) if (!(await exists(join(dir, name)))) return false; + return true; + }; for (const spec of FILMS) { - const names = [ - ...deliverablesFor(spec).map((d) => `${spec.id}${d.suffix}.mp4`), - `${spec.id}-poster.webp`, - ]; - if (await exists(join(fromDir, names[0]))) { + const names = shippedNames(spec); + if (await complete(fromDir, names)) { for (const name of names) await copyFile(join(fromDir, name), join(publicDir, name)); copied += 1; continue; } - if (await exists(join(publicDir, names[0]))) { + if (await complete(publicDir, names)) { kept.push(spec.id); continue; } + const partial = (await exists(join(fromDir, names[0]))) || (await exists(join(publicDir, names[0]))); throw new Error( - `${spec.id} is neither in ${fromDir} nor already published — film it before the ` + - `manifest names it`, + partial + ? `${spec.id} is present but incomplete — it is missing at least one of ` + + `${names.join(", ")}, so it predates a deliverable this script now ships. Re-film it.` + : `${spec.id} is neither in ${fromDir} nor already published — film it before the ` + + `manifest names it`, ); } console.log(`site ${copied} film(s) copied → ${publicDir}`); @@ -579,13 +813,22 @@ try { const shot = await film(browser, spec, framesDir); const out = join(outDir, spec.id); - const posterFrame = encode(framesDir, out, spec.fps, shot, spec.poster, deliverablesFor(spec)); - await rm(framesDir, { recursive: true, force: true }); + const posterFrames = encode( + framesDir, + out, + spec.fps, + shot, + { day: spec.poster, night: spec.posterNight }, + deliverablesFor(spec), + ); + if (!keepFrames) await rm(framesDir, { recursive: true, force: true }); made.push({ spec, out }); const seconds = (shot / spec.fps).toFixed(1); - console.log(` ${shot} frames → ${seconds}s at ${spec.fps}fps, poster from frame ${posterFrame}`); + console.log(` ${shot} frames → ${seconds}s at ${spec.fps}fps`); + console.log(` posters from frames ${posterFrames.day} (day) and ${posterFrames.night} (night)`); console.log(` ${out}.mp4`); + if (keepFrames) console.log(` frames kept → ${framesDir}`); } await writeFile( @@ -603,11 +846,10 @@ try { console.log(`site skipped — this was a ${frameOverride}-frame rough cut`); } else { await mkdir(publicDir, { recursive: true }); - for (const { spec, out } of made) { - for (const d of deliverablesFor(spec)) { - await copyFile(`${out}${d.suffix}.mp4`, join(publicDir, `${spec.id}${d.suffix}.mp4`)); + for (const { spec } of made) { + for (const name of shippedNames(spec)) { + await copyFile(join(outDir, name), join(publicDir, name)); } - await copyFile(`${out}-poster.webp`, join(publicDir, `${spec.id}-poster.webp`)); } console.log(`site ${made.length} film(s) → ${publicDir}`); // As in `shots.mjs`: a partial run must not rewrite a manifest that would @@ -637,6 +879,7 @@ function manifest() { : "" } poster: ${JSON.stringify(`/films/${f.id}-poster.webp`)}, + posterNight: ${JSON.stringify(`/films/${f.id}-poster-night.webp`)}, width: ${deliverSize(f).w}, height: ${deliverSize(f).h}, seconds: ${Number((f.frames / f.fps).toFixed(2))}, @@ -673,7 +916,22 @@ export interface Film { src: string; /** A narrower encode for small screens, where one exists. Same film, fewer bytes. */ srcSmall?: string; + /** + * The daylight poster, and the night one cut from the same reel. + * + * Pick by the reader's resolved theme, the way \`shots.ts\` consumers already + * do — \`useResolvedTheme()\` in \`apps/web/src/theme.ts\`, never a + * \`prefers-color-scheme\` media query, which sees the OS and is blind to the + * site's own toggle. + * + * This matters more than a poster usually would. \`Film\` ships + * \`preload="none"\`, so the poster is what most readers see before they press + * anything, and it is the *only* thing a reduced-motion reader ever sees. The + * reel itself is single-variant on purpose: every film here spans the whole + * change of light, so the moving picture is right on either page. + */ poster: string; + posterNight: string; width: number; height: number; /** Running time. Used to decide whether a pause control is required; it is. */ diff --git a/scripts/brand-assets/harness.mjs b/scripts/brand-assets/harness.mjs index 0e172ef..b137052 100644 --- a/scripts/brand-assets/harness.mjs +++ b/scripts/brand-assets/harness.mjs @@ -190,7 +190,18 @@ export function clockShim(iso) { export const FURNITURE = { /** Never wanted in a photograph: the boot curtain and the phone's panel scrim. */ transient: ["#boot", "#scrim", "#onboarding-host"], - BARE: ["#panel", "#panel-toggle", "#corner", "#rail", "#source", "#tier", "#onboarding-host"], + /* + * `#mode-dock` and `#play-hud` joined this list after they turned up in the + * shipped share card. The dock is the VIEW / DRIVE / EXPLORE / FLY pill and the + * HUD is the drive readout, and both are exactly the app furniture a card + * replaces with its own typography — but because they are new, they arrived in + * the art silently, the way `#onboarding-host` did. Anything mounted over the + * canvas belongs in one of these two lists on the day it is added. + */ + BARE: [ + "#panel", "#panel-toggle", "#corner", "#rail", "#source", "#tier", + "#onboarding-host", "#mode-dock", "#play-hud", + ], CLUTTER: ["#rail", "#tier", "#source", "#panel-toggle", "#onboarding-host"], }; diff --git a/scripts/brand-assets/og.html b/scripts/brand-assets/og.html index 4aa9098..1f6b29c 100644 --- a/scripts/brand-assets/og.html +++ b/scripts/brand-assets/og.html @@ -196,7 +196,13 @@ // rather than a screen-space gradient, so the world dissolves into the // horizon instead of stopping at a visible edge. The framing is free to // sit where the picture is best rather than where the defect was not. - position: "56% 62%", + // + // The state board is shot on a 2000x900 sensor, which is *wider* than + // the card, so `cover` crops horizontally and not at all vertically: + // the whole state is in frame top to bottom, and this number decides + // which two hundred pixels of ocean go. 0% keeps the western sea. + position: "0% 50%", + shift: 150, }, office: { eyebrow: "Lumbridge Spaces", @@ -205,6 +211,7 @@ host: "office.", art: "art-office.png", position: "62% 46%", + shift: 0, }, }; @@ -219,6 +226,20 @@ const art = document.getElementById("art"); art.style.backgroundImage = 'url("' + card.art + '")'; art.style.backgroundPosition = card.position; + /* + Slide the whole art layer right, and let the card clip it. + + `cover` can crop a subject toward an edge; it cannot move a subject that + is already centred, because the offset it computes is bounded by the + overflow. The state board is centred in the app frame — the camera is — + and the left 58% of this card is scrim, so without this the headline sits + on top of California rather than beside it. Moving the layer instead of + the crop leaves the exposed strip at the far left showing the card's own + background, which is what the scrim was already painting there anyway. + */ + const shift = card.shift ?? 0; + art.style.left = shift + "px"; + art.style.right = -shift + "px"; document.title = which; diff --git a/scripts/brand-assets/refresh.mjs b/scripts/brand-assets/refresh.mjs new file mode 100644 index 0000000..bb0a090 --- /dev/null +++ b/scripts/brand-assets/refresh.mjs @@ -0,0 +1,459 @@ +/** + * Re-shoot everything the site shows of this engine, in one command. + * + * npm run refresh # build, stills, cards, films, manifests + * npm run refresh -- --dry-run # what it would shoot; no browser, no build + * npm run refresh -- --stills-only + * npm run refresh -- --cards-only + * npm run refresh -- --films-only + * npm run refresh -- --rough # films at 24 frames, for looking at + * npm run refresh -- --skip-build # you just built; do not build again + * + * ### Why this exists + * + * The imagery on lumbridgecorp.com is four artefacts produced by three scripts + * into two repos: product stills and a TypeScript manifest into `lumbridge-v4`, + * time-lapse films and a second manifest into the same place, and two share + * cards into this repo's `public/`. Re-shooting after an engine change was a + * *procedure* — build first, because the scripts photograph `dist/` and not + * `src/`; then three commands in an order that matters; then check the run + * actually got the GPU; then commit this repo before regenerating the manifest + * so it names a clean sha. A procedure that lives in somebody's head is a + * procedure that gets half-run, and the evidence is in the tree: the stills were + * re-shot at one commit, the films at another two weeks older, and nothing said + * so until a human compared two files. + * + * So: one command, and the parts you can skip are flags rather than the memory + * of which script does what. + * + * ### The two failures this must never report as success + * + * Both of them produce output that looks deliberate, which is why they are + * checked here rather than left to the eye: + * + * 1. **SwiftShader.** `launch()` in `harness.mjs` asks for `--use-angle=vulkan` + * and falls back to software when the card is not reachable, printing which + * it got. The pictures are the same pictures — measured, half a level out of + * 255 — but a film goes from ninety seconds to twenty-two minutes, so a run + * that has silently fallen back reads as a hang. Every child's output is + * scanned for that line and the run stops on it. + * + * 2. **A chapter `expect` guard firing.** Chapters are pack data and pack data + * gets reordered; the guard in `shots.mjs` refuses to shoot when the button at + * an index is not the chapter the shot list names. It has already earned its + * keep once, when the studios were rebuilt and chapter 0 became "Front Door". + * When it fires, the answer is to fix the shot list, not to re-run — so this + * surfaces it as its own headline rather than as one line of a stack trace + * fifty lines up the log. + * + * ### It re-stamps the provenance manifest, narrowly + * + * `PROVENANCE.json` records a SHA-256 for every tracked binary and + * `npm run provenance` fails on a mismatch, so every legitimate re-shoot of the + * share cards used to leave that gate red. This re-stamps an entry only when the + * file is one this run wrote, its `origin` is `repository-generated`, and its + * `generator` names a script this run actually executed. Every other mismatch is + * reported and left alone, because a hash that moved without this pipeline + * running is the thing the gate exists to catch. + * + * ### What "changed" means in the summary + * + * Every deliverable is hashed before and after, so the summary can say which + * pictures actually moved rather than which commands were run. Renders are never + * byte-identical — aircraft are crossing and cloud shadow is drifting between any + * two runs — so "changed" here means the bytes differ, not that the framing did. + * The size delta is the useful column: a WebP that halved or doubled is a frame + * worth opening. + */ + +import { spawn } from "node:child_process"; +import { createHash } from "node:crypto"; +import { fileURLToPath } from "node:url"; +import { dirname, join, relative, resolve } from "node:path"; +import { readdir, readFile, writeFile } from "node:fs/promises"; + +const HERE = dirname(fileURLToPath(import.meta.url)); +const ROOT = join(HERE, "..", ".."); + +// ---- 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 has = (name) => process.argv.includes(`--${name}`); + +const dryRun = has("dry-run"); +const rough = has("rough"); +const skipBuild = has("skip-build"); +const siteDir = resolve(flag("site", join(ROOT, "..", "lumbridge-v4"))); + +/** + * `--stills-only` and friends select; naming none of them selects all three. + * + * They compose, so `--stills-only --cards-only` is "stills and cards", which is + * the pair you want after a lighting change and before you are ready to pay for + * films. + */ +const picked = ["stills", "cards", "films"].filter((part) => has(`${part}-only`)); +const parts = new Set(picked.length ? picked : ["stills", "cards", "films"]); + +// ---- What each part writes --------------------------------------------------- + +/** + * The deliverables, by part, so the summary can be computed rather than claimed. + * + * `optional` covers the normal case for anyone who cloned this repo on its own: + * there is no sibling `lumbridge-v4`, the scripts say so and skip the site step, + * and a missing directory here is a note rather than a failure. + */ +const OUTPUTS = { + stills: [ + { dir: join(siteDir, "apps", "web", "public", "shots"), match: /\.webp$/, optional: true }, + { dir: join(siteDir, "apps", "web", "src", "data"), match: /^shots\.ts$/, optional: true }, + ], + cards: [{ dir: join(ROOT, "public"), match: /^og-.*\.png$/ }], + films: [ + { dir: join(siteDir, "apps", "web", "public", "films"), match: /\.(mp4|webp|png|jpg)$/, optional: true }, + { dir: join(siteDir, "apps", "web", "src", "data"), match: /^films\.ts$/, optional: true }, + ], +}; + +async function fingerprint() { + const seen = new Map(); + for (const part of parts) { + for (const { dir, match } of OUTPUTS[part]) { + let names; + try { + names = await readdir(dir); + } catch { + continue; // Not there yet, or no site checkout. `report` says so. + } + for (const name of names) { + if (!match.test(name)) continue; + const path = join(dir, name); + const body = await readFile(path); + const sha256 = createHash("sha256").update(body).digest("hex"); + seen.set(path, { size: body.length, hash: sha256.slice(0, 12), sha256 }); + } + } + } + return seen; +} + +// ---- Running a step ---------------------------------------------------------- + +/** + * The two lines that mean "this run's output is not to be trusted". + * + * `launch()` prints exactly one of these per browser it opens, so matching the + * text it prints is matching the check it already does rather than repeating it. + */ +const SOFTWARE_RENDERER = /GPU:.*(SwiftShader|llvmpipe)|no GPU \(/i; +/** `chapter()` in `shots.mjs`, and the office picker's equivalent beside it. */ +const PACK_REORDERED = /a city pack was reordered|the OFFICES table\s*\n?\s*changed|is "[^"]*", not "/; + +class StepFailure extends Error { + constructor(step, headline, detail) { + super(headline); + this.step = step; + this.headline = headline; + this.detail = detail; + } +} + +/** + * Run one child, streaming its output, and read that output as it goes. + * + * Streaming *and* buffering, because both are wanted: a film is ninety seconds + * of silence otherwise, and the two failures above have to be findable after the + * fact. The buffer is a few kilobytes of console output, not the frames. + */ +function run(step, command, args, { cwd = ROOT } = {}) { + return new Promise((resolveRun, rejectRun) => { + const started = Date.now(); + const child = spawn(command, args, { cwd, stdio: ["ignore", "pipe", "pipe"] }); + let log = ""; + const watch = (stream, sink) => { + stream.setEncoding("utf8"); + stream.on("data", (chunk) => { + log += chunk; + sink.write(chunk); + }); + }; + watch(child.stdout, process.stdout); + watch(child.stderr, process.stderr); + child.on("error", (error) => rejectRun(new StepFailure(step, `could not start ${command}`, String(error)))); + child.on("close", (code) => { + const seconds = ((Date.now() - started) / 1000).toFixed(1); + if (SOFTWARE_RENDERER.test(log)) { + rejectRun( + new StepFailure( + step, + "the renderer came up as SwiftShader, not the card", + "Everything this run produced is software-rendered: same pictures, roughly fifteen times\n" + + "the wall clock, and a film that reads as a hang. Nothing has been published from it.\n" + + "Chrome reaches the Radeon with `--use-angle=vulkan` and needs read access to\n" + + "/dev/dri/renderD128, which group `render` grants — check `id` and `ls -l /dev/dri`,\n" + + "then re-run. `harness.mjs` explains why no other GL flag is a substitute.", + ), + ); + return; + } + if (code !== 0 && PACK_REORDERED.test(log)) { + rejectRun( + new StepFailure( + step, + "a chapter guard fired — the shot list no longer matches the packs", + "A shot names a chapter by index and asserts its short label before the shutter opens,\n" + + "and the assertion failed. This is not a flake and re-running will not fix it: a pack was\n" + + "reordered or renamed underneath the shot list, so every camera and caption downstream of\n" + + "that index is now pointed at something else.\n" + + "Fix `SHOTS` in shots.mjs against the pack (`node scripts/brand-assets/shots.mjs --list`\n" + + "prints what it believes), then run this again. Do not weaken the guard.", + ), + ); + return; + } + if (code !== 0) { + rejectRun(new StepFailure(step, `${step} failed (exit ${code})`, "The child's own output is above.")); + return; + } + resolveRun({ seconds, log }); + }); + }); +} + +// ---- The plan ---------------------------------------------------------------- + +const NODE = process.execPath; +const script = (name) => join(HERE, name); + +/** + * The film list, read out of `films.mjs` rather than imported from it. + * + * `films.mjs` is a script with top-level effects — importing it opens a browser — + * and it belongs to a different workstream, so this reads its `FILMS` ids for the + * dry run and treats a miss as "cannot say" rather than as an error. The command + * printed underneath is the truth either way. + */ +async function filmIds() { + try { + const body = await readFile(script("films.mjs"), "utf8"); + const from = body.indexOf("const FILMS = ["); + if (from === -1) return null; + const ids = [...body.slice(from).matchAll(/^\s{4}id: "([^"]+)"/gm)].map((m) => m[1]); + return ids.length ? ids : null; + } catch { + return null; + } +} + +async function plan() { + console.log(`refresh ${[...parts].join(", ")}${rough ? " (films at 24 frames)" : ""}`); + console.log(`site ${siteDir}`); + console.log(""); + if (parts.has("stills") || parts.has("cards")) { + await run("list", NODE, [script("shots.mjs"), "--list"]); + console.log(""); + } + if (parts.has("cards")) { + console.log("cards 2 renders of the running app, then og.html over each at 1200x630"); + console.log(" og-tera.png tera.lumbridgecorp.com"); + console.log(" og-office.png office.lumbridgecorp.com"); + console.log(""); + } + if (parts.has("films")) { + const ids = await filmIds(); + console.log(`films ${ids ? `${ids.length} reels: ${ids.join(", ")}` : "reel list is films.mjs's own"}`); + console.log(` ${NODE} ${relative(ROOT, script("films.mjs"))}${rough ? " --frames 24" : ""}`); + console.log(""); + } + console.log("nothing was rendered — this was --dry-run"); +} + +// ---- Provenance -------------------------------------------------------------- + +/** + * Re-stamp the SHA-256 of the artifacts this run generated, and nothing else. + * + * `PROVENANCE.json` records a hash for every tracked binary, and + * `npm run provenance` fails when one does not match. That is the right gate — + * it is what stops an art file arriving from somewhere nobody can name — but the + * two share cards are *outputs of this pipeline*, so every legitimate re-shoot + * left the gate red and a human had to work out which of two hashes went in + * which of two records. That is precisely the remembered procedure this script + * exists to delete. + * + * The rule is deliberately narrow, so this stays bookkeeping rather than a hole + * in the gate. An entry is re-stamped only when **all** of these hold: + * + * - the file is one this run wrote, in a directory this run owns; + * - `origin` is `repository-generated` — never a copied upstream item; + * - `generator` names a script this run actually executed. + * + * Anything else that has drifted is reported and left exactly as it was, because + * a hash that changed without this pipeline running is the thing the gate is for. + */ +async function restampProvenance(written, ranGenerators) { + const path = join(ROOT, "PROVENANCE.json"); + let body; + let manifest; + try { + body = await readFile(path, "utf8"); + manifest = JSON.parse(body); + } catch (error) { + console.log(`\nprovenance could not be read (${error.message}) — left alone`); + return; + } + const stamped = []; + const refused = []; + for (const entry of manifest.distributedArtifacts ?? []) { + const full = join(ROOT, entry.path); + const now = written.get(full); + if (!now || now.sha256 === entry.sha256) continue; + const mine = + entry.origin === "repository-generated" && + ranGenerators.some((generator) => entry.generator === generator); + if (!mine) { + refused.push(entry.path); + continue; + } + /* + * Substitute the hash in the *text*, not by re-serialising the object. + * `JSON.stringify(…, null, 2)` reflows every array in the file, so a + * two-hash update arrived as a fourteen-line diff across records this run + * had nothing to do with — which is the last thing a licensing manifest + * should do. A SHA-256 is 64 hex characters and unique in this file, so a + * literal replacement is exact. + */ + if (!body.includes(entry.sha256)) { + refused.push(`${entry.path} (recorded hash not found verbatim)`); + continue; + } + body = body.replace(entry.sha256, now.sha256); + stamped.push(entry.path); + } + if (stamped.length) { + await writeFile(path, body, "utf8"); + console.log(`\nprovenance re-stamped ${stamped.length}: ${stamped.join(", ")}`); + } + if (refused.length) { + console.log( + `\nprovenance ${refused.length} artifact(s) no longer match their recorded hash and were NOT` + + ` touched:\n ${refused.join("\n ")}\n` + + " Nothing this run generated wrote them. Find out what did before committing.", + ); + } +} + +// ---- The summary ------------------------------------------------------------- + +function report(before, after, timings) { + const paths = [...new Set([...before.keys(), ...after.keys()])].sort(); + const added = []; + const changed = []; + const same = []; + const gone = []; + for (const path of paths) { + const was = before.get(path); + const now = after.get(path); + if (!was) added.push({ path, now }); + else if (!now) gone.push({ path, was }); + else if (was.hash !== now.hash) changed.push({ path, was, now }); + else same.push({ path }); + } + + const kb = (n) => `${Math.round(n / 1024)}kB`; + const show = (entries, label, line) => { + if (!entries.length) return; + console.log(`\n${label} (${entries.length})`); + for (const entry of entries) console.log(` ${line(entry)}`); + }; + + console.log("\n" + "─".repeat(72)); + for (const [step, seconds] of timings) console.log(`${step.padEnd(10)} ${seconds}s`); + show(added, "new", ({ path, now }) => `${relative(ROOT, path).padEnd(58)} ${kb(now.size)}`); + show( + changed, + "changed", + ({ path, was, now }) => { + const delta = now.size - was.size; + const sign = delta > 0 ? "+" : ""; + return `${relative(ROOT, path).padEnd(58)} ${kb(now.size)} (${sign}${kb(delta)})`; + }, + ); + show(gone, "no longer written", ({ path }) => relative(ROOT, path)); + if (same.length) console.log(`\nbyte-identical (${same.length}) — nothing about these moved`); + if (!added.length && !changed.length) { + console.log("\nnothing changed. If you expected a change, check that `npm run build` ran:"); + console.log("these scripts photograph dist/, not src/, and --skip-build is a way to shoot a stale one."); + } + console.log(""); + console.log("Commit tera first, then re-run the manifests so they name a clean sha:"); + console.log(" node scripts/brand-assets/shots.mjs --manifest-only"); + console.log(" node scripts/brand-assets/films.mjs --manifest-only"); + console.log("Then commit lumbridge-v4 and deploy it."); +} + +// ---- Run --------------------------------------------------------------------- + +if (dryRun) { + await plan(); + process.exit(0); +} + +const before = await fingerprint(); +const timings = []; +try { + if (!skipBuild) { + // First, and not optional without saying so: every script below photographs + // `dist/`, so skipping the build is how a "re-shoot" publishes the old engine. + const built = await run("build", "npm", ["run", "build"]); + timings.push(["build", built.seconds]); + } else { + console.log("build skipped (--skip-build) — the stills, cards and films will photograph the dist/ already on disk\n"); + } + + if (parts.has("stills")) { + const done = await run("stills", NODE, [script("shots.mjs"), "--site", siteDir]); + timings.push(["stills", done.seconds]); + } + if (parts.has("cards")) { + const done = await run("cards", NODE, [script("capture.mjs")]); + timings.push(["cards", done.seconds]); + } + if (parts.has("films")) { + const args = [script("films.mjs"), "--site", siteDir]; + if (rough) args.push("--frames", "24"); + const done = await run("films", NODE, args); + timings.push(["films", done.seconds]); + } +} catch (error) { + if (error instanceof StepFailure) { + console.error("\n" + "━".repeat(72)); + console.error(`FAILED at ${error.step}: ${error.headline}`); + console.error("━".repeat(72)); + console.error(error.detail); + console.error(""); + process.exit(1); + } + throw error; +} + +const after = await fingerprint(); +report(before, after, timings); +await restampProvenance( + after, + timings.map(([step]) => step).flatMap((step) => + step === "cards" + ? ["scripts/brand-assets/capture.mjs"] + : step === "stills" + ? ["scripts/brand-assets/shots.mjs"] + : step === "films" + ? ["scripts/brand-assets/films.mjs"] + : [], + ), +); diff --git a/scripts/brand-assets/shots.mjs b/scripts/brand-assets/shots.mjs index 2fd4a6c..c544a69 100644 --- a/scripts/brand-assets/shots.mjs +++ b/scripts/brand-assets/shots.mjs @@ -5,6 +5,7 @@ * 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 + * node scripts/brand-assets/shots.mjs --list # what a run would shoot, 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 @@ -73,6 +74,15 @@ const ROOT = join(HERE, "..", ".."); * 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. + * + * `aim` is the optional second half of a camera, for the seven subjects the + * chapter list has no button for — SFO, LAX, both bridges, the drivable freeway, + * the state board's relief and the open Pacific. The chapter chooses the + * *stance* (how far back, how high, from which side); `aim` slides the orbit + * target onto a coordinate and adjusts the standoff and the angles from there. + * See the long note beside `aimAt` for how each number reaches the engine, and + * for why the chapter under an aimed shot is chosen so that the blurb still in + * the panel is true of the frame. */ const SHOTS = [ { @@ -175,6 +185,163 @@ const SHOTS = [ alt: "Downtown Los Angeles at night, the tower cluster lit and the surrounding basin covered in light to the horizon.", }, }, + { + id: "sfo", + door: "tera", + city: "sf", + chapter: 6, + expect: "Peninsula", + aim: { lat: 37.6189, lng: -122.375, zoom: -5, azimuth: 180, elevation: 10 }, + place: "San Francisco International", + caption: + "An airport is composed rather than modelled — runways, taxiways, aprons and massed terminals, with the markings painted into a canvas instead of built — because the pattern of the runways is what the eye recognises from altitude, long before any building does. SFO's two pairs are laid from true bearings and not from the numbers painted on them: the 28s point 298.6°, the 01s 26.5°, and they cross at 92.1°. A designator is magnetic and rounded to ten degrees, so building from the paint would have laid the whole airport thirteen degrees out.", + day: { + at: "2026-08-06T17:20:00-07:00", + note: "Twenty past five, the light running along the runways rather than across them. The 28 thresholds sit about seventy metres inside the bay edge this pack traced long before the airport did — that outline was drawn with “the runways are built out onto the mud” written on the vertex, and now they are.", + alt: "San Francisco International Airport from above in late afternoon light: two pairs of runways crossing at close to a right angle on made ground, a horseshoe of terminals with aircraft parked around it, and the bay beyond.", + }, + night: { + at: "2026-08-06T21:35:00-07:00", + note: "Twenty-five to ten, and the only thing on the field that has not gone dark is the paint. The markings use an unlit material deliberately — a lit one dims into the concrete at dusk, and an emissive one would be a claim about approach lighting nobody has modelled — so after sunset the airport is a diagram of itself.", + alt: "The same airport at night, the concrete gone dark and the painted runway markings still bright, so the pattern of the field reads as pale lines on black.", + }, + }, + { + id: "golden-gate", + door: "tera", + city: "sf", + chapter: 10, + expect: "Marin", + aim: { lat: 37.8205, lng: -122.479, zoom: -5, azimuth: 60, elevation: 12 }, + place: "The Golden Gate", + caption: + "One kit draws both of the Bay's crossings, because a suspension bridge is arithmetic over a path and a list of towers: a main cable hanging between the tower tops, a regular series of hangers down to the deck, and piers under every metre that hangs from nothing. Here that resolves to two cross-braced towers 227 m over the strait, one main span, and international orange. Every reach is classified before it is drawn — the previous builder hung a catenary between every pair of towers, which is how the Bay Bridge ended up with a two-and-a-half kilometre span over dry land.", + day: { + at: "2026-08-06T17:40:00-07:00", + note: "Twenty to six, raking down the strait, so the towers throw along the deck and the bridge puts its own shadow on the water. The road does not stop at the anchorages: both ends run onto land, because started at the waterline the deck appeared to float with its approaches missing.", + alt: "The Golden Gate Bridge from above in late afternoon light, international orange, its two towers cross-braced and the main cable sagging to mid-span between them, with the Marin headlands on one side and the Presidio on the other.", + }, + night: { + at: "2026-08-06T20:50:00-07:00", + note: "Ten to nine, sun seven and a half degrees under — nautical twilight, and an hour earlier than every other night frame here. That is not a preference. The other nine are cities, and a city at half past nine is carried by its own windows; a bridge over open water has none, so the hour that works everywhere else renders this one as a black rectangle. What is left at this one is the residual sky, the deck lamps, and orange going to brown.", + alt: "The same bridge in deep twilight, the international orange darkened almost to brown, the towers and cables silhouetted against the last light on the water.", + }, + }, + { + id: "bay-bridge", + door: "tera", + city: "sf", + chapter: 11, + expect: "The Bay", + aim: { lat: 37.7995, lng: -122.3775, zoom: -5, azimuth: 24, elevation: 16 }, + place: "The Bay Bridge", + caption: + "The same kit, given the real 2013 topology: two suspension towers west of Yerba Buena, one east, and a piered causeway into Oakland. The classifier is the whole difference between this and the Golden Gate — a reach no cable could hold up gets a deck on piers instead, and a pier is skipped wherever the ground has already come up to meet it, which is what lands the crossing on the island rather than standing it on stilts over the top. Towers, cables, hangers and piers merge into two draw calls: the painted structure, and the roadway on it.", + day: { + at: "2026-08-06T17:40:00-07:00", + note: "Twenty to six, looking north-east from off the city. One crossing, three different answers to the same water: two suspension spans off the waterfront, a single tower east of Yerba Buena, and a causeway on piers running out toward an Oakland that is past the top of the frame.", + alt: "The Bay Bridge from above in late afternoon light, leaving the San Francisco waterfront over two suspension spans, crossing Yerba Buena Island and continuing east on piers toward Oakland.", + }, + night: { + at: "2026-08-06T21:35:00-07:00", + note: "Twenty-five to ten, and only one of the two shores it joins is in the frame. The deck leaves San Francisco's lit ground and runs north-east over water with nothing on it, so what carries the structure at this hour is the silhouette of the towers and the cable rather than the crossing.", + alt: "The same crossing at night, the lights of San Francisco filling the lower left and the bridge leaving them as a dark line over unlit water.", + }, + }, + { + id: "pacific-sea", + door: "tera", + city: "sf", + chapter: 6, + expect: "Peninsula", + aim: { lat: 37.762, lng: -122.53, zoom: -4, elevation: -16 }, + place: "The Pacific, off Ocean Beach", + caption: + "The sea is a surface rather than a colour. Two samples of one tangent-space swell map, the second 2.6× smaller and turned 63°, so no crest holds its shape and the water never resolves into a repeating weave; a Fresnel term that leaves 60% of the body colour when you look straight down into it and all of the sky at grazing; and a specular lobe narrow enough that the sun comes back as a broken path instead of a mirror disc. The swell's wavelength is not physical and could not be — real swell at these scales is a fraction of a screen pixel — it is tuned until the glitter lands at a handful of pixels a tile.", + day: { + at: "2026-08-06T19:30:00-07:00", + note: "Half past seven, sun seven degrees up and almost dead ahead of the camera, which is the only geometry in which a glitter path exists at all. Turn ninety degrees and this water is flat blue.", + alt: "The Pacific seen from low over the San Francisco peninsula at sunset, the sun's reflection broken into a bright path running to the horizon, with the coastline in the corner of the frame.", + }, + night: { + at: "2026-08-06T21:20:00-07:00", + note: "Twenty past nine, sun twelve degrees under, and the specular lobe has not gone anywhere — there is simply something else in it. The moon lays the same broken path across the same swell, orders of magnitude down, and the coast is now the brightest thing in the frame.", + alt: "The same stretch of ocean after dark, the moon's reflection broken into a path across the swell, the lit edge of the city along the bottom of the frame.", + }, + }, + { + id: "california-relief", + door: "tera", + city: "california", + chapter: 0, + expect: "State", + place: "California", + caption: + "A third board, and the first that is a state rather than a city: 428 units across at 1,919 metres to the unit, which is the same heightfield code the Bay Area runs at 94. The Central Valley is the flat green band between the Sierra crest and the Coast Ranges; the Basin and Range steps away east of the Sierra, Death Valley is the pale pan below sea level inside it, and the Salton Sea is the water in the south-east corner. Adding a board is a data contribution — coastlines, ranges, roads, landmarks and chapters in one file with no code in it.", + day: { + at: "2026-08-06T17:30:00-07:00", + note: "Half past five, and every ridge in the Basin and Range is throwing its shadow east. That is the hour the steps between them separate at all: at midday the sun is behind the camera and the whole state is one sheet of sand.", + alt: "The state of California rendered from above as a relief board in late afternoon light, the Sierra Nevada and the Coast Ranges either side of the Central Valley, the Basin and Range stepping east into shadow, and the Salton Sea in the south.", + }, + night: { + at: "2026-08-06T21:10:00-07:00", + note: "Ten past nine, and the relief has gone. What is left is the state's other shape: two clusters of light six hundred kilometres apart, with the corridors between them drawn by the ground that is lit along them.", + alt: "The same board at night, the land dark and the built-up ground picked out around Los Angeles and San Francisco.", + }, + }, + { + id: "freeway", + door: "tera", + city: "california", + chapter: 1, + expect: "101", + /* + * Short, and the ceiling is not comfort — it is the end of the road. + * + * The chase camera follows a car that has been driving since the page + * loaded, so the wait chooses the stretch. At about twenty seconds the EV + * reaches San Francisco, the app swaps to the Bay Area board, and the shot + * is of a different product entirely; at about twelve it is passing through a + * town, where a metre-scale road runs between buildings drawn at 1,919 m to + * the unit. This lands it in open oak country with both carriageways in the + * frame. + */ + settle: 3000, + place: "US-101, the Salinas Valley", + caption: + "The one corridor you can drive, built at one metre to the metre on a board that is otherwise 1,919. The cross-section is what makes it read as a road: verge, shoulder, a solid edge line, lane dividers, a median. Everywhere else on a board that is a single texture on one ribbon, with the v axis carrying distance so a dash cycle comes out 24 metres long whatever the board's scale is; here it is eight ribbons a carriageway, because at a chase camera's height an embankment has to have a side to it. The unbadged Lumbridge EV ahead is on the asphalt, not gliding above it.", + day: { + at: "2026-08-06T09:00:00-07:00", + note: "Nine in the morning, the sun across the carriageway rather than down it, so the median and the shoulders have an edge. The speed on the readout is the simulation's, not a caption.", + alt: "A divided freeway seen from a chase camera above and behind a dark car, with dashed lane markings, solid edge lines, shoulders and a median, running through dry hill country.", + }, + night: { + at: "2026-08-06T21:30:00-07:00", + note: "Half past nine, and the markings are doing the work they are actually for.", + alt: "The same freeway at night, the lane markings and edge lines catching what light there is, the hills either side almost black.", + }, + }, + { + id: "lax", + door: "tera", + city: "socal", + chapter: 2, + expect: "Westside", + aim: { lat: 33.9425, lng: -118.4081, zoom: -1, azimuth: 40, elevation: 16 }, + place: "Los Angeles International", + caption: + "The same airport kit, on the second city pack, producing an airport nobody would mistake for the first: four parallels, two either side of the terminal horseshoe, instead of two crossing pairs. The property is a cardinal rectangle — Westchester Parkway north, Imperial Highway south, Aviation Boulevard east, the dunes above Dockweiler west — with the runway pattern set about seven degrees off it, and that disagreement between the fence and the pavement is the thing that reads as LAX from altitude.", + day: { + at: "2026-08-06T17:50:00-07:00", + note: "Ten to six, with the basin's grid running out to the San Gabriels behind and the Santa Monica Bay in front. The traffic overhead is the real traffic: registration and type come off the same feed.", + alt: "Los Angeles International Airport from above in late afternoon light: four parallel runways either side of a horseshoe of terminals, the street grid of the basin all around it and the Pacific beyond.", + }, + night: { + at: "2026-08-06T21:20:00-07:00", + note: "Twenty past nine. This is the one board where the lit ground runs to the edge of the frame in every direction and the airfield is the hole in it.", + alt: "The same airport at night, its dark field surrounded by the lights of the Los Angeles basin running to the horizon.", + }, + }, { id: "office-floor", hide: ["#device-section"], @@ -281,6 +448,192 @@ const SIZES = [ /** The archive frame: full sensor, near-lossless, kept forever. */ const ARCHIVE_QUALITY = 0.94; +/** + * ---- Aiming at things the chapter list does not stop at --------------------- + * + * A shot's camera is normally a chapter, because a chapter is the only camera + * the product ships. Then SFO, LAX, the Golden Gate, the Bay Bridge and a + * surfaced freeway arrived, and none of them has a chapter: they are things you + * find on a board rather than places the legend sends you to. Photographing them + * needs a camera the pack does not carry. + * + * The engine has no back door for one — there is no `?pose=` and no global — so + * this drives the same three inputs a reader has, through the DOM, and every + * one of them is a documented, exact transform rather than a nudge: + * + * - **Where.** A click on the plan view is `onSeek` in `main.ts`: it slides the + * orbit target to a lat/lng and carries the camera with it, keeping the + * distance, height and rotation the chapter chose. So a shot picks a chapter + * for its *stance* and a coordinate for its *subject*. The plan view's + * pixel-to-coordinate map is affine, and `#minimap-readout` prints the + * coordinate under the pointer to four decimals — so three hovers solve the + * map exactly, and the fourth event is a click that lands within a metre. + * Solving it beats hard-coding it: the board's bounds are pack data and the + * widget's size is a CSS rule, and either can move. + * + * - **How far.** `minimap.ts` has its own wheel handler that dollies the camera + * along its view vector by `exp(clamp(deltaY, ±160) · 0.0022)`. One notch of + * -160 is therefore exactly ×0.7035, and `zoom: -9` is ×0.0417 — reproducible + * to the float. The clamp is why standoff is counted in notches: a single + * enormous wheel event is silently the same as a small one. + * + * - **Which way up, and from which side.** `OrbitControls` maps a drag to + * `2π · delta / clientHeight` on both axes, so at this viewport one pixel is + * exactly 0.4°. `elevation` and `azimuth` are therefore written in degrees and + * converted here, because "lower the camera 20°" is a thing a photographer + * says and "drag 50 px" is not. + * + * Two things learned by looking at the output, both worth keeping: + * + * **Get close.** `minVisibilityM` scales with the board span, so the haze over a + * ninety-four-kilometre board is real and thick. At 130 units of standoff SFO is + * a grey rectangle; at 31 it is an airport. Every aim below is a close one. + * + * **The panel names the chapter you left, not the place you flew to**, because + * `city.current()` is set by `flyTo` and a seek is deliberately not a flight. + * That is honest — it is what the app does when a reader clicks the map — but it + * is a caption in the photograph, so each aimed shot picks the chapter whose + * blurb is *true of the frame*: SFO is shot from Peninsula, whose description + * already says "an airport built out onto the mud", and both bridges are shot + * from The Bay, whose description counts the crossings. + */ + +/** One wheel notch on the plan view, as a multiplier on the standoff. */ +const ZOOM_NOTCH = Math.exp(-160 * 0.0022); +/** `OrbitControls` turns `2π · px / clientHeight`; at this viewport, per degree. */ +const DRAG_PX_PER_DEGREE = VIEWPORT.height / 360; + +/** The plan view's canvas, in CSS pixels. */ +async function planRect(page) { + const rect = await page.evaluate(() => { + const canvas = document.querySelector("#minimap canvas"); + if (!canvas) return null; + const r = canvas.getBoundingClientRect(); + return { x: r.x, y: r.y, w: r.width, h: r.height }; + }); + if (!rect || rect.w < 40) { + throw new Error( + "no plan view to aim with — `#minimap canvas` is missing or collapsed, so a " + + "shot carrying `aim` cannot be framed", + ); + } + return rect; +} + +/** Hover one point of the plan view and read the coordinate it prints. */ +async function probePlan(page, rect, fx, fy) { + const px = rect.w * fx; + const py = rect.h * fy; + await page.mouse.move(rect.x + px, rect.y + py); + await page.waitForTimeout(140); + const text = await page.evaluate(() => document.querySelector("#minimap-readout")?.textContent ?? ""); + const found = /(-?\d+\.\d+),\s*(-?\d+\.\d+)/.exec(text); + if (!found) { + throw new Error( + `the plan view printed no coordinate at (${fx}, ${fy}) — got ${JSON.stringify(text)}. ` + + "`#minimap-readout` is how an aimed shot finds its subject, so this is not skippable", + ); + } + return { px, py, lat: Number(found[1]), lng: Number(found[2]) }; +} + +/** + * Solve the plan view's pixel-to-coordinate map, and return its inverse. + * + * Three probes rather than two, and inside the board rather than at its corners, + * because `onPointerMove` clamps the hover to the board's own rectangle inside + * the canvas — a probe in the margin reports the edge and would flatten the + * solve. The axes are independent (north is up), so a lat pair and a lng pair + * are the whole transform. + */ +async function solvePlan(page, rect) { + const origin = await probePlan(page, rect, 0.3, 0.3); + const east = await probePlan(page, rect, 0.72, 0.3); + const south = await probePlan(page, rect, 0.3, 0.72); + const lngPerPx = (east.lng - origin.lng) / (east.px - origin.px); + const latPerPy = (south.lat - origin.lat) / (south.py - origin.py); + if (!(Math.abs(lngPerPx) > 1e-9) || !(Math.abs(latPerPy) > 1e-9)) { + throw new Error("the plan view reported the same coordinate at three points — it is not ready"); + } + return (lat, lng) => ({ + px: origin.px + (lng - origin.lng) / lngPerPx, + py: origin.py + (lat - origin.lat) / latPerPy, + }); +} + +/** Put the camera on a subject the legend has no button for. See the note above. */ +async function aimAt(page, rect, toPx, aim) { + const toPixel = toPx(aim.lat, aim.lng); + if ( + toPixel.px < 0 || toPixel.px > rect.w || + toPixel.py < 0 || toPixel.py > rect.h + ) { + throw new Error( + `${aim.lat}, ${aim.lng} is off this board's plan view — the seek would be clamped ` + + "to the edge and the shot would be of somewhere else", + ); + } + await page.mouse.move(rect.x + toPixel.px, rect.y + toPixel.py); + await page.mouse.down(); + await page.mouse.up(); + await page.waitForTimeout(250); + + /* + * The notches are **dispatched into the page**, not driven through Playwright's + * mouse, and this is not a shortcut — it is the fix for a race that produced a + * different picture at 1x and at 2x. + * + * A dolly notch is multiplicative: five of them are ×0.172 and three of them + * are ×0.348, so losing one is not a rounding error, it is a different + * photograph. Chrome coalesces wheel events, and at `deviceScaleFactor: 2` the + * app is drawing four times the pixels, so real wheel events sent 60 ms apart + * arrive merged: the preview at 1x landed on the airport and the delivered + * frame at 2x was still a mile out over the bay, from the same shot record. + * `minimap.ts` registers its handler `{ passive: false }` and reads nothing off + * the event but `deltaY` and `deltaMode`, so a synthetic `WheelEvent` is the + * same input with none of the pipeline — n notches applied means n notches + * applied. The seek stays a real click, because `onPointerDown` calls + * `setPointerCapture`, which throws on a pointer id no input device owns. + */ + const notches = Math.abs(aim.zoom ?? 0); + if (notches) { + await page.evaluate( + ({ count, deltaY }) => { + const canvas = document.querySelector("#minimap canvas"); + if (!canvas) throw new Error("no plan view canvas to dolly with"); + for (let i = 0; i < count; i++) { + canvas.dispatchEvent( + new WheelEvent("wheel", { deltaY, deltaMode: 0, bubbles: true, cancelable: true }), + ); + } + }, + { count: notches, deltaY: Math.sign(aim.zoom) * 160 }, + ); + await page.waitForTimeout(250); + } + + const azimuth = aim.azimuth ?? 0; + const elevation = aim.elevation ?? 0; + if (azimuth || elevation) { + // Drag from the middle of the canvas, away from the panel and the plan view, + // so no step of the gesture lands on a control. + const from = { x: VIEWPORT.width * 0.52, y: VIEWPORT.height * 0.5 }; + const dx = azimuth * DRAG_PX_PER_DEGREE; + const dy = elevation * DRAG_PX_PER_DEGREE; + await page.mouse.move(from.x, from.y); + await page.mouse.down(); + for (let step = 1; step <= 12; step++) { + await page.mouse.move(from.x + (dx * step) / 12, from.y + (dy * step) / 12); + } + await page.mouse.up(); + } + // `OrbitControls` damps per frame, so the pose is a few frames behind the + // gesture; and the pointer has to leave the plan view or the shot keeps its + // hover crosshair and the coordinate readout under it. + await page.mouse.move(rect.x - 200, rect.y + 400); + await page.waitForTimeout(900); +} + // ---- Arguments -------------------------------------------------------------- function flag(name, fallback = null) { @@ -302,6 +655,12 @@ const preview = process.argv.includes("--preview"); * both are true, because the pictures did not change. */ const manifestOnly = process.argv.includes("--manifest-only"); +/** + * Print the plan and stop, so `refresh.mjs --dry-run` can say what a run would + * do without launching a browser. It is a listing rather than an import because + * this file is a script with top-level effects: importing it *is* running it. + */ +const listOnly = process.argv.includes("--list"); 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"))); @@ -358,24 +717,41 @@ async function encode(page, png, width, quality) { /** * 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. + * The index is all the shot list can carry, so `expect` is the guard, and it has + * already earned its keep once: the studios were rebuilt, chapter 0 became + * "Front Door", and this refused to shoot rather than silently reframing every + * office picture on the site and captioning them with the old names. + * + * **Read the label out of its own span, not out of `textContent`.** The button is + * `02101`, and the old + * `textContent.replace(/^\d+/, "")` returned the empty string for the California + * board's US-101 chapter — a shortLabel that is itself a number strips to + * nothing, so the guard would have refused a shot that was perfectly correct. + * `mount.ts` also puts the chapter's own id on the element as `data-view` now, + * which is a stronger identity than a position, so it is reported in the failure: + * "chapter 3 is now `mission-bay`" is a sentence somebody can act on. */ async function chapter(page, index, expect, { click = false } = {}) { - const label = await page.evaluate( + const found = 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(); + const spans = [...button.querySelectorAll("span")]; + const label = spans.length > 1 ? spans[spans.length - 1] : null; + return { + id: button.getAttribute("data-view"), + label: (label?.textContent ?? button.textContent ?? "").trim(), + }; }, { i: index, press: click }, ); - if (label === null) throw new Error(`no chapter at index ${index}`); - if (label !== expect) { + if (found === null) throw new Error(`no chapter at index ${index}`); + if (found.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`, + `chapter ${index} is "${found.label}" (${found.id ?? "no id"}), not "${expect}" — ` + + `a city pack was reordered, so the shot list and its captions need re-checking ` + + `before anything ships`, ); } } @@ -459,6 +835,33 @@ async function shoot(browser, shot, frame) { } await chapter(page, shot.chapter, shot.expect, { click: shot.chapter > 0 }); if (shot.chapter > 0) await page.waitForTimeout(2500); + /* + * The chapter chose the stance; `aim` chooses the subject. See the long note + * beside `aimAt` for why this is done through the plan view and the orbit + * rather than by writing a pose, and for what each number costs. + */ + if (shot.aim) { + const rect = await planRect(page); + const toPx = await solvePlan(page, rect); + await aimAt(page, rect, toPx, shot.aim); + } + /* + * `shot.settle` is for the one thing in a frame that is neither the camera + * nor the clock: the traffic simulation. + * + * The California board's US-101 and I-5 chapters put the viewer in a chase + * camera behind the Lumbridge EV, and the car has been driving since the page + * loaded — so where it is, and therefore what the frame is of, is a function + * of *elapsed wall time* and nothing else. The clock shim cannot help: it + * moves the sun, not the sim. The first render of this shot landed the car in + * a settlement, where a metre-scale road runs between buildings drawn at + * 1,919 metres to the unit and the picture reads as a rendering fault. + * + * So the wait is a shot parameter, chosen by shooting the corridor at several + * of them and looking. It is reproducible on this machine and approximately + * reproducible on another: boot time varies, and the car with it. + */ + if (shot.settle) await page.waitForTimeout(shot.settle); /* * `shot.hide` is the per-frame escape hatch, and the studio-hardware card is * why it exists. @@ -497,6 +900,17 @@ const noSite = () => ` (pass --site ; the archive is complete either way)`, ); +if (listOnly) { + console.log(`stills ${wanted.length} shots x ${VARIANTS.length} frames = ${wanted.length * VARIANTS.length} renders`); + for (const shot of wanted) { + const where = shot.door === "office" ? `office/${shot.office ?? "SF HQ"}` : `tera/${shot.city ?? "california"}`; + const aimed = shot.aim ? ` aimed at ${shot.aim.lat}, ${shot.aim.lng}` : ""; + console.log(` ${shot.id.padEnd(20)} ${where.padEnd(22)} chapter ${shot.chapter} "${shot.expect}"${aimed}`); + for (const variant of VARIANTS) console.log(` ${variant.padEnd(6)} ${shot[variant].at}`); + } + process.exit(0); +} + if (manifestOnly) { if (!(await haveSite())) { noSite(); @@ -546,6 +960,7 @@ try { "shot ", stem.padEnd(22), SIZES.map((s) => `${s.suffix}w ${Math.round(sizes[s.suffix].length / 1024)}kB`).join(" "), + shot.aim ? ` aim ×${(ZOOM_NOTCH ** -(shot.aim.zoom ?? 0)).toFixed(4)} standoff` : "", ); } } diff --git a/vite.config.ts b/vite.config.ts index ad1e4e1..f549f1d 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -37,7 +37,7 @@ function twoDoors(): Plugin { Spaces — a studio you can walk around @@ -53,9 +53,17 @@ function twoDoors(): Plugin { +