From b4eb2fa1a8462ade7c9a6cc2faa8984239d35efe Mon Sep 17 00:00:00 2001 From: Kartios Date: Thu, 6 Aug 2026 16:45:24 -0700 Subject: [PATCH] The engine can be filmed, by stepping its clock rather than recording it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `films.mjs` renders time-lapses of the running dist/: a locked camera watching eighteen hours pass, shadow sweeping the city and the windows coming on as the computed sun goes down. Two ship — the Financial District and the whole board. Real-time capture was never on the table and would have been the wrong idea anyway. SwiftShader draws about three frames a second with no GPU, so a screen recording is a slideshow; and a sunset takes an hour, which is not a length of video anybody watches. So it is rendered offline the way films always have been: set the clock, let the frame settle, expose, advance. The output is smooth 30fps regardless of what the renderer managed while being photographed. Two shims in `filmClock()` get that done without a reload per frame, which matters because a reload is fifteen seconds and would put a 180-frame film at three quarters of an hour. The skew is a live global read on every `Date` call rather than baked in at load; and `setInterval(…, 60_000)` is compressed to 120ms, which is the load-bearing half — `main.ts` recomputes the sun on a once-a-minute wall-clock tick, so a shifted clock otherwise sits unrendered. Only the 60-second interval is touched, by value. Both are capture-harness lies told to the page. The deployed bundle has no idea this file exists. Not wired into the site. This is the capability and two reels to look at. Co-Authored-By: Claude Opus 5 (1M context) --- .gitignore | 4 + scripts/brand-assets/films.mjs | 310 +++++++++++++++++++++++++++++++++ 2 files changed, 314 insertions(+) create mode 100644 scripts/brand-assets/films.mjs diff --git a/.gitignore b/.gitignore index 0021589..f8cb41b 100644 --- a/.gitignore +++ b/.gitignore @@ -35,3 +35,7 @@ scripts/brand-assets/art-*.png # straight into the site checkout and keeps the full-resolution frames here as a # dated archive; neither is source, and the archive is several MB a run. shots/ + +# Rendered films. `scripts/brand-assets/films.mjs` writes MP4s and posters here, +# plus a scratch frame directory it cleans up after itself. Regenerated, not source. +films/ diff --git a/scripts/brand-assets/films.mjs b/scripts/brand-assets/films.mjs new file mode 100644 index 0000000..d5568f6 --- /dev/null +++ b/scripts/brand-assets/films.mjs @@ -0,0 +1,310 @@ +/** + * 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 `