/**
 * Photograph the engine, for the pages that describe it.
 *
 *   node scripts/brand-assets/shots.mjs                  # everything, full size
 *   node scripts/brand-assets/shots.mjs --only fidi-night,office-floor
 *   node scripts/brand-assets/shots.mjs --preview        # fast, 1x, PNGs to look at
 *   node scripts/brand-assets/shots.mjs --manifest-only  # captions only, no browser
 *   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
 * looks like. Same harness, different framing — see `harness.mjs`.
 *
 * ### Why these are renders and not illustrations
 *
 * Every image this writes is a screenshot of the built `dist/` in this repo at
 * the commit named in the manifest. Nothing is composited, retouched, or drawn.
 * That is the whole point: /simulate/tera claims the sun is computed from your
 * clock and the city lights come on as it sets, and the honest way to
 * illustrate that claim is a photograph of it happening, taken by a script
 * anyone can re-run. `og.png` on lumbridgecorp.com spent a month advertising a
 * headline the site had stopped using, because it was a hand-made picture of a
 * page rather than a render of one. This is that lesson applied to the art.
 *
 * ### The two outputs
 *
 * **The site.** `--site <dir>` (default: the sibling `lumbridge-v4` checkout)
 * gets web-sized WebP into `apps/web/public/shots/` and a generated TypeScript
 * manifest into `apps/web/src/data/shots.ts`. The captions travel *with* the
 * images, in the manifest, so a page cannot show a caption for a picture that
 * was reframed underneath it. If the directory is not there — which is the
 * normal case for anyone who cloned this repo on its own — the step is skipped
 * with a note, because the site is Lumbridge's consumer of this script and not
 * a dependency of it.
 *
 * **The archive.** A dated folder under `shots/` keeps the full-resolution
 * frame of every shot from every run. Nothing reads it. It is there because the
 * cities keep changing and a render of the Bay from August is not recoverable
 * once the heightfield moves — the manifest can be regenerated, the past
 * cannot.
 */

import { fileURLToPath } from "node:url";
import { dirname, join, resolve } from "node:path";
import { mkdir, writeFile, readFile, access } from "node:fs/promises";
import { execFileSync } from "node:child_process";
import { serve, launch, clockShim, FURNITURE, hide } from "./harness.mjs";

const HERE = dirname(fileURLToPath(import.meta.url));
const ROOT = join(HERE, "..", "..");

// ---- The shots --------------------------------------------------------------

/**
 * `chapter` is an index into the chapter list, because that is all the DOM
 * offers — `renderLegend()` in `main.ts` builds the buttons from the city pack's
 * array and binds each to `flyToIndex(i)`, with no id on the element. `expect`
 * is the `shortLabel` that index is supposed to be, asserted before the shutter
 * opens. A city pack is data, and data gets reordered; without the assertion a
 * reordered pack silently reframes every picture on the site and captions them
 * with the old names.
 *
 * Every shot is taken **twice from the same camera**, once by day and once at
 * night, and the site loads the one that matches the theme the reader is in —
 * the daylit frame on the light page, the night frame on the dark one, so the
 * picture is in the same light as the page around it. One camera and two clocks
 * also happens to be the cheapest possible demonstration of the thing these
 * pages claim hardest: the sun is computed from a real time, not painted on.
 *
 * Times are local to the city and are chosen, not defaulted. Midday is the
 * worst light a heightfield ever gets — the sun is behind the camera, nothing
 * casts, and 83k buildings read as gravel. The daylight frames are taken in the
 * morning or late afternoon for the same reason a photographer would; the night
 * frames sit around sixteen degrees below the horizon, which is dark enough for
 * the window lights to carry the picture and not so dark that the ground has
 * gone.
 *
 * `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.
 *
 * `play` is the third camera, and the only one that is not a camera at all: it
 * asks the app for a **body**. The three shots that carry it are pictures of an
 * agent doing something — the chase camera behind the EV, the crow in the air,
 * the aircraft on the corridor — and in those three the play HUD is the subject
 * rather than clutter. See the long note beside `enterPlay`.
 */
const SHOTS = [
  {
    id: "bay-relief",
    door: "tera",
    city: "sf",
    chapter: 0,
    expect: "Whole Board",
    place: "San Francisco Bay Area",
    caption:
      "Everything else in the engine stands on this heightfield: 83 named hills, four octaves of value noise, and overlapping peaks combined as tallest-plus-35%-of-the-rest — the one rule that makes a run of summits read as a ridge instead of stacking into a fictional mountain.",
    day: {
      at: "2026-08-06T17:00:00-07:00",
      /*
       * Five in the afternoon, and it used to be ten past nine — which was the
       * right instinct about shadow and the wrong one about cloud. `atmosphere.ts`
       * models the Pacific marine layer on *apparent solar* time, and its diurnal
       * curve is 0.95 before dawn, 0.6 at nine, 0.2 at eleven and back to 0.4 by
       * four. At 09:10 PDT — a little after eight solar — the layer is at about
       * three quarters, and the frame it produced was a white lid with a board
       * somewhere under it: the caption underneath was about eighty-three named
       * hills and four octaves of value noise, none of which was visible.
       *
       * This is the compromise the curve allows. Physically the layer is thinnest
       * at solar eleven to fourteen, which is 12:07-15:07 on a wristwatch here —
       * the exact hours a heightfield has no shadow at all. 17:00 PDT is solar
       * 15.9, thin enough that the board is out from under it, and low enough at
       * 36.6 degrees that both ranges still throw.
       */
      note: "Five in the afternoon, which on this coast is a decision about cloud before it is one about shadow. The marine layer here is modelled rather than painted — thickest before dawn, burnt back through the late morning, on its way in again by six, all of it on apparent solar time — so at nine this board is a white lid, and at five it is out from under it with both ranges throwing.",
      alt: "The San Francisco Bay Area seen from above in late afternoon light: the peninsula between the Pacific and the bay, the East Bay hills beyond, both crossings over the water, and scattered cloud out over the ocean.",
    },
    night: {
      at: "2026-08-06T21:35:00-07:00",
      note: "Twenty-five to ten, and the same board is a scatter of lit ground between two dark ranges — the shape of the region is now where the cities are, not where the hills are.",
      alt: "The same view of the Bay Area at night, the built-up ground picked out in clusters of light between dark hills and darker water.",
    },
  },
  {
    id: "soma",
    door: "tera",
    city: "sf",
    chapter: 2,
    expect: "SoMa",
    place: "SoMa, San Francisco",
    caption:
      "South of Market, where the grid turns forty-six degrees and the blocks get long. About 83,000 buildings are on screen here, drawn as instanced geometry on street bearings the city pack declares per district.",
    day: {
      at: "2026-08-06T17:20:00-07:00",
      note: "Twenty past five.",
      alt: "Downtown San Francisco south of Market in afternoon light, towers clustered on a street grid turned at an angle to the rest of the city.",
    },
    night: {
      at: "2026-08-06T21:35:00-07:00",
      note: "Twenty-five to ten. Nothing about the geometry changed — only the hour the page was asked for.",
      alt: "The same towers south of Market at night, windows lit in a scatter across every face.",
    },
  },
  {
    id: "fidi",
    door: "tera",
    city: "sf",
    chapter: 3,
    expect: "FiDi",
    place: "Financial District, San Francisco",
    caption:
      "The old money and the venture money, stacked between Montgomery and the Embarcadero under the pyramid, with the Bay Bridge running out to the right.",
    day: {
      at: "2026-08-06T10:10:00-07:00",
      note: "Ten past ten in the morning, sun forty-four degrees up.",
      alt: "The San Francisco financial district from above in mid-morning light, the pyramid among the towers and the Bay Bridge crossing to the right.",
    },
    night: {
      at: "2026-08-06T21:40:00-07:00",
      note: "Twenty to ten, sun sixteen degrees below the horizon. The lights are not a night theme — they are emissive window faces chosen from a seeded RNG as the computed sun goes down. The moonlight carrying the ground is about five orders of magnitude brighter than the real moon-to-sun ratio, deliberately: the honest number renders a black rectangle.",
      alt: "The San Francisco financial district at night, tower windows lit from within and the ground reading dark blue rather than black.",
    },
  },
  {
    id: "peninsula",
    door: "tera",
    city: "sf",
    chapter: 6,
    expect: "Peninsula",
    place: "The Peninsula",
    caption:
      "Twenty miles of city between the water and the ridge, never more than four wide. The camera is about 32 km back — the city scene's orbit limit — which is why the region ships as five chapters rather than one impossible wide shot.",
    day: {
      at: "2026-08-06T17:10:00-07:00",
      /*
       * Moved off 09:20 for `bay-relief`'s reason, and this shot is the sharper
       * case of it: at a thirty-two kilometre standoff the modelled marine layer
       * is not weather in the picture, it is a lid over the whole subject, and
       * the twenty miles of city this caption is about were simply not in the
       * frame. Ten past five is thin enough to see the ground and low enough to
       * model the ridge.
       */
      note: "Ten past five, the sun down the length of the bay and low enough that the Santa Cruz ridge has a lit side and a dark one. Nothing here is a fixed number of miles from anything: the standoff is the city scene's own orbit limit, which is why the region ships as five chapters and not as one impossible wide shot.",
      alt: "The San Francisco peninsula from above in late afternoon light: the Santa Cruz mountains green along one side, the built ground between them and the bay, sun glitter across the water and haze closing the far end of the bay.",
    },
    night: {
      at: "2026-08-06T21:35:00-07:00",
      note: "Twenty-five to ten, and the ridge is legible only as the edge the lights stop at.",
      alt: "The San Francisco peninsula at night, a band of lit ground between dark water and a darker mountain ridge.",
    },
  },
  {
    id: "socal-dtla",
    door: "tera",
    city: "socal",
    chapter: 1,
    expect: "DTLA",
    place: "Downtown Los Angeles",
    caption:
      "The second city pack, on the same engine and the same renderer. A city is pure data — coastlines, hills, district street bearings, landmarks and camera chapters in one file with no code in it — so adding one is a reviewable contribution rather than a fork. The camera is square to downtown's grid, which is why everything else in the frame looks crooked.",
    day: {
      at: "2026-08-06T18:10:00-07:00",
      note: "Ten past six, the San Gabriels standing behind the basin.",
      alt: "Downtown Los Angeles from above in late afternoon light, the river running past the tower cluster and the San Gabriel mountains behind the basin.",
    },
    night: {
      at: "2026-08-06T21:20:00-07:00",
      note: "Twenty past nine. The basin is the one place on either board where the lit ground runs to the edge of the frame in every direction.",
      alt: "Downtown Los Angeles at night, the tower cluster lit and the surrounding basin covered in light to the horizon.",
    },
  },
  {
    id: "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 both towers lay their own shadows out across the water beside the deck. 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: {
      /*
       * Half past nine, the same hour as every other night frame here — and it
       * is worth recording that it used to be 20:50 and why it no longer has to
       * be. A city at this hour is carried by its own windows; a bridge over
       * open water has none, so this shot was taken at nautical twilight instead
       * to keep a silhouette against the residual sky. The crossing now carries
       * its own deck lamps, so it draws itself at any hour, and a special case
       * that existed only to work around an unlit bridge could go.
       */
      at: "2026-08-06T21:35:00-07:00",
      note: "Twenty-five to ten, sun fifteen degrees under, and the bridge is now the brightest thing over the strait rather than the darkest. The lamps follow the deck down through both towers and up onto the Marin approach, with a red light standing over each tower head — all of it emissive rather than lit, so nothing on this crossing casts anything on anything else.",
      alt: "The same bridge after dark, the international orange gone to silhouette and the deck picked out by a running line of small lamps through both towers, a red light standing over each tower head, and the Marin headlands black behind.",
    },
  },
  {
    id: "bay-bridge",
    door: "tera",
    city: "sf",
    chapter: 11,
    expect: "The Bay",
    /*
     * Broadside to the crossing, from over the water off Alameda, and every
     * number here is the fix for a specific fault in the frame this replaces.
     *
     * That one seeked to 37.7995, -122.3775 — a point on the *west span*, a
     * kilometre and a half from the SF landing — at `zoom: -5`, which is a
     * standoff of about 1.8 km. From there the crossing could not fit: it ran
     * diagonally out of the top-right corner, the whole right half of the frame
     * was open water, and only the San Francisco shore was in it. A picture
     * called `bay-bridge` that does not contain the Oakland end is a picture of
     * a bridge to nowhere, and the caption had to say so.
     *
     * So: seek to 37.8085, -122.363 — a point on the causeway just east of
     * Yerba Buena, which is the *midpoint of what has to fit* rather than the
     * midpoint of the structure, because the panel eats the left 310 px and the
     * frame is therefore not symmetrical about its centre. `zoom: -4` puts the
     * camera about 4.1 km back, which is where the 6.4 km of crossing spans
     * roughly two thirds of the picture with both landings inside it.
     *
     * `azimuth: -45` is the one that matters. The chapter's own stance looks
     * almost due north (`rotation: 0.15`), and the crossing runs ENE at about
     * 57°, so from there it can only ever be a diagonal. Forty-five degrees of
     * drag turns the camera to look NNW, which is broadside: the bridge lies
     * across the frame, San Francisco at one end, the East Bay shore at the
     * other. `elevation: -6` drops the camera far enough to put the horizon in
     * — Marin, Angel Island and the Golden Gate close the top of the picture,
     * which is what stops the water reading as empty.
     */
    aim: { lat: 37.8085, lng: -122.363, zoom: -4, azimuth: -45, elevation: -6 },
    place: "The Bay Bridge",
    caption:
      "The same kit, given the real 2013 topology: two suspension towers west of Yerba Buena, one east of it, and a piered causeway running on 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. The lamps down the deck and the red light over each tower head are two more and no triangles at all — they are points, not lights, because a crossing this long lit for real would spend more of the frame budget than the whole city does.",
    day: {
      at: "2026-08-06T17:40:00-07:00",
      note: "Twenty to six, broadside to the crossing from over the water off Alameda, with the light coming down the length of it. One bridge, three different answers to the same water: two suspension spans off the San Francisco waterfront on the left, a deck that comes down onto Yerba Buena rather than standing over it, a third tower east of the island, and then piers the rest of the way to the East Bay shore in the corner.",
      alt: "The Bay Bridge from across the water in late afternoon light: two suspension spans leaving the San Francisco skyline at the left, the deck crossing Yerba Buena and Treasure Island in the middle, a third tower east of the island, and a causeway on piers running down to the East Bay shore at the right, with Marin and the Golden Gate behind.",
    },
    night: {
      at: "2026-08-06T21:35:00-07:00",
      note: "Twenty-five to ten, and both shores it joins are still in the frame — San Francisco's lit ground at one end, the dark East Bay flats at the other, and between them the only continuous line of light on the bay. The moon lays its broken path across the water behind the deck, which is the one thing here that was already bright at this hour.",
      alt: "The same crossing after dark, the deck drawn as a running line of small warm lamps from the lit San Francisco waterfront across to the dark East Bay shore, a red light over each tower head, and the moon's reflection broken across the water behind it.",
    },
  },
  {
    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: the whole of California, Mexico to Oregon, 554 units across at 1,919 metres to the unit — 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, running the length of the board; the Basin and Range steps away east of the Sierra, Death Valley is the pale pan below sea level inside it, the Salton Sea is the water in the south-east corner, and in the north the Sierra hands over to the Cascade arc with the Klamath knot west of it. 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 whole 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, the Salton Sea in the south and the Cascade and Klamath country closing the board in the north.",
    },
    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: "california-fire",
    door: "tera",
    city: "california",
    chapter: 0,
    expect: "State",
    /*
     * The one shot with a server behind it. See the long note beside
     * `serveFeed`: the fire layer is gated on `/health` naming a fire source, so
     * a keyless capture cannot photograph it at all, while every real visitor to
     * tera.lumbridgecorp.com gets it. The body is a fixture in this repo rather
     * than the live feed, so the picture is reproducible at the commit the
     * manifest names.
     *
     * The whole board rather than a close pass over one fire, and that is the
     * feature's own argument rather than a framing preference. There are no
     * perimeters in the store — every fire in it is a point — so the mark is
     * drawn as extent *without* shape, a radial gradient with no edge anywhere,
     * because a crisp circle would claim a boundary that would be wrong at every
     * point on it. That reads as designed at board scale, where it is a mark on a
     * map sized by acreage; close up it is a blur claiming nothing, and the eight
     * marks and the list that names them stop being one picture.
     *
     * The clock is 23 August rather than 6 August, unlike every other frame here,
     * because the panel prints the body's own `fetchedAt` and how long ago that
     * was. A fixture from the 23rd photographed on the 6th reads "fetched
     * 2026-08-23 · just now", which looks like a bug and is one.
     */
    feed: "fires",
    place: "California",
    caption:
      "The same board with a feed behind it. Eight fires are drawn here out of seventy-three live rows, and the gate that refused the other sixty-five is the whole feature: under ten acres or no acreage at all is not a small fire, it is nobody having said it is a fire; eighty per cent contained is finished; a prescribed burn is not a wildfire; and a row the collector stopped seeing but never deleted is a ghost. That decision is made once, in server code with no GL context in it, and this layer draws what it is handed and never one thing more. There are no perimeters in the store — every incident is a point — so a mark carries its acreage as extent without shape, a radial gradient with no edge, rather than a circle that would claim a boundary wrong at every point on it. The hot pixels underneath are evidence and not incidents, and the ninety-seven known flare stacks, kilns and landfills among them are counted and not drawn.",
    day: {
      at: "2026-08-23T09:40:00-07:00",
      note: "Twenty to ten in the morning, eight hours after the collector last answered — which the panel says out loud, because a board with no feed behind it and a board with nothing burning on it are indistinguishable without a timestamp. Every mark is sized by its own acreage, from the Timber Fire's 8,665 in Monterey down to a ten-acre start in San Bernardino, and the soft ground under each one is that acreage drawn as extent.",
      alt: "The whole state of California as a relief board in morning light with eight orange fire marks on it, each sitting in a soft glow sized to its acreage, and a panel down the left listing the incidents by name, acreage, containment and county.",
    },
    night: {
      at: "2026-08-23T21:10:00-07:00",
      note: "Ten past nine, and the marks are the only warm thing left on a board whose relief has gone. Nothing here is a THREE light — a wildfire is the most tempting exception in the codebase and a point light is one line — so the glow is emission in an over-range colour taken through ACES, which is both cheaper than a rig and a better picture of it.",
      alt: "The same board after dark, the land unlit and the eight fire marks glowing orange against it, with the lit ground of the two city clusters and the same incident list down the left.",
    },
  },
  {
    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",
      /*
       * This note used to end "the traffic overhead is the real traffic:
       * registration and type come off the same feed", and that sentence cannot
       * be true of this picture. Every frame here but `california-fire` is shot
       * keyless — no `/health`, so `access.feeds` is `null`, so `TrafficSource`
       * is the bundled simulator and not ADS-B. The deployed site does have the
       * feed; this photograph does not, and a caption may not borrow a fact from
       * a deployment the shutter never opened on.
       */
      note: "Ten to six, and the disagreement in this frame is the subject: the property is a cardinal rectangle, the pavement inside it is set about seven degrees off, and the basin's street grid outside the fence goes back to the cardinal. That is what reads as LAX from altitude — long before any building does.",
      alt: "Los Angeles International Airport from above in late afternoon light: four parallel runways either side of a horseshoe of terminals, set at a slight angle inside a rectangular property, with 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: "harbour",
    door: "tera",
    city: "socal",
    chapter: 5,
    expect: "Harbour",
    /*
     * The chapter's own stance is a wide plan of the whole bight from about
     * forty units up, which puts Terminal Island in the middle of a lot of
     * water and makes the cranes a fringe. The subject is the cranes, so this
     * comes down three notches to about the closest standoff the board allows
     * and drags forty-five degrees of azimuth to look across the island rather
     * than down at it — which is what stands the gantries up against the water
     * instead of laying them flat on the yard.
     *
     * The seek is a point in the West Basin rather than the middle of the
     * island, for `bay-bridge`'s reason: the panel eats the left 310 px, so the
     * midpoint of *what has to fit* is west and north of the midpoint of the
     * thing. From here the Vincent Thomas crosses the Main Channel in the lower
     * left with the channel running out under it, all four of the Los Angeles
     * crane rails are in frame at their four different bearings, and San Pedro
     * closes the bottom of the picture so the harbour has a town on it.
     */
    aim: { lat: 33.7405, lng: -118.2565, zoom: -3, azimuth: 45, elevation: 4 },
    place: "San Pedro Bay",
    caption:
      "Two ports around one basin — Los Angeles and Long Beach — behind thirteen kilometres of federal breakwater. The gantry crane is the hero of this kit and vertical exaggeration is why: at 3.4× on a board of 390 metres to the scene unit a 130 m crane stands 1.13 units tall while an entire 400 m container ship is 1.02 units long, so the crane is the taller object and it is what makes a port read as a port from altitude. All fifty-six gantries on the board are one InstancedMesh with a boom angle per instance — lowered over a berth reads as working, raised to near-vertical reads as stood down — and the four rails are set at four different bearings, because a port where every boom points the same way reads as a fence. The yards are the opposite call: a forty-foot box is one and a half pixels from the closest pose this board offers, so a terminal is one painted quad indexing one canvas atlas rather than ten thousand instanced boxes, banded at exactly the empty share the data claims.",
    day: {
      at: "2026-08-06T17:50:00-07:00",
      note: "Ten to six, low enough that every boom throws its own shadow back across the yard it stands on and the four rails read as four different bearings. The hulls alongside are modelled from this board's own berths and channels rather than observed — anonymous, no names and no MMSIs — because the live AIS feed is not configured and a panel that said otherwise would be the only dishonest thing in the frame.",
      alt: "The Los Angeles and Long Beach port complex from above in late afternoon light: container terminals on Terminal Island with rows of white gantry cranes along their quays, stacked container yards beside them, ships lying alongside and one under way in the Main Channel, the green Vincent Thomas Bridge crossing the channel and the breakwater running across the outer harbour.",
    },
    night: {
      at: "2026-08-06T21:20:00-07:00",
      note: "Twenty past nine, and nothing in a container terminal has a window to switch on. What marks one from the air after dark is the red obstruction light at the apex of every gantry — one per crane, the same fixture and the same red the bridge tower heads carry, drawn as points in the board's one additive cloud rather than as fifty-six lights nobody could afford.",
      alt: "The same harbour after dark: the terminals reading as pale decks against black water, a red light standing at the top of each gantry crane, the Vincent Thomas Bridge picked out by the lamps down its deck, and the lit ground of San Pedro and Long Beach around the basin.",
    },
  },
  /*
   * ---- The three that take a body ----------------------------------------
   *
   * Everything above is a camera looking at the world. These three are the
   * world being *driven*, and they exist because /arena needs a picture of each
   * spatial environment actually running: `drive-101-v1`, `crow-nav-v1` and
   * `california-flight-v1` are scored against Tera's vehicle, actor and
   * aircraft controllers, and the play HUD across the top of each frame is the
   * observation those environments are built out of.
   *
   * They replace three hand-made screenshots. Those were the last pictures on
   * lumbridgecorp.com that no script could reproduce, which meant they were also
   * the last pictures nobody could re-shoot when the engine moved: all three
   * still showed a left column with "Explore as your crow →" in it, a "FULL
   * VIEW" badge no visitor to the site ever sees, and a single daylight frame
   * served to dark-mode readers as well. Being a script is the point of them.
   */
  {
    id: "drive-101",
    door: "tera",
    city: "california",
    chapter: 1,
    expect: "101",
    /*
     * A place on the leg, not a number of seconds, and `freeway`'s `settle: 3000`
     * next door is the argument for it: the traffic simulation has been running
     * since page load, so a duration is only ever "boot plus a bit" and boot is
     * not a constant. The same nine seconds put this car at 55% of the leg in a
     * 1x preview and 69% in the 2x render, which is a different stretch of road
     * — the second one had the edge of a town across the top of the frame, where
     * buildings drawn at 1,919 m to the unit stand beside a road built at one.
     *
     * 55% is open oak country with both carriageways in the frame and other
     * traffic on them, which is the picture: an agent alone on a road is not
     * being asked anything.
     */
    play: { mode: "drive", expect: "Drive", untilProgress: 0.55 },
    place: "US-101, at the wheel",
    caption:
      "Taking the body is an input the app already accepts: the Drive button on the dock hands the vehicle controller the held keys and the follow camera, and the strip across the top is that controller reporting itself — speed, which road, whether the assist or a person is steering, how far along the leg, which camera. It is the same state Arena's drive-101 environment builds its observation out of, drawn for a human rather than serialised for a policy, which is the whole claim these pictures exist to make: the environment is this, running, and not a diagram of it. The unbadged Lumbridge EV is on the asphalt of the one corridor built at a metre to the metre, on a board that is otherwise 1,919 metres to the unit.",
    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 keep an edge under the car. Everything on the readout is the simulation's own number — 56 mph is what the controller is doing, not a figure somebody typed into a mock-up.",
      alt: "A chase camera above and behind a dark car driving a divided freeway through dry oak country, another vehicle further down the same carriageway, and a heads-up strip across the top of the frame reading its speed, the road name, the assist state and the route progress.",
    },
    night: {
      at: "2026-08-06T21:15:00-07:00",
      note: "Quarter past nine, and the car is now carrying its own light: a low beam widening away from the bumper up the carriageway with the markings inside it, two tail lamps back at the camera, edge lines and a yellow median pair running to the horizon, and cat's eyes ticking along beside the dashes. Nothing here is lit that would not be — the hills either side of US-101 still have nothing on them to switch on, and the beam is a pool drawn on the road rather than a lamp added to the scene, because the sun and the moon own every light in it.",
      alt: "The same chase camera at night: the car throwing a low beam up the road in front of itself with the lane markings inside it, its two tail lamps facing the camera, and continuous edge lines and a yellow median pair defining both carriageways into the dark, with the same heads-up strip along the top.",
    },
  },
  {
    id: "crow-nav",
    door: "tera",
    city: "sf",
    chapter: 0,
    expect: "Whole Board",
    /*
     * The Bay Area board rather than California, which is where the hand-made
     * original was taken. Two reasons, and the second is the one that decides it.
     *
     * The crow is authored at about a metre. On the state board a metre is
     * 0.0005 of a scene unit and the frame around it is bare ground drawn at
     * 1,919 m to the unit, so the original is a bird in front of a featureless
     * sand-coloured wall with one oak the size of a hill behind it — the same
     * scale mismatch the `freeway` note warns about, and it reads as a rendering
     * fault. On the Bay Area board a metre is a hundredth of a unit and there is
     * a city under the wings.
     *
     * And the night frame has to stand on its own. It is served to every
     * dark-mode reader on the page and it is the only frame they get; a crow
     * over unlit valley floor at half past nine is a black rectangle with a bird
     * in it. Over San Francisco the ground carries itself.
     */
    play: { mode: "actor", expect: "Crow", run: 5000 },
    place: "Over San Francisco, as a crow",
    caption:
      "The same dock, a different body. Explore hands the held keys to an articulated crow, and the wings are not a clip being played: the controller carries a pose — flap, glide, bank, tuck, perch — and a phase whose rate rises with throttle and falls as the reserve goes, so what the bird is doing is a readout of the same numbers the HUD is showing. Airspeed and altitude in place of a speedometer, and the flight energy that beating spends and gliding does not, which is why Arena's crow-nav environment can price powered circling: it costs the one quantity the goal also needs.",
    day: {
      at: "2026-08-06T09:20:00-07:00",
      note: "Twenty past nine, over the downtown towers with the bay, a crossing and the far shore stacked up behind them — the whole board is in one frame because the body is above all of it, which is the one camera the chapter list has no button for.",
      alt: "An articulated crow seen from behind, wings spread, flying over the towers of downtown San Francisco in morning light with the bay, a crossing and the far shore beyond, and a heads-up strip along the top reading its airspeed, altitude, pose and flight energy.",
    },
    night: {
      at: "2026-08-06T21:35:00-07:00",
      note: "Twenty-five to ten, and the frame has inverted: by day the bird is the dark thing on a pale board, and at night it is the dark thing on a board made of windows. Same body, same altitude, same pose — only the hour was asked for.",
      alt: "The same crow at night, silhouetted against the lit windows of the San Francisco towers below and the dark bay beyond, with the same heads-up strip along the top.",
    },
  },
  {
    id: "california-flight",
    door: "tera",
    city: "california",
    chapter: 0,
    expect: "State",
    /*
     * Chapter 0 rather than 1, and it matters for the same reason an aimed shot
     * picks its chapter: the blurb left in the panel is a caption inside the
     * photograph. "State" says the two cities are joined as one route board,
     * which is exactly what the aircraft in the frame is flying between; "101"
     * would be talking about a road the frame does not contain.
     *
     * The aircraft is on the California board and nowhere else — `main.ts`
     * builds it only for that pack — so this shot cannot move to another board
     * the way the crow did. Five seconds from the LA waypoint is far enough that
     * it is over ground rather than sitting on its start, and near enough that
     * the basin is still what is underneath at night.
     */
    play: { mode: "aircraft", expect: "Flight", run: 5000 },
    place: "The California corridor, flying",
    caption:
      "The third body and the only one with a published route: the distributed-electric aircraft starts at Los Angeles on a three-waypoint corridor to San Francisco, and Fly takes it off its own autopilot and gives you the yoke. The HUD is in knots, metres and watt-hours because that is what an aircraft has, and the battery is the reason the environment is not a pure altitude race — Arena's california-flight scores waypoint progress against energy, and climbing is the cheapest way to farm a number that is not the goal.",
    day: {
      at: "2026-08-06T18:00:00-07:00",
      /*
       * This note used to end "and the Pacific past the wingtip", and the sea has
       * since gone out of the frame — not because the aircraft moved but because
       * the air did. Fog now follows camera altitude, so at 1,413 m the far ridges
       * grey out in the order they recede and the ocean behind them is inside the
       * haze rather than beyond it. Rewritten to the frame that is delivered.
       */
      note: "Six in the evening, five seconds off the Los Angeles waypoint on a heading of 320°, low over the Coast Ranges with the corridor threading the valley underneath. The ridges grey out in the order they recede rather than all at once, because the fog follows the camera's own altitude now — aerial perspective that only exists once something is flying through it. The altitude on the readout is above sea level, not above the ridge under it: the controller keeps both, and the one that terminates an episode is the clearance.",
      alt: "A distributed-electric aircraft seen from behind and above, flying north-west over green coastal mountains in late afternoon light, a freeway threading the valley below and the far ridges fading one behind another into haze, with a heads-up strip along the top reading its airspeed, altitude, flight mode and battery.",
    },
    night: {
      at: "2026-08-06T21:20:00-07:00",
      note: "Twenty past nine, and there is no daylight anywhere in this frame. Two things carry it. The aircraft's own lights — red on the port wingtip, green on the starboard, white strobes out along the leading edge and a light under the tail — are unlit materials, so they are the only part of this picture that does not care what the sun is doing. And the corridor under the wing, where the road's edge lines and cat's eyes are now the brightest thing left on the ground.",
      alt: "The same aircraft at night over dark coastal mountains, its red, green and white navigation lights the brightest thing in the air, the freeway below picked out by its own lane markings, and the same heads-up strip along the top.",
    },
  },
  {
    id: "office-floor",
    hide: ["#device-section"],
    door: "office",
    chapter: 1,
    expect: "Studio",
    place: "SF HQ · Studio",
    caption:
      "The same renderer from inside, at one unit to the metre — which is why this cannot share a THREE.Scene with the city, where San Francisco puts one unit at about 94. A live/work studio rather than a floorplate: the desk, the bench and the domestic half are one room, and an Office is one building's interior while a Space is a room within it.",
    day: {
      at: "2026-08-06T11:20:00-07:00",
      note: "Twenty past eleven, daylight through the glazing — the sun is the real one over this coordinate, and the pack says where on the earth it stands and which way it is turned.",
      alt: "A studio interior rendered in 3D as an open-topped model: desks with monitors, a sofa and rug, shelving and planting, with its floor plan drawn beside it.",
    },
    night: {
      at: "2026-08-06T21:30:00-07:00",
      note: "Half past nine. The building has switched its own lights on, because the sun it is following has gone down over the real San Francisco — and a fitting brightens as somebody walks under it.",
      alt: "The same studio at night, lit from within by its ceiling fittings against a dark ground.",
    },
  },
  {
    id: "office-desks",
    door: "office",
    chapter: 2,
    expect: "Agent Bench",
    place: "SF HQ · Studio",
    caption:
      "The bench, and the hardware on it. A microphone on the desk and a speaker on the machine are declared by the pack and simulated deterministically behind an adapter a real device API can occupy later — power, gain, level, volume, all readable by anyone, all honestly labelled as demonstration data. Stepping in here does not throw the city away: the city scene is retained and paused rather than disposed, because rebuilding the Bay's heightfield costs about 2.3 seconds.",
    day: {
      at: "2026-08-06T11:20:00-07:00",
      note: "Twenty past eleven, daylight across the bench.",
      alt: "A close view of a two-place desk bench in a studio: monitors, chairs, a desk microphone and a speaker, with planting behind.",
    },
    night: {
      at: "2026-08-06T21:30:00-07:00",
      note: "Half past nine, and the windows have stopped being the light source. The fittings have taken over.",
      alt: "The same bench at night, lit by the studio's own lighting with darkness beyond the windows.",
    },
  },
  {
    id: "office-commons",
    hide: ["#device-section"],
    door: "office",
    office: "LA HQ",
    chapter: 6,
    expect: "Courtyard",
    place: "LA HQ · Office, Arts District",
    caption:
      "A second studio, eight hundred kilometres south, and the argument for the format being a format. A courtyard block square to the 1781 pueblo grid rather than to the compass — which is the whole reason a pack carries a heading, because the heading decides which walls the sun comes through. Same engine, same clock, same sun; nothing here was ever a mesh file.",
    day: {
      at: "2026-08-06T11:20:00-07:00",
      note: "Twenty past eleven, and the courtyard is taking the sun the way a courtyard at 36 degrees off north does.",
      alt: "An open courtyard inside a two-storey block rendered in 3D: planters, a parasol, tables and chairs, glazed offices around the edges.",
    },
    night: {
      at: "2026-08-06T21:30:00-07:00",
      note: "Half past nine, and the courtyard is lit from the rooms around it rather than from above.",
      alt: "The same courtyard at night, lit from the glazed rooms around its edges.",
    },
  },
  {
    id: "office-hangar",
    hide: ["#device-section"],
    door: "office",
    office: "Frontier Valley",
    chapter: 0,
    expect: "Hangar",
    place: "Frontier Valley, Alameda Point",
    caption:
      "A third pack, still in development, and the clearest statement of what an office pack is: one room, fifty-four metres by thirty, nine to the trusses, in a hangar on the old naval air station across the estuary. It feels nothing like either studio because four metres above an airfield is not a hundred and eighty-eight above Transbay, and the pack is what says so.",
    day: {
      at: "2026-08-06T11:20:00-07:00",
      note: "Twenty past eleven. North light through the clerestory, which is the light a shed actually wants.",
      alt: "A large open hangar interior rendered in 3D: benches of desks down the middle, freestanding meeting boxes at one end, a mezzanine deck at the other, and a twelve-metre door.",
    },
    night: {
      at: "2026-08-06T21:30:00-07:00",
      note: "Half past nine, and the trusses are carrying the lights.",
      alt: "The same hangar at night, lit from the roof trusses.",
    },
  },
];

/** The two frames every shot is taken in, in the order they are rendered. */
const VARIANTS = ["day", "night"];

// ---- Sizes ------------------------------------------------------------------

/**
 * Shot at 2x and delivered at two widths, because the pages that use these run
 * a picture at about 720 CSS px on a desktop and full-bleed on a phone.
 *
 * WebP rather than PNG, and by a wide margin: these frames are photographs of a
 * continuous-tone render, and PNG spends about 650 KB on what WebP says in 90.
 * The encoder is the browser that is already open — this box has no `sharp`, no
 * ImageMagick and no `rsvg-convert`, which is the same constraint that made the
 * share cards a screenshot in the first place.
 */
const VIEWPORT = { width: 1440, height: 900 };
const SIZES = [
  { suffix: "1600", width: 1600, quality: 0.82 },
  { suffix: "800", width: 800, quality: 0.84 },
];
/** The archive frame: full sensor, near-lossless, kept forever. */
const ARCHIVE_QUALITY = 0.94;

/**
 * ---- 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);
}

/**
 * ---- Taking a body ----------------------------------------------------------
 *
 * `aim` points a camera at the world. `play` asks the app to hand over a *body*
 * — the car, the crow, the aircraft — and then photographs the world moving
 * under it, with the play HUD deliberately left in the frame.
 *
 * It is the same kind of input `aim` is, and for the same reason: the engine has
 * no back door. There is no `?mode=drive`, and `main.ts` keeps the control mode
 * in a module-scoped `ControlModeState` that nothing outside it can write. What
 * it does have is the dock — `<nav id="mode-dock">`, five buttons carrying
 * `data-control-mode` — which is the control a reader uses, so it is the control
 * a shot uses.
 *
 * ### Three things this asserts, and why each one is not ceremony
 *
 * **1. The button is offered before it is pressed.** A mode exists only where
 * the pack builds a body for it: `availableControlModes()` in `main.ts` puts the
 * EV and the aircraft on the California board *alone*, and gates driving further
 * on the current route being `la-sf-us-101` or `la-sf-i-5` — so "Drive" is not
 * merely unpressed on the Bay Area board, it is `hidden`. Clicking a hidden
 * button is a no-op that leaves the camera in `overview` and the run cheerfully
 * shooting a wide shot. The failure below names what the board *did* offer,
 * because that is the sentence that tells you whether the shot list is wrong or
 * the pack is.
 *
 * **2. The mode is confirmed off the app, not off the click.** Exactly as
 * `performance-budget.mjs` does for its `california-drive` scene: wait for
 * `[data-control-mode='…'][aria-pressed='true']` **and** `#play-hud` unhidden.
 * `transitionControlMode` can silently substitute the fallback mode for a
 * request it will not honour, so a click that "worked" and a mode that changed
 * are two different facts. Pressing a key and hoping is how the tera share card
 * ended up being a chase camera on US-101 under the headline "Cities from
 * above".
 *
 * **3. The HUD says which body, at the shutter and not only at the click.**
 * `expect` here is the chapter guard's twin: `#play-hud-mode` reads "Drive",
 * "Crow", "Flight" or "Studio", and the actor mode in particular reports *which*
 * actor — a signed-in visitor gets a humanoid and the chip reads "Explore"
 * instead. The check is repeated after `run` because a body can lose the mode
 * while the wait is going: a completed route, a bound, an envelope contact. A
 * frame shot two seconds after the app dropped back to `overview` is a wide shot
 * with a correct-looking filename, which is precisely the class of failure the
 * hand-made originals could not be checked for.
 *
 * ### `run` for a body that was asleep, `untilProgress` for one that was not
 *
 * `run` is a duration, and it is honest for two of the three bodies: the crow
 * and the aircraft are built inert — `createSceneAircraft` takes `active: false`
 * so that "construction never steals input or camera ownership" — so neither of
 * them has moved a metre before the dock hands it over, and n milliseconds after
 * that is the same place every time. Measured: the crow reports 9.9 m/s at
 * 1,202 m and the aircraft 120 kt at 1,403 m, identically at `deviceScaleFactor`
 * 1 and 2.
 *
 * The car is different, and the difference cost a delivered frame. Road traffic
 * runs from page load, so a duration measured from the mode change is a duration
 * measured from *boot*, and boot is not a constant: the same `run: 9000` put the
 * EV at 55% of the leg in a 1x preview and at 69% in the 2x render — a different
 * stretch of road, in a frame with the edge of a town across the top of it. A
 * preview that does not predict the delivery is worse than no preview.
 *
 * So `untilProgress` waits on the app's own number instead, which is trap three
 * of this file applied to the simulation rather than to boot. Route progress is
 * the one monotonic quantity any of these HUDs publishes; it is a position along
 * the leg, so the same fraction is the same place whatever the machine was
 * doing. It is read out of `#play-hud-status` because that is where
 * `formatPlayHud` puts it and the engine offers no other channel — a formatted
 * string is a poor interface, and the alternative was a picture that moves.
 *
 * Overshoot is a failure and not a wait. If the leg is already past the target
 * when the mode is taken, the shot cannot get there by waiting and the frame
 * would be of somewhere the caption does not describe, so this throws rather
 * than shooting. On `la-sf-us-101` there is also a ceiling: at 100% the EV
 * reaches San Francisco, the app swaps to the Bay Area board, and the shot is of
 * a different product entirely.
 */
async function enterPlay(page, play) {
  const read = () =>
    page.evaluate((wanted) => {
      const buttons = [...document.querySelectorAll("#mode-dock [data-control-mode]")];
      const button = buttons.find((b) => b.getAttribute("data-control-mode") === wanted) ?? null;
      const hud = document.getElementById("play-hud");
      const text = (id) => document.getElementById(id)?.textContent?.trim() ?? "";
      const status = text("play-hud-status");
      const percent = /(\d+)%/.exec(status);
      return {
        present: button !== null,
        hidden: button === null || button.hidden,
        pressed: button?.getAttribute("aria-pressed") === "true",
        hudVisible: hud !== null && hud.hidden === false,
        mode: text("play-hud-mode"),
        primary: text("play-hud-primary"),
        status,
        progress: percent ? Number(percent[1]) / 100 : null,
        offered: buttons
          .filter((b) => !b.hidden)
          .map((b) => b.getAttribute("data-control-mode")),
      };
    }, play.mode);

  const before = await read();
  if (!before.present) {
    throw new Error(
      `the mode dock has no "${play.mode}" button — index.html's dock changed under the ` +
        "shot list, so a shot carrying `play` cannot take a body",
    );
  }
  if (before.hidden) {
    throw new Error(
      `"${play.mode}" is not offered here — the dock is showing ` +
        `[${before.offered.join(", ") || "nothing"}]. A body exists only where the pack ` +
        "builds one: the EV and the aircraft are on the California board alone, and " +
        "driving additionally needs US-101 or I-5 to be the current chapter",
    );
  }
  // Already pressed is the normal case for `drive`: choosing the US-101 chapter
  // enters drive mode by itself, so the shot asserts the mode it inherited
  // rather than toggling it back off by clicking a button that is already on.
  if (!before.pressed) {
    await page.click(`#mode-dock [data-control-mode="${play.mode}"]`);
    await page.waitForFunction(
      (wanted) => {
        const button = document.querySelector(`#mode-dock [data-control-mode="${wanted}"]`);
        const hud = document.getElementById("play-hud");
        return button?.getAttribute("aria-pressed") === "true" && hud?.hidden === false;
      },
      play.mode,
      { timeout: 60_000 },
    );
  }

  const entered = await read();
  if (!entered.pressed || !entered.hudVisible) {
    throw new Error(
      `the dock did not take "${play.mode}" — it is showing [${entered.offered.join(", ")}] ` +
        `and the HUD is ${entered.hudVisible ? "up" : "down"}. \`transitionControlMode\` ` +
        "substitutes the fallback mode for a request it will not honour, so this is a " +
        "refusal and not a slow transition",
    );
  }
  if (entered.mode !== play.expect) {
    throw new Error(
      `the play HUD says "${entered.mode}", not "${play.expect}" — the mode was entered ` +
        "but the body is not the one this shot's caption describes (an actor is a crow " +
        "for an anonymous visitor and a humanoid for a signed-in one, and the chip says " +
        "which). Fix the shot list before anything ships",
    );
  }

  if (play.untilProgress !== undefined) {
    if (entered.progress === null) {
      throw new Error(
        `\`untilProgress\` was asked of the "${play.expect}" HUD, whose status reads ` +
          `"${entered.status}" and carries no percentage. Only the drive HUD publishes a ` +
          "route progress; the other bodies are inert until the dock hands them over, so " +
          "`run` is exact for them",
      );
    }
    if (entered.progress > play.untilProgress + 0.02) {
      throw new Error(
        `the leg was already ${Math.round(entered.progress * 100)}% done when "${play.mode}" ` +
          `was taken, past this shot's ${Math.round(play.untilProgress * 100)}% — waiting ` +
          "cannot go backwards, so the frame would be of a stretch of road the caption does " +
          "not describe. Boot got slower; raise `untilProgress` and re-check the framing",
      );
    }
    await page.waitForFunction(
      (target) => {
        const found = /(\d+)%/.exec(
          document.getElementById("play-hud-status")?.textContent ?? "",
        );
        return found !== null && Number(found[1]) / 100 >= target;
      },
      play.untilProgress,
      { timeout: 60_000 },
    );
  }

  if (play.run) await page.waitForTimeout(play.run);

  // The shutter guard. A body can lose its mode during `run` — a route that
  // completed, a bound, an envelope contact — and the frame that comes back is a
  // wide shot with the right filename.
  const shutter = await read();
  if (!shutter.pressed || !shutter.hudVisible || shutter.mode !== play.expect) {
    throw new Error(
      `"${play.mode}" did not survive the wait — the dock now reads ` +
        `${shutter.pressed ? "pressed" : "released"}, the HUD is ` +
        `${shutter.hudVisible ? `up saying "${shutter.mode}"` : "down"}. A completed route, ` +
        "a bound or an envelope contact ends a mode, and the frame that comes back is a " +
        "wide shot with the right filename. Shorten the wait",
    );
  }
  return shutter;
}

// ---- Arguments --------------------------------------------------------------

function flag(name, fallback = null) {
  const i = process.argv.indexOf(`--${name}`);
  return i > -1 && process.argv[i + 1] && !process.argv[i + 1].startsWith("--")
    ? process.argv[i + 1]
    : fallback;
}

const preview = process.argv.includes("--preview");
/**
 * Rewrite the site's manifest from the shot list without opening a browser.
 *
 * A caption is prose and gets edited like prose — several times, in a row, to
 * hear how it reads. Charging a full render for each pass is how a caption
 * ends up shipped in the first wording anybody tried,
 * which is the same failure the share-card README describes. The images this
 * points at are the ones already on disk, and the commit it stamps is today's;
 * both are true, because the pictures did not change.
 */
const manifestOnly = process.argv.includes("--manifest-only");
/**
 * 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")));
const wanted = only ? SHOTS.filter((s) => only.includes(s.id)) : SHOTS;

if (only) {
  const unknown = only.filter((id) => !SHOTS.some((s) => s.id === id));
  if (unknown.length) {
    console.error(`unknown shot(s): ${unknown.join(", ")}`);
    process.exit(1);
  }
}

// ---- Provenance -------------------------------------------------------------

const git = (...args) => execFileSync("git", args, { cwd: ROOT, encoding: "utf8" }).trim();
const commit = git("rev-parse", "--short", "HEAD");
/**
 * Recorded, and shown on the site. A shot taken from a working tree with
 * uncommitted changes is a picture of a build that exists on exactly one
 * machine, and the manifest should say so rather than name a commit that would
 * render something else.
 */
const dirty = git("status", "--porcelain").length > 0;
const today = new Date().toISOString().slice(0, 10);

// ---- Capture ----------------------------------------------------------------

/**
 * Re-encode a PNG buffer to WebP at a target width, using the browser as the
 * codec. `height` follows from the source aspect so a size table only has to
 * carry widths.
 */
async function encode(page, png, width, quality) {
  const dataUrl = await page.evaluate(
    async ({ b64, w, q }) => {
      const img = new Image();
      img.src = `data:image/png;base64,${b64}`;
      await img.decode();
      const canvas = document.createElement("canvas");
      canvas.width = w;
      canvas.height = Math.round((img.naturalHeight / img.naturalWidth) * w);
      const ctx = canvas.getContext("2d");
      ctx.imageSmoothingEnabled = true;
      ctx.imageSmoothingQuality = "high";
      ctx.drawImage(img, 0, 0, canvas.width, canvas.height);
      return canvas.toDataURL("image/webp", q);
    },
    { b64: png.toString("base64"), w: width, q: quality },
  );
  return Buffer.from(dataUrl.slice(dataUrl.indexOf(",") + 1), "base64");
}

/**
 * Read a chapter button's label, and optionally press it.
 *
 * The index is all the 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 found = await page.evaluate(
    ({ i, press }) => {
      const button = [...document.querySelectorAll("#chapters .chapter")][i];
      if (!button) return null;
      if (press) 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 ?? button.textContent ?? "").trim(),
      };
    },
    { i: index, press: click },
  );
  if (found === null) throw new Error(`no chapter at index ${index}`);
  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 shot list and its captions need re-checking ` +
        `before anything ships`,
    );
  }
}


/**
 * ---- The one shot that needs a server behind it ----------------------------
 *
 * Every other frame here is the **keyless** experience, and that is the right
 * default rather than a limitation. `harness.mjs` serves `dist/` and nothing
 * else, `/api/v1/health` 404s, `access.ts` resolves with `feeds: null`, and what
 * is photographed is what CONTRACT §0's stranger sees on a box with no API.
 * Almost all of this product is that picture.
 *
 * Fire is the exception, and it is not a small one. `drawsFire()` in `main.ts`
 * gates the entire layer on `access.feeds?.fires === true`, which comes from
 * `/health`'s `sources` block — so a keyless capture cannot photograph it at
 * all, while `tera.lumbridgecorp.com` answers `"fires":"cloud1"` and every
 * anonymous visitor to the deployed site does get it. Without this the site
 * would have shipped an imagery set in which the most visible thing the engine
 * gained this round did not exist, and the pictures would have looked entirely
 * deliberate while doing it.
 *
 * ### A fixture in this repo, not a proxy to the live feed
 *
 * `scripts/look.mjs --api` points a browser at a running server, which is the
 * right tool for judging a layer against real data. It is the wrong tool for
 * *delivering* a picture. This script's whole claim is that anything on the site
 * can be reproduced by re-running it at the commit the manifest names, and a
 * frame whose content is whatever California is doing today does not have that
 * property: it re-renders with different fires, a different count and a
 * different list under an unchanged caption, and nothing says so.
 *
 * So `fires-2026-08-23.json` is the verbatim body `GET /api/v1/fires` returned
 * on 23 August 2026, kept whole — seventy-three incident rows, three hundred and
 * sixteen hot pixels, both attribution lines, nothing trimmed. Trimming it would
 * have been the tempting thing and would have quietly falsified the panel:
 * `promote()` in `server/fires.ts` counts the rows it *refuses* and the panel
 * prints that number, so a fixture with the refused rows removed would draw the
 * same eight fires under a sentence claiming a gate that never ran.
 *
 * ### The health body says as little as it can
 *
 * Synthetic, and deliberately minimal. Every source but `fires` is `"none"`, so
 * exactly one thing changes in the frame and nothing else in the picture becomes
 * a claim about a feed this capture does not have. `auth.mode` is `"none"`,
 * which `access.ts` resolves to `member` — the self-host-with-the-door-open
 * case — and that keeps `#tier-adds` out of the shot. That card is the sign-in
 * offer, it is shown only to `anon`, and it is **not** in `FURNITURE.CLUTTER`:
 * it would have arrived in this frame silently, the way `#mode-dock` arrived in
 * the share card and stayed for a fortnight.
 *
 * Anything under `/api/v1` that is not these two answers 404, which is the state
 * every watcher in the app is already written for.
 */
const FEEDS = {
  fires: {
    fixture: "fires-2026-08-23.json",
    route: "/api/v1/fires",
    sources: {
      weather: "none",
      flights: "none",
      satellites: "none",
      markers: "none",
      devices: "none",
      fires: "cloud1",
      radar: "none",
      birds: "none",
    },
  },
};

/**
 * Put one fixture behind `/api/v1`, before the page is navigated.
 *
 * Installed per page rather than per context, so a shot without `feed` cannot
 * inherit it — `shoot()` opens a fresh page for every frame, which makes the
 * isolation free.
 *
 * `route.fulfill` rather than a redirect to a second server, for `look.mjs`'s
 * reason: the response is same-origin as far as the page is concerned, so no
 * CORS header has to exist anywhere for a screenshot to work.
 */
async function serveFeed(page, name) {
  const feed = FEEDS[name];
  if (feed === undefined) throw new Error(`no such feed fixture: ${name}`);
  const body = await readFile(join(HERE, feed.fixture), "utf8");
  const health = JSON.stringify({
    ok: true,
    service: "tera-api",
    version: `fixture:${feed.fixture}`,
    uptimeSeconds: 0,
    sources: feed.sources,
    auth: { mode: "none" },
    regions: [],
    degraded: [],
  });
  await page.route("**/api/v1/**", async (route) => {
    const path = new URL(route.request().url()).pathname;
    const answer = path.endsWith("/health") ? health : path === feed.route ? body : null;
    await route.fulfill(
      answer === null
        ? { status: 404, contentType: "application/json", body: "{}" }
        : { status: 200, contentType: "application/json", body: answer },
    );
  });
}

async function shoot(browser, shot, frame) {
  const host = shot.door === "office" ? "office.lumbridgecorp.com" : "tera.lumbridgecorp.com";
  /*
   * **Both halves of this are explicit on purpose, and neither used to be.**
   *
   * `city` used to fall through to the app's own default when a shot did not
   * declare one, and four of the twenty-one shots relied on that. The default
   * then moved from the state board to the Bay Area, which would have re-pointed
   * those four at a different place while every filename, caption and `alt`
   * stayed exactly as it was — the silent kind of wrong this harness exists to
   * prevent. So the fallback is named here rather than borrowed from the app.
   *
   * `handover=0` pins the free-camera tier promotion off. With it on, a planted
   * pose wider than a board's retention band demotes to the coarser tier while
   * the shutter is open, and the frame that comes back is a real photograph of
   * the wrong board. A capture harness must not share a camera policy with a
   * visitor.
   */
  const query = `?city=${shot.city ?? "california"}&handover=0`;
  const page = await browser.newPage({
    viewport: VIEWPORT,
    deviceScaleFactor: preview ? 1 : 2,
    /**
     * The camera cuts instead of flying, and this is the difference between a
     * shot list that takes three minutes and one that takes twelve — or worse,
     * one that quietly photographs the camera mid-flight.
     *
     * `scenekit.ts` eases a chapter change over about two seconds of *scene*
     * time, and `stage.ts` clamps `dt` to 50 ms a frame — so a flight is about
     * forty frames however fast they are drawn, and any fixed wait shorter than
     * they take lands somewhere over the bay. Under the software fallback that
     * was twenty seconds. Reduced motion is the app's own
     * documented answer to "I only clicked a name in a list": `flyTo` calls
     * `setPose` and the pose is simply true on the next frame.
     */
    reducedMotion: "reduce",
  });
  const problems = [];
  page.on("pageerror", (e) => problems.push(String(e)));
  try {
    await page.addInitScript(clockShim(frame.at));
    // Before the navigation: `resolveAccess()` asks `/health` during boot, and a
    // route installed after `goto` would miss it and photograph a keyless board.
    if (shot.feed) await serveFeed(page, shot.feed);
    await page.goto(`http://${host}:5210/${query}`, { waitUntil: "networkidle" });
    /**
     * Wait for the app to say it is up rather than for a number of seconds.
     * The terrain is built in a worker and the city is ~83k instances, and how
     * long that takes on software GL depends on the city, the machine and what
     * else is running — a fixed sleep is either wrong or wasteful, and here it
     * would have been both.
     */
    await page.waitForFunction(
      () =>
        document.getElementById("boot")?.hidden === true &&
        document.querySelectorAll("#chapters .chapter").length > 0,
      undefined,
      { timeout: 180_000 },
    );
    // Boot hiding means the scene exists, not that it has drawn a full frame.
    await page.waitForTimeout(preview ? 3000 : 6000);
    /**
     * Which building, before which view of it.
     *
     * The office door opens on the first entry in `OFFICES` and there are two
     * of them now, so a shot of the hangar has to press the picker first — and
     * has to do it *before* choosing a chapter, because switching buildings
     * disposes the scene and rebuilds it, which would strand a chapter click
     * against a legend that no longer exists.
     *
     * Selected by visible label rather than by index, and asserted, for the same
     * reason `chapter()` asserts its `shortLabel`: a reordered `OFFICES` table
     * would otherwise silently photograph the wrong building and caption it with
     * the other one's name.
     */
    if (shot.office) {
      const button = page.locator("#cities button", { hasText: shot.office });
      const count = await button.count();
      if (count === 0) {
        throw new Error(
          `no office picker button matching "${shot.office}" — the OFFICES table ` +
            `changed, so the shot list and its captions need re-checking`,
        );
      }
      await button.first().click();
      // A building switch tears the scene down and builds another. Wait on the
      // app again rather than on a clock, exactly as the first boot does.
      await page.waitForFunction(
        () =>
          document.getElementById("boot")?.hidden === true &&
          document.querySelectorAll("#chapters .chapter").length > 0,
        undefined,
      { timeout: 180_000 },
      );
      await page.waitForTimeout(preview ? 3000 : 6000);
    }
    await chapter(page, shot.chapter, shot.expect, { click: shot.chapter > 0 });
    if (shot.chapter > 0) await page.waitForTimeout(2500);
    /*
     * 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.play` asks the app for a body and lets it move before the shutter
     * opens. It comes last of the three camera steps because it *takes the
     * camera away* — the follow camera owns the pose in every play mode, so a
     * chapter chosen underneath it survives only as the blurb left in the panel,
     * and an `aim` under it would be overwritten. See the long note beside
     * `enterPlay` for what it asserts and why each assertion is load-bearing.
     */
    const hud = shot.play ? await enterPlay(page, shot.play) : null;
    /*
     * `shot.hide` is the per-frame escape hatch, and the studio-hardware card is
     * why it exists.
     *
     * The device section is a `.panel-group` inside a scrolling card, so when the
     * panel is taller than the viewport the card scrolls and the last control is
     * simply below the fold. That is correct on a screen — a reader scrolls — and
     * wrong in a photograph, where the frame edge slices a gain slider in half and
     * every viewer who does not know the card scrolls reads it as a broken render.
     *
     * It is also a question of subject. `office-desks` is a picture *of* the desk
     * mic and the machine speaker and wants the card open beside them. The
     * courtyard is a picture of a courtyard, and a column of audio controls down
     * one side of it is answering a question nobody asked.
     */
    /*
     * `#mode-dock` and `#play-hud` are **not** in `CLUTTER`, and for these three
     * shots that is the point rather than an oversight — the HUD is what the
     * picture is of. They are in `BARE`, which is the share card's list, because
     * a card supplies its own typography; a product still of an agent driving
     * with its instruments cropped off is a photograph of a road.
     */
    await hide(page, [...FURNITURE.transient, ...FURNITURE.CLUTTER, ...(shot.hide ?? [])]);
    const png = await page.screenshot({ timeout: 120_000, animations: "disabled" });
    if (problems.length) {
      throw new Error(`the page threw while being photographed: ${problems[0]}`);
    }
    return { png, hud };
  } finally {
    await page.close();
  }
}

// ---- Run --------------------------------------------------------------------

const exists = (p) => access(p).then(() => true, () => false);
const publicDir = join(siteDir, "apps", "web", "public", "shots");
const dataDir = join(siteDir, "apps", "web", "src", "data");
const haveSite = () => exists(join(siteDir, "apps", "web"));
const noSite = () =>
  console.log(
    `site    skipped — no checkout at ${siteDir}\n` +
      `        (pass --site <dir>; the archive is complete either way)`,
  );

if (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}` : "";
    /*
     * `--list` is how `refresh.mjs --dry-run` says what a run would do without
     * opening a browser, and it is what the chapter guard's failure message
     * tells you to run. A capability the listing does not mention is a
     * capability a reader of the plan cannot see, so `play` prints the mode it
     * asks for, the HUD chip it demands back, and how long it lets the world run.
     */
    const waited = shot.play?.untilProgress !== undefined
      ? `to ${Math.round(shot.play.untilProgress * 100)}% of the leg`
      : `for ${shot.play?.run ?? 0} ms`;
    const played = shot.play
      ? ` playing ${shot.play.mode} → HUD "${shot.play.expect}", ${waited}`
      : "";
    // Same argument as `play` above: a shot that is *not* the keyless
    // experience has to say so in the plan, because "this frame had a server
    // behind it" is the single fact that most changes what it is a photograph
    // of, and `refresh --dry-run` is where somebody reads the plan.
    const fed = shot.feed ? ` against the ${shot.feed} fixture ${FEEDS[shot.feed].fixture}` : "";
    console.log(`  ${shot.id.padEnd(20)} ${where.padEnd(22)} chapter ${shot.chapter} "${shot.expect}"${aimed}${played}${fed}`);
    for (const variant of VARIANTS) console.log(`    ${variant.padEnd(6)} ${shot[variant].at}`);
  }
  process.exit(0);
}

if (manifestOnly) {
  if (!(await haveSite())) {
    noSite();
  } else {
    await mkdir(dataDir, { recursive: true });
    await writeFile(join(dataDir, "shots.ts"), manifest());
    console.log(`manifest → ${join(dataDir, "shots.ts")} (no images re-rendered)`);
  }
  process.exit(0);
}

const app = await serve(join(ROOT, "dist"), 5210, { spa: true });
const browser = await launch();
const results = [];

try {
  // One page for encoding, reused. Spinning up a browser context per re-encode
  // costs more than the encode does.
  const codec = await browser.newPage();
  await codec.goto("about:blank");

  const archiveDir = join(archiveRoot, `${today}-${commit}${dirty ? "-dirty" : ""}`);
  if (!preview) await mkdir(archiveDir, { recursive: true });

  for (const shot of wanted) {
    for (const variant of VARIANTS) {
      const stem = `${shot.id}-${variant}`;
      const { png, hud } = await shoot(browser, shot, shot[variant]);
      /*
       * The HUD is echoed into the log because it is the only evidence in the
       * run that the body was doing something. "shot drive-101-day" is true of a
       * parked car, a finished route and a correct frame alike; `56 mph ·
       * US-101 | assisted · 41% · chase` is not.
       */
      const readout = hud ? `  [${hud.mode}] ${hud.primary} | ${hud.status}` : "";
      if (preview) {
        await mkdir(join(ROOT, "shots", "preview"), { recursive: true });
        await writeFile(join(ROOT, "shots", "preview", `${stem}.png`), png);
        console.log("preview", stem, readout);
        continue;
      }

      await writeFile(
        join(archiveDir, `${stem}.webp`),
        await encode(codec, png, VIEWPORT.width * 2, ARCHIVE_QUALITY),
      );

      const sizes = {};
      for (const size of SIZES) {
        sizes[size.suffix] = await encode(codec, png, size.width, size.quality);
      }
      results.push({ stem, sizes });
      console.log(
        "shot   ",
        stem.padEnd(22),
        SIZES.map((s) => `${s.suffix}w ${Math.round(sizes[s.suffix].length / 1024)}kB`).join("  "),
        shot.aim ? `  aim ×${(ZOOM_NOTCH ** -(shot.aim.zoom ?? 0)).toFixed(4)} standoff` : "",
        readout,
      );
    }
  }

  if (preview) {
    console.log(`\npreviews in ${join(ROOT, "shots", "preview")}`);
  } else {
    console.log(`\narchive ${archiveDir}`);

    if (!(await haveSite())) {
      noSite();
    } else {
      await mkdir(publicDir, { recursive: true });
      await mkdir(dataDir, { recursive: true });
      for (const { stem, sizes } of results) {
        for (const size of SIZES) {
          await writeFile(join(publicDir, `${stem}-${size.suffix}.webp`), sizes[size.suffix]);
        }
      }
      // Regenerating the manifest from a partial run would drop every shot the
      // run did not take, so `--only` writes images and leaves the manifest be.
      if (only) {
        console.log(`site    ${results.length} frames → ${publicDir}`);
        console.log("        manifest left alone (partial run; re-run without --only to rewrite it)");
      } else {
        await writeFile(join(dataDir, "shots.ts"), manifest());
        console.log(`site    ${results.length} frames → ${publicDir}`);
        console.log(`        manifest → ${join(dataDir, "shots.ts")}`);
      }
    }
  }
} finally {
  await browser.close();
  app.close();
}

// ---- The generated manifest -------------------------------------------------

function manifest() {
  const aspect = VIEWPORT.width / VIEWPORT.height;
  const frame = (id, variant) => {
    const f = SHOTS.find((s) => s.id === id)[variant];
    return `{
      at: ${JSON.stringify(f.at)},
      src: ${JSON.stringify(`/shots/${id}-${variant}-1600.webp`)},
      srcSmall: ${JSON.stringify(`/shots/${id}-${variant}-800.webp`)},
      alt: ${JSON.stringify(f.alt)},
      note: ${JSON.stringify(f.note)},
    }`;
  };
  const entries = SHOTS.map(
    (s) => `  {
    id: ${JSON.stringify(s.id)},
    door: ${JSON.stringify(s.door)},
    place: ${JSON.stringify(s.place)},
    width: ${SIZES[0].width},
    height: ${Math.round(SIZES[0].width / aspect)},
    caption: ${JSON.stringify(s.caption)},
    day: ${frame(s.id, "day")},
    night: ${frame(s.id, "night")},
  },`,
  ).join("\n");

  return `/**
 * Generated. Do not edit — \`scripts/brand-assets/shots.mjs\` in the tera repo
 * rewrites this file wholesale, and the captions below live next to the camera
 * poses that framed them so the two cannot drift apart.
 *
 * To change a picture or its caption: edit \`SHOTS\` in that script, run
 * \`node scripts/brand-assets/shots.mjs\`, and commit both repos. A caption-only
 * change can use \`--manifest-only\` and skip the render entirely.
 *
 * Every image is a screenshot of tera's built \`dist/\` at the commit named
 * below. Nothing here is an illustration.
 */

/**
 * A union rather than \`string\`, so a page asking for a picture that is not in
 * this list fails \`npm run typecheck\` — which the pre-push hook runs — instead
 * of rendering a hole a reader finds later.
 */
export type ShotId =
${SHOTS.map((s) => `  | ${JSON.stringify(s.id)}`).join("\n")};

/** One camera, one clock. Every shot has two of these and differs only in the hour. */
export interface Frame {
  /** The instant the engine's own clock was set to. The sun follows from it. */
  at: string;
  src: string;
  srcSmall: string;
  alt: string;
  /** What this hour in particular is showing, appended to the shot's caption. */
  note: string;
}

export interface Shot {
  id: ShotId;
  /** Which door the engine was showing: the city, or an office interior. */
  door: "tera" | "office";
  /** Where this is, in words a reader would use. */
  place: string;
  width: number;
  height: number;
  /** True of both frames — the place and the mechanism, never the light. */
  caption: string;
  day: Frame;
  night: Frame;
}

/** The tera commit these were rendered from. */
export const SHOTS_COMMIT = ${JSON.stringify(commit)};
/** True if that commit is not the whole story — the tree had uncommitted work. */
export const SHOTS_DIRTY = ${dirty};
/** The day the shutter opened, ISO. */
export const SHOTS_CAPTURED = ${JSON.stringify(today)};

export const SHOTS: Shot[] = [
${entries}
];

/** Total, because \`ShotId\` cannot name a shot that is not in \`SHOTS\`. */
export const byId = (id: ShotId): Shot => SHOTS.find((s) => s.id === id)!;
`;
}
