/** * Film the engine running, by stepping its clock rather than by recording it. * * 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 --manifest-only # captions only * node scripts/brand-assets/films.mjs --publish films/2026-08-06-abc1234 * * `shots.mjs` next door takes the stills. This takes the moving pictures, and * the two share `harness.mjs` for the same reason they always did. * * ### Why this is not Remotion, or Motion Canvas, or HyperFrames * * All three of those compose an animation *out of code* — React components, * canvas nodes, HTML and GSAP — and they are good at it. None of them can help * here, because the animation already exists: it is a real-time 3D engine with * a sun computed from a clock, and what is missing is not a way to author * motion but a way to *record* motion that is already happening. That is a * frame-stepper and an encoder, which is this file and ffmpeg. * * The distinction is worth holding on to, because the day a title card or a * cross-fade between two of these films is wanted, one of those tools becomes * exactly right — as an editor, downstream of footage this produced. Reaching * for one now would mean reimplementing the city in React. * * ### Why the clock is stepped and not simply left running * * Real-time capture is not available on this box and would be the wrong idea * anyway. A sunset takes an hour, which is not a length of video anybody * watches, and the capture loop screenshots far slower than it renders. So the film is rendered offline the * way films always have been: set the clock, let the frame settle, expose, * advance. The output is smooth 30 fps regardless of what the renderer managed * while it was being photographed. * * Two shims in `filmClock()` make that possible from outside the app, without * a reload per frame — a reload costs fifteen seconds and would put a 180-frame * film at three quarters of an hour. */ import { fileURLToPath } from "node:url"; import { dirname, join, resolve } from "node:path"; import { mkdir, writeFile, rm, access, copyFile } from "node:fs/promises"; import { execFileSync } from "node:child_process"; import { serve, launch, FURNITURE, hide } from "./harness.mjs"; const HERE = dirname(fileURLToPath(import.meta.url)); const ROOT = join(HERE, "..", ".."); // ---- The films -------------------------------------------------------------- const VIEWPORT = { width: 1440, height: 900 }; /** A hero is a band, not a window, so it is framed wider than the figures. */ const HERO_VIEWPORT = { width: 1600, height: 900 }; /** Delivered at 1280 wide. The frames are shot at 1440 and scaled once, by ffmpeg. */ const DELIVER_WIDTH = 1280; const DELIVER_HEIGHT = Math.round((DELIVER_WIDTH * VIEWPORT.height) / VIEWPORT.width); /** * What each film ships as. * * A figure is one file at 1280. The hero ships **two**, because it autoplays * above the fold and a phone at 390 CSS px has no use for a 1440-wide encode — * it is the same picture at two and a half times the bytes, on the connection * least able to spare them. * * 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. */ const DELIVERABLES = { figure: [{ suffix: "", width: DELIVER_WIDTH, crf: 22 }], hero: [ { suffix: "", width: 1440, crf: 28 }, { suffix: "-sm", width: 960, crf: 30 }, ], }; const deliverablesFor = (spec) => (spec.chrome === "bare" ? DELIVERABLES.hero : DELIVERABLES.figure); const deliverSize = (spec) => { const vp = spec.viewport ?? VIEWPORT; const w = deliverablesFor(spec)[0].width; return { w, h: Math.round((w * vp.height) / vp.width) }; }; /** * `door`, `chapter` and `expect` work exactly as they do in `shots.mjs`: which * building the engine is showing, an index into the chapter list, and the * `shortLabel` that index is asserted to be before the shutter opens. * * What is deliberately *not* carried over is `shots.mjs`'s `office` field and * the picker press behind it. Every film below is of the building its door opens * on, so that press would be a capture path no film exercises — and an untried * step in a pipeline is worse than an absent one, because it looks like coverage. * A Frontier Valley film means porting those fifteen lines along with it; the * office entry at the bottom says why the hangar is the second office film to * shoot rather than the first. * * `from` and `to` are the ends of the stretch of day the camera watches, and * every film crops to the part of it that moves. For the city films that means * dropping the small hours, which are dark frames indistinguishable from each * 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. */ const FILMS = [ { id: "fidi-day", door: "tera", city: "sf", chapter: 3, expect: "FiDi", from: "2026-08-06T04:40:00-07:00", to: "2026-08-06T22:40:00-07:00", frames: 180, fps: 30, title: "Eighteen hours over the Financial District", /** Which frame becomes the poster — the one worth stopping on. */ poster: 0.86, 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.", alt: "A time-lapse of the San Francisco financial district seen from above. Shadow sweeps across the towers as the sun crosses the sky, the water changes colour, and after sunset the tower windows light up one by one.", }, { /** * The hero on lumbridgecorp.com, and the only film shot `bare`. * * The figures keep the app's panel because the clock in it is the caption. * A hero has its own headline sitting on top of the picture, and a second * 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. * * 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 * seeks it to the reader's own local hour and a film that starts at 04:40 * has no frame to show someone at two in the morning. 180 frames across 24 * hours is exactly eight minutes a frame, and the last frame lands at 23:52 * rather than midnight so the loop closes without showing the same minute * twice. */ id: "hero-soma", door: "tera", city: "sf", chapter: 2, expect: "SoMa", from: "2026-08-06T00:00:00-07:00", to: "2026-08-06T23:52:00-07:00", frames: 180, fps: 30, viewport: HERO_VIEWPORT, chrome: "bare", /** Whole-day, so this is the only film where the poster hour is a choice about light. */ poster: 0.33, title: "San Francisco, one whole day", place: "SoMa, San Francisco", caption: "A full day of San Francisco every six seconds, starting at whatever hour it is where you are. The sun, the moon and the window lights are computed from a clock rather than themed — the one claim this engine makes that a picture can settle on its own.", alt: "A time-lapse of downtown San Francisco from above, running through a whole day: dark before dawn, long shadows at sunrise, flat midday light over the tower cluster and the Bay Bridge, then dusk and the windows lighting up one by one.", }, { id: "bay-relief-day", door: "tera", city: "sf", chapter: 0, expect: "Whole Board", from: "2026-08-06T04:40:00-07:00", to: "2026-08-06T22:40:00-07:00", frames: 180, fps: 30, /** * The region shot, which is now the weather film as much as the light one. * * This is the only one of the three city cameras that is *above* the cloud * deck — `clouds.ts` sizes its own hard case off exactly this chapter, "the * camera 430 units above the ground with the cloud base at 52" — so it is * the only one that watches the marine layer from on top rather than from * under it. And with no weather feed wired, which is what a capture against * a local `dist/` always is, there is a marine layer to watch: `atmosphere.ts` * models the cover from the layer's own diurnal curve on apparent solar time * rather than defaulting to an empty sky. Over San Francisco on this date the * model runs about 0.88 before dawn, thins to 0.27 by mid-afternoon, and is * back over 0.8 by nine. * * **Do not judge this one from a rough cut**, and the reason is a trap worth * writing down. Cover is eased toward its target over a six-second time * constant in *real* time (`COVER_TAU` in `clouds.ts`), while a frame costs * about half a second of real time no matter how many hours of sky it steps * over — measured here at 0.51 s, by differencing an 8-frame run against a * 28-frame one. So the deck always runs *behind* the day, and how far behind * is decided by the frame count rather than by the weather. At `--frames 40` * one frame is twenty-eight minutes of sky, the layer never gets near its * target, and the board reads as permanently socked in — which it is not. * At 180 frames a frame is six minutes and the lag through the steepest part * of the burn-off works out around a fifth of cover: trailing, but the deck * visibly opens and shuts. The frame count is part of what this picture *is*, * not just how smooth it is. * * 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. */ title: "A day across the whole board", poster: 0.51, 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.", alt: "A time-lapse of the whole San Francisco Bay Area from above, seen from over a cloud deck. The cloud covers the board at dawn, thins through the middle of the day to let long shadows rake across the hills and the bay show through, then closes back in at dusk, after which the built-up ground reads as scattered clusters of light between the dark ranges.", }, { /** * 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. * * **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. * * 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 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. * * 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 * `FILMS`. */ id: "office-dusk", door: "office", chapter: 0, expect: "The Floor", 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", 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.", }, ]; // ---- 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 only = flag("only")?.split(",").map((s) => s.trim()); /** * Override the frame count for a rough cut. * * Filming costs about half a second a frame at 1440x900 on the GPU, so a * 180-frame film is a minute and a half; on the software fallback it is seven * seconds a frame and twenty-two minutes. `--frames 24` is the cheapest * possible way to discover that a camera is pointed at the wrong thing. A rough cut also skips the site, because a 24-frame stutter is not * something to publish by accident. */ const frameOverride = flag("frames") ? Number(flag("frames")) : null; 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. */ const manifestOnly = process.argv.includes("--manifest-only"); /** * Publish an existing render directory to the site instead of shooting a new one. * * There has to be a way to put reels that already exist in front of the site * without re-shooting them — otherwise the answer to "how did those files get * there" becomes `cp`, and a hand-copied artefact is exactly the thing this * pipeline exists to not have. */ const publishFrom = flag("publish"); const wanted = only ? FILMS.filter((f) => only.includes(f.id)) : FILMS; if (only) { const unknown = only.filter((id) => !FILMS.some((f) => f.id === id)); if (unknown.length) { console.error(`unknown film(s): ${unknown.join(", ")}`); process.exit(1); } } const git = (...args) => execFileSync("git", args, { cwd: ROOT, encoding: "utf8" }).trim(); const commit = git("rev-parse", "--short", "HEAD"); const dirty = git("status", "--porcelain").length > 0; const today = new Date().toISOString().slice(0, 10); // ---- The two shims ---------------------------------------------------------- /** * A clock the harness can move, and an app that notices when it does. * * **The skew is a live global, not a constant.** `shots.mjs` bakes one instant * into the page at load, which is right for a still and useless here: 180 * stills would be 180 page loads. Reading `__teraSkew` on every call means the * whole page can be walked through a day without reloading. * * **`setInterval(…, 60_000)` is compressed to 120 ms.** This is the load-bearing * half. `main.ts` recomputes the sun on a once-a-minute wall-clock tick — which * is exactly right for a map somebody is looking at, and means a shifted clock * would otherwise sit unrendered for up to a minute per frame. Only the 60-second * interval is touched, by value, so nothing else in the app has its timing * changed underneath it. * * Both are capture-harness lies told to the page, and neither is shipped: the * deployed bundle has no idea this file exists. */ function filmClock() { return `{ globalThis.__teraSkew = 0; const Real = Date; globalThis.Date = class extends Real { constructor(...a) { super(...(a.length ? a : [Real.now() + globalThis.__teraSkew])); } static now() { return Real.now() + globalThis.__teraSkew; } }; const realSetInterval = globalThis.setInterval.bind(globalThis); globalThis.setInterval = (fn, ms, ...rest) => realSetInterval(fn, ms === 60000 ? 120 : ms, ...rest); }`; } /** Put the page's clock at `instant`, and wait until the frame showing it has been painted. */ async function expose(page, instant) { await page.evaluate((t) => { globalThis.__teraSkew = t - performance.timeOrigin - performance.now(); }, instant); // Long enough for the compressed interval to fire and recompute the sun... await page.waitForTimeout(260); // ...and then two straddled frames, so what is on screen is what we just asked // for rather than the one before it. A `waitForTimeout` alone cannot promise // that when the renderer is slower than the loop asking for frames. await page.evaluate( () => new Promise((r) => requestAnimationFrame(() => requestAnimationFrame(r))), ); } // ---- Filming ---------------------------------------------------------------- async function film(browser, spec, dir) { const host = spec.door === "office" ? "office.lumbridgecorp.com" : "tera.lumbridgecorp.com"; const query = spec.city ? `?city=${spec.city}` : ""; const frames = frameOverride ?? spec.frames; const page = await browser.newPage({ viewport: spec.viewport ?? VIEWPORT, deviceScaleFactor: 1, // As in `shots.mjs`: the chapter cut has to be a cut, not a twenty-second // flight the film would open in the middle of. reducedMotion: "reduce", }); const problems = []; page.on("pageerror", (e) => problems.push(String(e))); try { await page.addInitScript(filmClock()); await page.goto(`http://${host}:5210/${query}`, { waitUntil: "networkidle" }); await page.waitForFunction( () => document.getElementById("boot")?.hidden === true && document.querySelectorAll("#chapters .chapter").length > 0, { timeout: 180_000 }, ); await page.waitForTimeout(4000); const label = await page.evaluate( ({ i, press }) => { const button = [...document.querySelectorAll("#chapters .chapter")][i]; if (!button) return null; if (press) button.click(); return button.textContent.replace(/^\d+/, "").trim(); }, { i: spec.chapter, press: spec.chapter > 0 }, ); if (label !== spec.expect) { throw new Error(`chapter ${spec.chapter} is "${label}", not "${spec.expect}"`); } await page.waitForTimeout(2500); // For a figure the clock stays: it is the caption the film writes for // 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. await hide(page, [ ...FURNITURE.transient, ...(spec.chrome === "bare" ? FURNITURE.BARE : FURNITURE.CLUTTER), ]); const start = new Date(spec.from).getTime(); const end = new Date(spec.to).getTime(); const step = (end - start) / (frames - 1); for (let i = 0; i < frames; i++) { await expose(page, start + step * i); await page.screenshot({ path: join(dir, `f-${String(i).padStart(4, "0")}.png`), timeout: 120_000, animations: "disabled", }); if (i % 20 === 0 || i === frames - 1) { process.stdout.write(`\r frame ${i + 1}/${frames} `); } } process.stdout.write("\n"); if (problems.length) throw new Error(`the page threw while filming: ${problems[0]}`); return frames; } finally { await page.close(); } } /** * Frames to a master, the master to deliverables, and a poster off the frames. * * Two stages rather than one, deliberately. The master is a near-lossless * archive encode that stays in the render directory; every shipped file is * derived from it. That makes "the hero is too heavy, try CRF 28" a ten-second * job instead of a re-shoot, and — the part that matters — * it means what is on the site is always exactly what this script produces, * rather than something hand-rolled with ffmpeg the day the size became a * problem. * * `yuv420p` and `+faststart` are not decoration: without the first, Safari and * 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. */ function encode(dir, out, fps, frames, posterAt, deliverables) { const ff = (...args) => execFileSync("ffmpeg", ["-y", "-loglevel", "error", ...args]); const master = `${out}-master.mp4`; ff( "-framerate", String(fps), "-i", join(dir, "f-%04d.png"), "-c:v", "libx264", "-preset", "slow", "-crf", "18", "-pix_fmt", "yuv420p", master, ); for (const d of deliverables) { ff( "-i", master, "-vf", `scale=${d.width}:-2:flags=lanczos`, "-c:v", "libx264", "-preset", "slow", "-crf", String(d.crf), "-pix_fmt", "yuv420p", "-movflags", "+faststart", `${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; } // ---- Run -------------------------------------------------------------------- const exists = (p) => access(p).then(() => true, () => false); const publicDir = join(siteDir, "apps", "web", "public", "films"); const dataDir = join(siteDir, "apps", "web", "src", "data"); const haveSite = () => exists(join(siteDir, "apps", "web")); async function writeManifest() { await mkdir(dataDir, { recursive: true }); await writeFile(join(dataDir, "films.ts"), manifest()); return join(dataDir, "films.ts"); } /** * Copy a render directory to the site, then rewrite the manifest. * * A film that is not in `fromDir` but is already published is **left alone** * rather than treated as an error. A one-reel render is the normal way to add * or replace one, and demanding that * every reel be present in the same directory would mean re-shooting the whole * set to change any of it. What is not tolerated is a film that exists in * neither place: the manifest is about to name it, so that is a hard failure * rather than a broken `