feat(brand): re-shoot everything at the new engine, and make it one command
**Seven new shots**, because the world grew the most photogenic things in it
after the last pass: `sfo`, `lax`, `golden-gate`, `bay-bridge`, `freeway`,
`california-relief` and `pacific-sea`. All nine existing ids are unchanged — the
manifest emits a `ShotId` union that lumbridge-v4 imports, so ids are added and
never renamed.
**A shot can now aim itself.** The chapter list has no camera for SFO, LAX, the
bridges, the freeway or the open sea, and the engine has no `?pose=` back door,
so a shot points itself by driving the app's own inputs: a click on the plan view
slides the orbit target to a lat/lng while keeping the chapter's stance, wheel
notches set the standoff, and a drag sets azimuth and elevation. The plan view's
pixel-to-coordinate map is solved at runtime from three hovers of
`#minimap-readout` rather than hard-coded, so it survives a board resize or a
restyle of the widget.
**The tera share card was a picture of the wrong thing, and had been.** Its art
came from `keyboard.press("2")`, which had landed on the California board's drive
mode once the default board changed — so the card under the headline "Cities from
above." was a chase camera on US-101, showing metre-scale cars driving between
kilometre-wide buildings, with the DRIVE readout and the mode pill baked into the
art. It renders, it looks deliberate, and it is why an unguarded key press has no
place in a capture script. `capture.mjs` now clicks an indexed chapter and
asserts its `shortLabel` the way `shots.mjs` does, waits on `#boot` and
`#chapters` instead of sleeping twenty seconds, and gives each card its own hour.
**`npm run refresh` is the durable half.** One command: build, stills, cards,
films, both manifests, and a hashed before/after diff of every deliverable. It
fails loudly and specifically on the two conditions that otherwise produce
confident wrong output — the renderer coming up as SwiftShader, and a chapter
`expect` guard firing. `--stills-only` / `--cards-only` / `--films-only` compose,
`--dry-run` lists the plan without opening a browser, and
`shots.mjs --list` prints the whole shot plan — board, chapter, expect, aim, both
hours — which is what to run first when a guard does fire.
It also re-stamps `PROVENANCE.json`, narrowly: only entries whose origin is
`repository-generated` and whose `generator` names a script the run actually
executed, by literal hash substitution rather than re-serialising the file.
Without that, every legitimate card re-shoot leaves `npm run provenance` red.
**Every film re-shot.** They were at `9c9e78f`, captured 2026-08-07, and predated
the tone mapping, the reflective sea, the sky dome, terrain shadows, the rebuilt
California board, SFO, LAX, both bridges and the moving aircraft.
Tests 1137, typecheck, build, eight budget cells and every provenance and licence
check pass.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
+332
-74
@@ -4,6 +4,7 @@
|
||||
* node scripts/brand-assets/films.mjs # every film
|
||||
* node scripts/brand-assets/films.mjs --only fidi-day
|
||||
* node scripts/brand-assets/films.mjs --only fidi-day --frames 24 # a rough cut
|
||||
* node scripts/brand-assets/films.mjs --keep-frames # keep the PNGs
|
||||
* node scripts/brand-assets/films.mjs --manifest-only # captions only
|
||||
* node scripts/brand-assets/films.mjs --publish films/2026-08-06-abc1234
|
||||
*
|
||||
@@ -66,14 +67,36 @@ const DELIVER_HEIGHT = Math.round((DELIVER_WIDTH * VIEWPORT.height) / VIEWPORT.w
|
||||
* The CRFs are measured, not guessed. A time-lapse is close to the worst case
|
||||
* for inter-frame compression: the camera never moves but every pixel changes
|
||||
* every frame as the light does, so the encoder has no static background to
|
||||
* lean on. At CRF 22 the hero came out at 2.5 MB. Behind a scrim that is 38–96%
|
||||
* opaque, CRF 28 is indistinguishable and 987 kB.
|
||||
* lean on. At CRF 22 the hero came out at 2.5 MB; behind a scrim that is 38–96%
|
||||
* opaque, CRF 28 was indistinguishable and 987 kB.
|
||||
*
|
||||
* **The hero CRFs moved in August 2026, and the number that was held constant
|
||||
* was the size rather than the CRF.** The engine grew a specular sea with a
|
||||
* swell normal map and sun glitter, which is high-frequency detail that changes
|
||||
* every frame — the exact thing this encoder has no way to predict — so the same
|
||||
* CRF 28 that bought 987 kB before now buys 1.31 MB of the same six seconds.
|
||||
* The original decision was about a megabyte above the fold, not about the
|
||||
* number 28, so the number moved to keep the megabyte:
|
||||
*
|
||||
* 1440w CRF 28 → 1.31 MB CRF 30 → 1004 kB CRF 31 → 892 kB
|
||||
* 960w CRF 30 → 502 kB CRF 32 → 393 kB
|
||||
*
|
||||
* 30 and 32 are the ones that land back on the sizes the earlier pass measured.
|
||||
* Checked rather than assumed: SSIM against a lossless re-encode of the master
|
||||
* is 0.962 at CRF 28 and 0.952 at CRF 30, and the two were compared as pixels at
|
||||
* 1:1 on frame 161 — the night frame, whose fine window-light speckle on dark
|
||||
* ground is where blocking would show first — and are not tellable apart. Under
|
||||
* the scrim there is nothing left to tell apart.
|
||||
*
|
||||
* The figures are untouched at CRF 22. They sit inline at 1280 with nothing over
|
||||
* them, they are looked at deliberately rather than glanced past, and they do not
|
||||
* download until they are on screen.
|
||||
*/
|
||||
const DELIVERABLES = {
|
||||
figure: [{ suffix: "", width: DELIVER_WIDTH, crf: 22 }],
|
||||
hero: [
|
||||
{ suffix: "", width: 1440, crf: 28 },
|
||||
{ suffix: "-sm", width: 960, crf: 30 },
|
||||
{ suffix: "", width: 1440, crf: 30 },
|
||||
{ suffix: "-sm", width: 960, crf: 32 },
|
||||
],
|
||||
};
|
||||
const deliverablesFor = (spec) => (spec.chrome === "bare" ? DELIVERABLES.hero : DELIVERABLES.figure);
|
||||
@@ -102,6 +125,36 @@ const deliverSize = (spec) => {
|
||||
* other; for `office-dusk` it means a window narrow enough that the lights
|
||||
* coming on lasts half a second instead of one frame. `hero-soma` is the
|
||||
* exception, and argues for itself on its own entry.
|
||||
*
|
||||
* ### Why there are two posters and only one reel
|
||||
*
|
||||
* The stills are shot twice, day and night, because a still is one instant and
|
||||
* the site serves the instant that matches the reader's theme. The obvious way
|
||||
* to carry that over here is a day reel and a night reel per film, and it is the
|
||||
* wrong answer twice over: it doubles the render, and it ships two crops of the
|
||||
* same day to a reader who is allowed to see only one of them — for a film whose
|
||||
* entire subject is that the light changes.
|
||||
*
|
||||
* Every reel below already spans both. `fidi-day` and `bay-relief-day` open at
|
||||
* 04:40 in the dark and close at 22:40 in the dark; `office-dusk` starts in
|
||||
* daylight and ends with the building lit from inside; `hero-soma` is a whole
|
||||
* day by construction. Nothing about the moving picture is wrong on a dark page.
|
||||
*
|
||||
* What *is* wrong on a dark page is the frame that sits there before anybody
|
||||
* presses anything. `Film` in `project-layout.tsx` ships `preload="none"`, so the
|
||||
* poster is what most readers see and the only thing a reduced-motion reader ever
|
||||
* sees — and `bay-relief-day`'s poster is ten to two in the afternoon, which on a
|
||||
* black page is a lit rectangle.
|
||||
*
|
||||
* So: **one reel, two posters, both cut from that same reel.** `poster` is the
|
||||
* daylight frame and `posterNight` the night one, each a fraction along the same
|
||||
* render. It costs no extra filming at all — a poster is one PNG pulled out
|
||||
* during encode — and it puts the figure at rest in the reader's own light, which
|
||||
* is the thing the stills pipeline gets right and this one did not.
|
||||
*
|
||||
* Choosing `posterNight` by arithmetic and hoping is how you find out the frame
|
||||
* was under a cloud. Shoot with `--keep-frames`, look at the PNGs, and move the
|
||||
* fraction; see the flag's own note.
|
||||
*/
|
||||
const FILMS = [
|
||||
{
|
||||
@@ -115,8 +168,16 @@ const FILMS = [
|
||||
frames: 180,
|
||||
fps: 30,
|
||||
title: "Eighteen hours over the Financial District",
|
||||
/** Which frame becomes the poster — the one worth stopping on. */
|
||||
/**
|
||||
* Which frame becomes the poster — the one worth stopping on, twice.
|
||||
*
|
||||
* 0.86 is eight past eight in the evening, a few minutes before sunset:
|
||||
* the last of the low sun down the length of Montgomery. 0.935 is half
|
||||
* past nine, the hour the shot list uses for night, with the sun twelve
|
||||
* degrees under and the tower windows carrying the frame.
|
||||
*/
|
||||
poster: 0.86,
|
||||
posterNight: 0.935,
|
||||
place: "Financial District, San Francisco",
|
||||
caption:
|
||||
"One camera, eighteen hours, six seconds. Nothing here is keyframed: every frame is the engine asked for a different instant, and the light, the shadows, the sky, the map in the corner and the window lights all follow from that one number. The readout at the top of the panel is the film captioning itself — the hour, how high the sun is, which band of twilight that puts it in, and how much of the moon is lit.",
|
||||
@@ -131,10 +192,38 @@ const FILMS = [
|
||||
* column of interface underneath that headline is not atmosphere, it is
|
||||
* two interfaces arguing.
|
||||
*
|
||||
* The camera is SoMa rather than the whole board, which was tried first: at
|
||||
* that standoff the edge of the terrain plate is visible against the sky
|
||||
* across the top of the frame, and midday is a lot of pale sand. Close in,
|
||||
* the city fills the frame to every edge at every hour.
|
||||
* The camera is SoMa rather than the whole board. **The original reason for
|
||||
* that is dead and the conclusion survived it**, which is worth writing down
|
||||
* so the question is not reopened a third time on the old evidence.
|
||||
*
|
||||
* Whole board was rejected in August 2026 because at that standoff the edge
|
||||
* of the terrain plate showed against the sky across the top of the frame.
|
||||
* That seam no longer exists: the sea now runs eighteen board spans and ends
|
||||
* past the fog far plane, and the sky is a world-space dome rather than a
|
||||
* screen-space gradient. So the camera was shot again — `--frames 24`,
|
||||
* midnight to midnight, against `Whole Board`, `The Bay` and `Marin` — and
|
||||
* all three lost, for reasons that have nothing to do with the old one:
|
||||
*
|
||||
* - **Whole Board** is now a weather picture, not a city one. The camera sits
|
||||
* above the cloud deck and `atmosphere.ts` models the marine layer from its
|
||||
* own diurnal curve, so most of the day is white. It is also, precisely,
|
||||
* `bay-relief-day`'s camera — a hero that duplicates a figure two screens
|
||||
* further down is a wasted first impression.
|
||||
* - **The Bay** (chapter 11, three crossings ahead and two behind) is the
|
||||
* handsomest of the four and still wrong for a hero: the sea is specular
|
||||
* now, so the sun's glitter path lands as a blown white bloom across the
|
||||
* middle third of the frame for most of the afternoon — which is exactly
|
||||
* where the headline goes, and exactly what `npm run check:hero` measures.
|
||||
* Both bridges are hairlines at 280 units of standoff. Keep it in mind as a
|
||||
* *figure*, where nothing sits on top of it.
|
||||
* - **Marin** is outside the pack's single `focusRegions` rectangle, so the
|
||||
* terrain there is on the 450 m coarse lattice: a flat green blob for
|
||||
* Tamalpais and a staircase for the coast.
|
||||
*
|
||||
* Close in, SoMa still fills the frame to every edge at every hour, and it
|
||||
* gained the subject it used to lack — the Bay Bridge is a real suspension
|
||||
* bridge in the right third of the frame now, with towers and cables, and
|
||||
* the sea under it reflects.
|
||||
*
|
||||
* It is also the only one that runs **midnight to midnight**. The others
|
||||
* crop to the part of the day that moves; this one cannot, because the page
|
||||
@@ -155,8 +244,13 @@ const FILMS = [
|
||||
fps: 30,
|
||||
viewport: HERO_VIEWPORT,
|
||||
chrome: "bare",
|
||||
/** Whole-day, so this is the only film where the poster hour is a choice about light. */
|
||||
/**
|
||||
* Whole-day, so this is the only film where the poster hours are a free
|
||||
* choice rather than a crop of one. 0.33 is ten to eight in the morning —
|
||||
* the relief hour — and 0.90 is half past nine at night.
|
||||
*/
|
||||
poster: 0.33,
|
||||
posterNight: 0.901,
|
||||
title: "San Francisco, one whole day",
|
||||
place: "SoMa, San Francisco",
|
||||
caption:
|
||||
@@ -204,9 +298,33 @@ const FILMS = [
|
||||
* The poster moved off 0.20 for a related reason: 0.20 is twenty past eight
|
||||
* in the morning, which is now a white rectangle. 0.51 is ten to two, the
|
||||
* clearest the board gets.
|
||||
*
|
||||
* The night poster is the one place on this film where the cloud model has
|
||||
* to be argued with rather than admired, and it is the reason
|
||||
* `--keep-frames` exists at all.
|
||||
*
|
||||
* The arithmetic answer was 0.894 — a quarter to nine, after sunset and
|
||||
* before the layer was expected to close. Looking at the kept PNGs said
|
||||
* otherwise: by 20:44 the deck is already back over the board and the frame
|
||||
* is a featureless purple wash, which is the old 0.20 failure with the
|
||||
* colours inverted. Frames 148 through 172 were compared as pictures, and
|
||||
* **every post-sunset frame on this camera is under cloud** — that is not a
|
||||
* bad hour to have picked, it is what this film is about, and the caption
|
||||
* already says the deck closes in again after sunset.
|
||||
*
|
||||
* So the night poster is chosen for what survives the cloud rather than for
|
||||
* a clear sky that is not on offer. 0.96 is 21:57, sun 18.6° under: dark
|
||||
* enough to belong on a black page, and late enough that the built ground
|
||||
* reads through the gaps as speckled clusters — San Jose, the East Bay, the
|
||||
* peninsula — which is the last line of the caption arriving as a picture.
|
||||
* Five aircraft and their tracks are in it too.
|
||||
*
|
||||
* If the cloud model is ever retuned, re-shoot with `--keep-frames` and look
|
||||
* again rather than trusting this number.
|
||||
*/
|
||||
title: "A day across the whole board",
|
||||
poster: 0.51,
|
||||
posterNight: 0.96,
|
||||
place: "San Francisco Bay Area",
|
||||
caption:
|
||||
"Relief is easiest to read when the light moves, and this camera is high enough to read the weather the same way. With nobody to ask for an observation — which is what a fresh clone of this engine has, and what this camera had — the sky is modelled from the Pacific marine layer's own daily curve instead of left empty: the deck that covers the board before dawn burns back through the afternoon and closes in again after sunset. Under it, shadow runs the length of two mountain ranges and back — and at the end the thing the terrain never told you comes through the gaps, which is the cities, which are somewhere else entirely.",
|
||||
@@ -216,60 +334,93 @@ const FILMS = [
|
||||
/**
|
||||
* The one film the stills cannot stand in for.
|
||||
*
|
||||
* `office-floor` in `shots.mjs` is this exact camera at two hours — 11:20 and
|
||||
* 21:30 — and that pair is a fair account of both of them. What a pair cannot
|
||||
* show is the part in between, which is the part that is actually the claim:
|
||||
* the building has no night theme, it has a sun going down, and the fittings
|
||||
* come up as it does. An event with a duration wants the medium that has one.
|
||||
* **This entry was rewritten from scratch in August 2026, because the
|
||||
* building it described was demolished.** It used to be forty-eight metres
|
||||
* by eighteen, two storeys with fourteen metres of interstitial between
|
||||
* them, four Optimus units walking two floor plates — and the camera was
|
||||
* `chapter: 0`, `expect: "The Floor"`. `The Floor` does not exist any more.
|
||||
* Chapter 0 is `Front Door` now, and the `expect` guard is what stopped this
|
||||
* script rather than letting it photograph an arrival pose and caption it
|
||||
* with an open-plan office that is gone. That is the entire reason the guard
|
||||
* is there; do not weaken it.
|
||||
*
|
||||
* What stands at `lumbridge-hq` today is a twelve by nine metre live/work
|
||||
* studio on one level: a two-place agent bench, a demo lounge with a real
|
||||
* sofa facing a shared wall, a kitchen island with stools, a sleeping alcove
|
||||
* and a bath. The id is unchanged on purpose — journey snapshots, presence,
|
||||
* media grants and self-hosted deployments all address it — so the film id
|
||||
* stays `office-dusk` too, and nothing on the site has to be renamed.
|
||||
*
|
||||
* `office-floor` in `shots.mjs` is this exact camera at two hours — 11:20
|
||||
* and 21:30 — and that pair is a fair account of both of them. What a pair
|
||||
* cannot show is the part in between, which is the part that is actually the
|
||||
* claim: the building has no night theme, it has a sun going down, and the
|
||||
* fittings come up as it does. An event with a duration wants the medium
|
||||
* that has one.
|
||||
*
|
||||
* **The window is cut to the switch-over, not to the working day.**
|
||||
* `luminaires.ts` ramps the house lights from nothing at six degrees of solar
|
||||
* elevation to full at zero, and over this building's own coordinate on this
|
||||
* date that band is 19:38 to 20:12 — thirty-four minutes. Spread across the
|
||||
* eighteen-hour window the city films use it would be six frames and a
|
||||
* blink. Across these six hours it is seventeen, a little over half a second,
|
||||
* with another thirty-nine frames of twilight behind it in which the fittings
|
||||
* hold the building on their own. Dawn does the same thing in reverse and is
|
||||
* deliberately out of frame: one legible switch-over beats two unreadable
|
||||
* ones, and a sixteen-hour window buys the second one by making both a blur.
|
||||
* `luminaires.ts` ramps the house lights from nothing at six degrees of
|
||||
* solar elevation to full at zero, and over this building's own coordinate
|
||||
* — Transbay, 37.79N, which the rebuild did not move — on this date that
|
||||
* band is 19:38 to 20:12: thirty-four minutes. Spread across the eighteen-
|
||||
* hour window the city films use it would be six frames and a blink. Across
|
||||
* these six hours it is seventeen, a little over half a second, with another
|
||||
* thirty-nine frames of twilight behind it in which the fittings hold the
|
||||
* room on their own. Dawn does the same thing in reverse and is deliberately
|
||||
* out of frame: one legible switch-over beats two unreadable ones.
|
||||
*
|
||||
* The other end is chosen too. Solar noon here is 13:15 with the sun at 69°,
|
||||
* which is straight down into an open-topped model and flat on every surface
|
||||
* — the interior version of the midday problem the shot list keeps away from.
|
||||
* By half past three the sun is at 54° and falling, and the shadows on the
|
||||
* two plates have somewhere to go for the whole reel.
|
||||
* — the interior version of the midday problem the shot list keeps away
|
||||
* from. By half past three the sun is at 54° and falling, and the shadows on
|
||||
* the floor have somewhere to go for the whole reel.
|
||||
*
|
||||
* The last frame is 21:30, which is `office-floor`'s night frame exactly. So
|
||||
* the film ends on the still, and anyone who suspects one of them is lying
|
||||
* can put them side by side.
|
||||
*
|
||||
* `chapter: 0` is the arrival pose and needs no click. It is also the only
|
||||
* camera that holds all forty-eight metres of the plate and both storeys at
|
||||
* once, which is what makes this read as a *building* changing state rather
|
||||
* than as one room dimming. The Commons was the other candidate: it shows the
|
||||
* void far better and the ceiling far worse, and the ceiling is the subject.
|
||||
* `chapter: 1` — `The Whole Studio` — is the only camera that holds all
|
||||
* twelve metres and every zone at once, which is what makes this read as a
|
||||
* *building* changing state rather than as one lamp coming on. The Front
|
||||
* Door is the arrival pose and needs no click, which is why it was tempting;
|
||||
* it is also framed at 11.8 units on the threshold and cuts the kitchen off.
|
||||
*
|
||||
* Lumbridge HQ rather than Frontier Valley, and the hangar is the better
|
||||
* second film than first — it is one storey and two robots against two
|
||||
* storeys and four, its lights are a single run of trusses, and reaching it
|
||||
* needs the office-picker step this script does not have. See the note above
|
||||
* Lumbridge HQ rather than Frontier Valley or the LA courtyard, and the
|
||||
* hangar is still the better second film than first — reaching either needs
|
||||
* the office-picker press this script does not have. See the note above
|
||||
* `FILMS`.
|
||||
*/
|
||||
id: "office-dusk",
|
||||
door: "office",
|
||||
chapter: 0,
|
||||
expect: "The Floor",
|
||||
chapter: 1,
|
||||
expect: "Studio",
|
||||
from: "2026-08-06T15:30:00-07:00",
|
||||
to: "2026-08-06T21:30:00-07:00",
|
||||
frames: 180,
|
||||
fps: 30,
|
||||
/** Frame 147, twenty-five past eight: fittings at full, sky not yet black. */
|
||||
poster: 0.82,
|
||||
title: "Six hours in the office, and the lights taking over",
|
||||
place: "Lumbridge HQ",
|
||||
/**
|
||||
* Both picked off the kept PNGs rather than off the clock, and one of them
|
||||
* moved because of it.
|
||||
*
|
||||
* 0.25 — five past five, sun at 34° — was the arithmetic answer and it is
|
||||
* the flat one: the light is nearly down the camera's own axis, the walls
|
||||
* are one value and the floor has no shadow on it. That is the midday
|
||||
* problem in miniature, which is the thing the 15:30 start was chosen to
|
||||
* avoid, arriving anyway because the poster was chosen separately from the
|
||||
* window. 0.53 is twenty to seven with the sun at 13°, warm and raking the
|
||||
* length of the floor, and the panel still reads `day` beside it.
|
||||
*
|
||||
* 0.90 is five to nine: sun 12° under, the sky behind the glass gone deep
|
||||
* blue, and the room lit entirely by its own fittings. It is the same claim
|
||||
* as the title, at rest.
|
||||
*/
|
||||
poster: 0.53,
|
||||
posterNight: 0.9,
|
||||
title: "Six hours in the studio, and the lights taking over",
|
||||
place: "SF HQ · Studio, Transbay",
|
||||
caption:
|
||||
"Forty-eight metres by eighteen, two storeys with fourteen metres of interstitial between them, and a sun that belongs to the building rather than to the picture: the pack declares where on the earth it stands, so this floor is lit by the real afternoon over Transbay and switches to its own fittings as that afternoon ends. The changeover is a ramp across six degrees of solar elevation rather than a theme, which is why it takes a film to show it at all. The four Optimus units walking the two floors brighten whatever fitting they pass under.",
|
||||
alt: "A time-lapse of an office interior seen from outside as an open-topped model, both storeys visible at once and held a long way apart. Afternoon daylight rakes across the two floor plates and the sky behind the building warms to orange and then goes dark blue; the ceiling fittings on both levels come up as it does, until the building is lit entirely from within. Small figures sit at the desks and walk between them.",
|
||||
"Twelve metres by nine, one level, and a sun that belongs to the building rather than to the picture: the pack declares where on the earth it stands, so this floor is lit by the real afternoon over Transbay and switches to its own fittings as that afternoon ends. The changeover is a ramp across six degrees of solar elevation rather than a theme, which is why it takes a film to show it at all. The panel beside it is reading the same clock, and the studio hardware under it says in as many words that its levels are simulated.",
|
||||
alt: "A time-lapse of a small live/work studio seen from outside as an open-topped model: a two-place desk with monitors and task chairs, a glass-walled lounge with a sofa, a kitchen island, a bookshelf and a bed in an alcove, all on one floor. Afternoon daylight rakes across the floor and the sky behind the building warms to orange and then goes deep blue; the ceiling fittings come up as it does, until the room is lit entirely from within. Two humanoid robots move about the floor and a dog crosses it.",
|
||||
},
|
||||
];
|
||||
|
||||
@@ -293,6 +444,30 @@ const only = flag("only")?.split(",").map((s) => s.trim());
|
||||
* something to publish by accident.
|
||||
*/
|
||||
const frameOverride = flag("frames") ? Number(flag("frames")) : null;
|
||||
/**
|
||||
* Leave the PNG frames on disk instead of deleting them after the encode.
|
||||
*
|
||||
* This exists because of one specific, expensive asymmetry. Everything about a
|
||||
* film can be changed after the fact for the price of an ffmpeg run — the CRF,
|
||||
* the delivered width, the caption, which files ship — because the master is a
|
||||
* near-lossless archive and the deliverables come off it. **The poster fractions
|
||||
* could not**, because the poster is cut from the PNGs and the PNGs were gone
|
||||
* the moment `encode()` returned. Moving a poster cost a re-shoot.
|
||||
*
|
||||
* That was survivable with one poster and a fraction picked off a rough cut. It
|
||||
* stopped being survivable with two, because a night poster cannot be chosen by
|
||||
* arithmetic: `bay-relief-day` at half past nine is under a closed marine layer
|
||||
* and looks like nothing, and you cannot know that from the clock. So: shoot
|
||||
* once with `--keep-frames`, look at the candidate frames, and if a fraction is
|
||||
* wrong, change it and re-run that one film.
|
||||
*
|
||||
* The frames are not small — measured on this pass, 180 PNGs of the FiDi camera
|
||||
* at 1440x900 came to 122 MB, and the hero at 1600x900 rather more — which is
|
||||
* why this is a flag and not the default. They live in `films/.frames/<id>/`,
|
||||
* and the next run of the same film deletes them whether or not it was asked to
|
||||
* keep its own, so a full set never accumulates twice.
|
||||
*/
|
||||
const keepFrames = process.argv.includes("--keep-frames");
|
||||
const outRoot = resolve(flag("out", join(ROOT, "films")));
|
||||
const siteDir = resolve(flag("site", join(ROOT, "..", "lumbridge-v4")));
|
||||
/** Rewrite the site's manifest from the film list without filming anything. */
|
||||
@@ -416,6 +591,24 @@ async function film(browser, spec, dir) {
|
||||
// itself, and the only thing on screen proving the light follows a real
|
||||
// time rather than a hand-keyed fade. A `bare` film is going behind
|
||||
// somebody else's headline and takes all of it off.
|
||||
//
|
||||
// Both lists come from `harness.mjs` rather than being extended here, and
|
||||
// that is worth one line of defence because this pass nearly did extend
|
||||
// them. `#mode-dock` — the View / Drive / Explore / Fly / Walk pill along
|
||||
// the bottom of the canvas — arrived after those lists were written, and it
|
||||
// was sitting in the bottom centre of the first hero rough cut of this pass:
|
||||
// a picture that is meant to carry nothing but a headline, with a control
|
||||
// pill in it, rendering perfectly and looking entirely deliberate. That is
|
||||
// the hazard `FURNITURE`'s own comment names, and the answer to it is the
|
||||
// one shared list, not a private patch in each script that photographs the
|
||||
// app. It is in `FURNITURE.BARE` now, along with `#play-hud`.
|
||||
//
|
||||
// It is *not* in `CLUTTER`, so the figures keep it, and that is deliberate
|
||||
// rather than an omission: the figures keep the panel because the dock and
|
||||
// the panel are both true and readable instruments — the dock says this
|
||||
// board can be driven, flown and walked, which is a claim the page makes in
|
||||
// words a couple of paragraphs away. `CLUTTER` drops what is untrue outside
|
||||
// the capture or unreadable inside it, and the dock is neither.
|
||||
await hide(page, [
|
||||
...FURNITURE.transient,
|
||||
...(spec.chrome === "bare" ? FURNITURE.BARE : FURNITURE.CLUTTER),
|
||||
@@ -459,14 +652,18 @@ async function film(browser, spec, dir) {
|
||||
* a good deal of Android will not decode the file at all, and without the
|
||||
* second the index sits at the end and playback waits for the whole download.
|
||||
*
|
||||
* The poster comes off the PNG, not off the master, because it is a still and
|
||||
* has no reason to inherit a video codec's compromises. The price of that is
|
||||
* worth knowing before you go looking for a flag that does not exist: the frames
|
||||
* are deleted the moment this returns, so **moving a `poster` fraction costs a
|
||||
* re-shoot**, where moving a CRF costs ten seconds. Everything else about a film
|
||||
* can be changed for free; that one cannot.
|
||||
* The posters come off the PNGs, not off the master, because they are stills and
|
||||
* have no reason to inherit a video codec's compromises. There are two of them —
|
||||
* a daylight frame and a night one, cut from the same reel, so that the figure at
|
||||
* rest is in the reader's own light; the note above `FILMS` argues for that over
|
||||
* a second reel.
|
||||
*
|
||||
* The price of cutting them from the PNGs used to be that the frames were deleted
|
||||
* the moment this returned, so **moving a poster fraction cost a re-shoot** where
|
||||
* moving a CRF costs ten seconds. `--keep-frames` is the answer to that; see its
|
||||
* note. Everything else about a film can still be changed for free.
|
||||
*/
|
||||
function encode(dir, out, fps, frames, posterAt, deliverables) {
|
||||
function encode(dir, out, fps, frames, posters, deliverables) {
|
||||
const ff = (...args) => execFileSync("ffmpeg", ["-y", "-loglevel", "error", ...args]);
|
||||
const master = `${out}-master.mp4`;
|
||||
ff(
|
||||
@@ -490,16 +687,36 @@ function encode(dir, out, fps, frames, posterAt, deliverables) {
|
||||
`${out}${d.suffix}.mp4`,
|
||||
);
|
||||
}
|
||||
const posterFrame = Math.min(frames - 1, Math.round((frames - 1) * posterAt));
|
||||
ff(
|
||||
"-i", join(dir, `f-${String(posterFrame).padStart(4, "0")}.png`),
|
||||
"-vf", `scale=${deliverables[0].width}:-2:flags=lanczos`,
|
||||
"-quality", "82",
|
||||
`${out}-poster.webp`,
|
||||
);
|
||||
return posterFrame;
|
||||
const cut = (at, suffix) => {
|
||||
const frame = Math.min(frames - 1, Math.max(0, Math.round((frames - 1) * at)));
|
||||
ff(
|
||||
"-i", join(dir, `f-${String(frame).padStart(4, "0")}.png`),
|
||||
"-vf", `scale=${deliverables[0].width}:-2:flags=lanczos`,
|
||||
"-quality", "82",
|
||||
`${out}${suffix}.webp`,
|
||||
);
|
||||
return frame;
|
||||
};
|
||||
return {
|
||||
day: cut(posters.day, "-poster"),
|
||||
night: cut(posters.night, "-poster-night"),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Every file a film ships, in one place.
|
||||
*
|
||||
* `publish()` and the end-of-run copy both walk this, so a new deliverable —
|
||||
* the night poster was one — cannot be added to the encoder and forgotten in
|
||||
* the copy, which would leave the manifest naming a `<img>` that 404s while
|
||||
* every other check passed.
|
||||
*/
|
||||
const shippedNames = (spec) => [
|
||||
...deliverablesFor(spec).map((d) => `${spec.id}${d.suffix}.mp4`),
|
||||
`${spec.id}-poster.webp`,
|
||||
`${spec.id}-poster-night.webp`,
|
||||
];
|
||||
|
||||
// ---- Run --------------------------------------------------------------------
|
||||
|
||||
const exists = (p) => access(p).then(() => true, () => false);
|
||||
@@ -528,23 +745,40 @@ async function publish(fromDir) {
|
||||
await mkdir(publicDir, { recursive: true });
|
||||
let copied = 0;
|
||||
const kept = [];
|
||||
/**
|
||||
* Every file, not just the first one.
|
||||
*
|
||||
* This used to test `names[0]` — the main mp4 — and take its presence as proof
|
||||
* the whole set was there. That was true for exactly as long as the set never
|
||||
* changed. The moment the night poster was added, an older render directory or
|
||||
* an already-published film from before the change satisfied `names[0]`
|
||||
* perfectly and was waved through **missing a file the manifest was about to
|
||||
* name**, which is a 404 behind an `<img>` that nothing else in this pipeline
|
||||
* would have caught. A render that predates a deliverable has to be re-shot,
|
||||
* and the way to find that out is here rather than in a browser.
|
||||
*/
|
||||
const complete = async (dir, names) => {
|
||||
for (const name of names) if (!(await exists(join(dir, name)))) return false;
|
||||
return true;
|
||||
};
|
||||
for (const spec of FILMS) {
|
||||
const names = [
|
||||
...deliverablesFor(spec).map((d) => `${spec.id}${d.suffix}.mp4`),
|
||||
`${spec.id}-poster.webp`,
|
||||
];
|
||||
if (await exists(join(fromDir, names[0]))) {
|
||||
const names = shippedNames(spec);
|
||||
if (await complete(fromDir, names)) {
|
||||
for (const name of names) await copyFile(join(fromDir, name), join(publicDir, name));
|
||||
copied += 1;
|
||||
continue;
|
||||
}
|
||||
if (await exists(join(publicDir, names[0]))) {
|
||||
if (await complete(publicDir, names)) {
|
||||
kept.push(spec.id);
|
||||
continue;
|
||||
}
|
||||
const partial = (await exists(join(fromDir, names[0]))) || (await exists(join(publicDir, names[0])));
|
||||
throw new Error(
|
||||
`${spec.id} is neither in ${fromDir} nor already published — film it before the ` +
|
||||
`manifest names it`,
|
||||
partial
|
||||
? `${spec.id} is present but incomplete — it is missing at least one of ` +
|
||||
`${names.join(", ")}, so it predates a deliverable this script now ships. Re-film it.`
|
||||
: `${spec.id} is neither in ${fromDir} nor already published — film it before the ` +
|
||||
`manifest names it`,
|
||||
);
|
||||
}
|
||||
console.log(`site ${copied} film(s) copied → ${publicDir}`);
|
||||
@@ -579,13 +813,22 @@ try {
|
||||
|
||||
const shot = await film(browser, spec, framesDir);
|
||||
const out = join(outDir, spec.id);
|
||||
const posterFrame = encode(framesDir, out, spec.fps, shot, spec.poster, deliverablesFor(spec));
|
||||
await rm(framesDir, { recursive: true, force: true });
|
||||
const posterFrames = encode(
|
||||
framesDir,
|
||||
out,
|
||||
spec.fps,
|
||||
shot,
|
||||
{ day: spec.poster, night: spec.posterNight },
|
||||
deliverablesFor(spec),
|
||||
);
|
||||
if (!keepFrames) await rm(framesDir, { recursive: true, force: true });
|
||||
made.push({ spec, out });
|
||||
|
||||
const seconds = (shot / spec.fps).toFixed(1);
|
||||
console.log(` ${shot} frames → ${seconds}s at ${spec.fps}fps, poster from frame ${posterFrame}`);
|
||||
console.log(` ${shot} frames → ${seconds}s at ${spec.fps}fps`);
|
||||
console.log(` posters from frames ${posterFrames.day} (day) and ${posterFrames.night} (night)`);
|
||||
console.log(` ${out}.mp4`);
|
||||
if (keepFrames) console.log(` frames kept → ${framesDir}`);
|
||||
}
|
||||
|
||||
await writeFile(
|
||||
@@ -603,11 +846,10 @@ try {
|
||||
console.log(`site skipped — this was a ${frameOverride}-frame rough cut`);
|
||||
} else {
|
||||
await mkdir(publicDir, { recursive: true });
|
||||
for (const { spec, out } of made) {
|
||||
for (const d of deliverablesFor(spec)) {
|
||||
await copyFile(`${out}${d.suffix}.mp4`, join(publicDir, `${spec.id}${d.suffix}.mp4`));
|
||||
for (const { spec } of made) {
|
||||
for (const name of shippedNames(spec)) {
|
||||
await copyFile(join(outDir, name), join(publicDir, name));
|
||||
}
|
||||
await copyFile(`${out}-poster.webp`, join(publicDir, `${spec.id}-poster.webp`));
|
||||
}
|
||||
console.log(`site ${made.length} film(s) → ${publicDir}`);
|
||||
// As in `shots.mjs`: a partial run must not rewrite a manifest that would
|
||||
@@ -637,6 +879,7 @@ function manifest() {
|
||||
: ""
|
||||
}
|
||||
poster: ${JSON.stringify(`/films/${f.id}-poster.webp`)},
|
||||
posterNight: ${JSON.stringify(`/films/${f.id}-poster-night.webp`)},
|
||||
width: ${deliverSize(f).w},
|
||||
height: ${deliverSize(f).h},
|
||||
seconds: ${Number((f.frames / f.fps).toFixed(2))},
|
||||
@@ -673,7 +916,22 @@ export interface Film {
|
||||
src: string;
|
||||
/** A narrower encode for small screens, where one exists. Same film, fewer bytes. */
|
||||
srcSmall?: string;
|
||||
/**
|
||||
* The daylight poster, and the night one cut from the same reel.
|
||||
*
|
||||
* Pick by the reader's resolved theme, the way \`shots.ts\` consumers already
|
||||
* do — \`useResolvedTheme()\` in \`apps/web/src/theme.ts\`, never a
|
||||
* \`prefers-color-scheme\` media query, which sees the OS and is blind to the
|
||||
* site's own toggle.
|
||||
*
|
||||
* This matters more than a poster usually would. \`Film\` ships
|
||||
* \`preload="none"\`, so the poster is what most readers see before they press
|
||||
* anything, and it is the *only* thing a reduced-motion reader ever sees. The
|
||||
* reel itself is single-variant on purpose: every film here spans the whole
|
||||
* change of light, so the moving picture is right on either page.
|
||||
*/
|
||||
poster: string;
|
||||
posterNight: string;
|
||||
width: number;
|
||||
height: number;
|
||||
/** Running time. Used to decide whether a pause control is required; it is. */
|
||||
|
||||
Reference in New Issue
Block a user