feat(brand): re-shoot everything at the new engine, and make it one command
**Seven new shots**, because the world grew the most photogenic things in it
after the last pass: `sfo`, `lax`, `golden-gate`, `bay-bridge`, `freeway`,
`california-relief` and `pacific-sea`. All nine existing ids are unchanged — the
manifest emits a `ShotId` union that lumbridge-v4 imports, so ids are added and
never renamed.
**A shot can now aim itself.** The chapter list has no camera for SFO, LAX, the
bridges, the freeway or the open sea, and the engine has no `?pose=` back door,
so a shot points itself by driving the app's own inputs: a click on the plan view
slides the orbit target to a lat/lng while keeping the chapter's stance, wheel
notches set the standoff, and a drag sets azimuth and elevation. The plan view's
pixel-to-coordinate map is solved at runtime from three hovers of
`#minimap-readout` rather than hard-coded, so it survives a board resize or a
restyle of the widget.
**The tera share card was a picture of the wrong thing, and had been.** Its art
came from `keyboard.press("2")`, which had landed on the California board's drive
mode once the default board changed — so the card under the headline "Cities from
above." was a chase camera on US-101, showing metre-scale cars driving between
kilometre-wide buildings, with the DRIVE readout and the mode pill baked into the
art. It renders, it looks deliberate, and it is why an unguarded key press has no
place in a capture script. `capture.mjs` now clicks an indexed chapter and
asserts its `shortLabel` the way `shots.mjs` does, waits on `#boot` and
`#chapters` instead of sleeping twenty seconds, and gives each card its own hour.
**`npm run refresh` is the durable half.** One command: build, stills, cards,
films, both manifests, and a hashed before/after diff of every deliverable. It
fails loudly and specifically on the two conditions that otherwise produce
confident wrong output — the renderer coming up as SwiftShader, and a chapter
`expect` guard firing. `--stills-only` / `--cards-only` / `--films-only` compose,
`--dry-run` lists the plan without opening a browser, and
`shots.mjs --list` prints the whole shot plan — board, chapter, expect, aim, both
hours — which is what to run first when a guard does fire.
It also re-stamps `PROVENANCE.json`, narrowly: only entries whose origin is
`repository-generated` and whose `generator` names a script the run actually
executed, by literal hash substitution rather than re-serialising the file.
Without that, every legitimate card re-shoot leaves `npm run provenance` red.
**Every film re-shot.** They were at `9c9e78f`, captured 2026-08-07, and predated
the tone mapping, the reflective sea, the sky dome, terrain shadows, the rebuilt
California board, SFO, LAX, both bridges and the moving aircraft.
Tests 1137, typecheck, build, eight budget cells and every provenance and licence
check pass.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -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
|
||||
* `<span class="num">02</span><span>101</span>`, 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 <dir>; 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` : "",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user