/** * 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 30 # a rough cut * * `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. SwiftShader draws about three frames a second with no GPU, so a * screen recording would be a slideshow; and a sunset takes an hour, which is * not a length of video anybody watches. 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 } 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 -------------------------------------------------------------- /** * `chapter` and `expect` work exactly as they do in `shots.mjs` — an index into * the chapter list, and the `shortLabel` it is asserted to be. * * `from` and `to` are the ends of the day the camera watches. They are not * midnight to midnight: the hours either side of dawn and dusk are the whole * point and the small hours are eighteen identical dark frames, so the window * is cropped to the part that moves. */ 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, }, { 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 is the one where the day does the most work. Shadow // sweeps the length of two mountain ranges, and then the thing the relief // was hiding turns up: the cities, which are somewhere else entirely. title: "A day across the whole board", poster: 0.2, }, ]; const VIEWPORT = { width: 1440, height: 900 }; /** Delivered at 1280 wide. The frames are shot at 1440 and scaled once, by ffmpeg. */ const DELIVER_WIDTH = 1280; // ---- 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. A 30-frame pass takes about half a minute. */ const frameOverride = flag("frames") ? Number(flag("frames")) : null; const outRoot = resolve(flag("out", join(ROOT, "films"))); 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 at three frames a second. 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: 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); // The clock stays. It is the caption the film writes for itself, and the // only thing on screen that proves the light is following a real time // rather than a hand-keyed fade. await hide(page, [...FURNITURE.transient, ...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 an MP4, plus the poster a `