1
0
This repository has been archived on 2026-08-25. You can view files and clone it. You cannot open issues or pull requests or push a commit.
Files
tera/scripts/brand-assets/refresh.mjs
T
karti 2aa4049258 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>
2026-08-22 12:49:06 -07:00

460 lines
18 KiB
JavaScript

/**
* Re-shoot everything the site shows of this engine, in one command.
*
* npm run refresh # build, stills, cards, films, manifests
* npm run refresh -- --dry-run # what it would shoot; no browser, no build
* npm run refresh -- --stills-only
* npm run refresh -- --cards-only
* npm run refresh -- --films-only
* npm run refresh -- --rough # films at 24 frames, for looking at
* npm run refresh -- --skip-build # you just built; do not build again
*
* ### Why this exists
*
* The imagery on lumbridgecorp.com is four artefacts produced by three scripts
* into two repos: product stills and a TypeScript manifest into `lumbridge-v4`,
* time-lapse films and a second manifest into the same place, and two share
* cards into this repo's `public/`. Re-shooting after an engine change was a
* *procedure* — build first, because the scripts photograph `dist/` and not
* `src/`; then three commands in an order that matters; then check the run
* actually got the GPU; then commit this repo before regenerating the manifest
* so it names a clean sha. A procedure that lives in somebody's head is a
* procedure that gets half-run, and the evidence is in the tree: the stills were
* re-shot at one commit, the films at another two weeks older, and nothing said
* so until a human compared two files.
*
* So: one command, and the parts you can skip are flags rather than the memory
* of which script does what.
*
* ### The two failures this must never report as success
*
* Both of them produce output that looks deliberate, which is why they are
* checked here rather than left to the eye:
*
* 1. **SwiftShader.** `launch()` in `harness.mjs` asks for `--use-angle=vulkan`
* and falls back to software when the card is not reachable, printing which
* it got. The pictures are the same pictures — measured, half a level out of
* 255 — but a film goes from ninety seconds to twenty-two minutes, so a run
* that has silently fallen back reads as a hang. Every child's output is
* scanned for that line and the run stops on it.
*
* 2. **A chapter `expect` guard firing.** Chapters are pack data and pack data
* gets reordered; the guard in `shots.mjs` refuses to shoot when the button at
* an index is not the chapter the shot list names. It has already earned its
* keep once, when the studios were rebuilt and chapter 0 became "Front Door".
* When it fires, the answer is to fix the shot list, not to re-run — so this
* surfaces it as its own headline rather than as one line of a stack trace
* fifty lines up the log.
*
* ### It re-stamps the provenance manifest, narrowly
*
* `PROVENANCE.json` records a SHA-256 for every tracked binary and
* `npm run provenance` fails on a mismatch, so every legitimate re-shoot of the
* share cards used to leave that gate red. This re-stamps an entry only when the
* file is one this run wrote, its `origin` is `repository-generated`, and its
* `generator` names a script this run actually executed. Every other mismatch is
* reported and left alone, because a hash that moved without this pipeline
* running is the thing the gate exists to catch.
*
* ### What "changed" means in the summary
*
* Every deliverable is hashed before and after, so the summary can say which
* pictures actually moved rather than which commands were run. Renders are never
* byte-identical — aircraft are crossing and cloud shadow is drifting between any
* two runs — so "changed" here means the bytes differ, not that the framing did.
* The size delta is the useful column: a WebP that halved or doubled is a frame
* worth opening.
*/
import { spawn } from "node:child_process";
import { createHash } from "node:crypto";
import { fileURLToPath } from "node:url";
import { dirname, join, relative, resolve } from "node:path";
import { readdir, readFile, writeFile } from "node:fs/promises";
const HERE = dirname(fileURLToPath(import.meta.url));
const ROOT = join(HERE, "..", "..");
// ---- 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 has = (name) => process.argv.includes(`--${name}`);
const dryRun = has("dry-run");
const rough = has("rough");
const skipBuild = has("skip-build");
const siteDir = resolve(flag("site", join(ROOT, "..", "lumbridge-v4")));
/**
* `--stills-only` and friends select; naming none of them selects all three.
*
* They compose, so `--stills-only --cards-only` is "stills and cards", which is
* the pair you want after a lighting change and before you are ready to pay for
* films.
*/
const picked = ["stills", "cards", "films"].filter((part) => has(`${part}-only`));
const parts = new Set(picked.length ? picked : ["stills", "cards", "films"]);
// ---- What each part writes ---------------------------------------------------
/**
* The deliverables, by part, so the summary can be computed rather than claimed.
*
* `optional` covers the normal case for anyone who cloned this repo on its own:
* there is no sibling `lumbridge-v4`, the scripts say so and skip the site step,
* and a missing directory here is a note rather than a failure.
*/
const OUTPUTS = {
stills: [
{ dir: join(siteDir, "apps", "web", "public", "shots"), match: /\.webp$/, optional: true },
{ dir: join(siteDir, "apps", "web", "src", "data"), match: /^shots\.ts$/, optional: true },
],
cards: [{ dir: join(ROOT, "public"), match: /^og-.*\.png$/ }],
films: [
{ dir: join(siteDir, "apps", "web", "public", "films"), match: /\.(mp4|webp|png|jpg)$/, optional: true },
{ dir: join(siteDir, "apps", "web", "src", "data"), match: /^films\.ts$/, optional: true },
],
};
async function fingerprint() {
const seen = new Map();
for (const part of parts) {
for (const { dir, match } of OUTPUTS[part]) {
let names;
try {
names = await readdir(dir);
} catch {
continue; // Not there yet, or no site checkout. `report` says so.
}
for (const name of names) {
if (!match.test(name)) continue;
const path = join(dir, name);
const body = await readFile(path);
const sha256 = createHash("sha256").update(body).digest("hex");
seen.set(path, { size: body.length, hash: sha256.slice(0, 12), sha256 });
}
}
}
return seen;
}
// ---- Running a step ----------------------------------------------------------
/**
* The two lines that mean "this run's output is not to be trusted".
*
* `launch()` prints exactly one of these per browser it opens, so matching the
* text it prints is matching the check it already does rather than repeating it.
*/
const SOFTWARE_RENDERER = /GPU:.*(SwiftShader|llvmpipe)|no GPU \(/i;
/** `chapter()` in `shots.mjs`, and the office picker's equivalent beside it. */
const PACK_REORDERED = /a city pack was reordered|the OFFICES table\s*\n?\s*changed|is "[^"]*", not "/;
class StepFailure extends Error {
constructor(step, headline, detail) {
super(headline);
this.step = step;
this.headline = headline;
this.detail = detail;
}
}
/**
* Run one child, streaming its output, and read that output as it goes.
*
* Streaming *and* buffering, because both are wanted: a film is ninety seconds
* of silence otherwise, and the two failures above have to be findable after the
* fact. The buffer is a few kilobytes of console output, not the frames.
*/
function run(step, command, args, { cwd = ROOT } = {}) {
return new Promise((resolveRun, rejectRun) => {
const started = Date.now();
const child = spawn(command, args, { cwd, stdio: ["ignore", "pipe", "pipe"] });
let log = "";
const watch = (stream, sink) => {
stream.setEncoding("utf8");
stream.on("data", (chunk) => {
log += chunk;
sink.write(chunk);
});
};
watch(child.stdout, process.stdout);
watch(child.stderr, process.stderr);
child.on("error", (error) => rejectRun(new StepFailure(step, `could not start ${command}`, String(error))));
child.on("close", (code) => {
const seconds = ((Date.now() - started) / 1000).toFixed(1);
if (SOFTWARE_RENDERER.test(log)) {
rejectRun(
new StepFailure(
step,
"the renderer came up as SwiftShader, not the card",
"Everything this run produced is software-rendered: same pictures, roughly fifteen times\n" +
"the wall clock, and a film that reads as a hang. Nothing has been published from it.\n" +
"Chrome reaches the Radeon with `--use-angle=vulkan` and needs read access to\n" +
"/dev/dri/renderD128, which group `render` grants — check `id` and `ls -l /dev/dri`,\n" +
"then re-run. `harness.mjs` explains why no other GL flag is a substitute.",
),
);
return;
}
if (code !== 0 && PACK_REORDERED.test(log)) {
rejectRun(
new StepFailure(
step,
"a chapter guard fired — the shot list no longer matches the packs",
"A shot names a chapter by index and asserts its short label before the shutter opens,\n" +
"and the assertion failed. This is not a flake and re-running will not fix it: a pack was\n" +
"reordered or renamed underneath the shot list, so every camera and caption downstream of\n" +
"that index is now pointed at something else.\n" +
"Fix `SHOTS` in shots.mjs against the pack (`node scripts/brand-assets/shots.mjs --list`\n" +
"prints what it believes), then run this again. Do not weaken the guard.",
),
);
return;
}
if (code !== 0) {
rejectRun(new StepFailure(step, `${step} failed (exit ${code})`, "The child's own output is above."));
return;
}
resolveRun({ seconds, log });
});
});
}
// ---- The plan ----------------------------------------------------------------
const NODE = process.execPath;
const script = (name) => join(HERE, name);
/**
* The film list, read out of `films.mjs` rather than imported from it.
*
* `films.mjs` is a script with top-level effects — importing it opens a browser —
* and it belongs to a different workstream, so this reads its `FILMS` ids for the
* dry run and treats a miss as "cannot say" rather than as an error. The command
* printed underneath is the truth either way.
*/
async function filmIds() {
try {
const body = await readFile(script("films.mjs"), "utf8");
const from = body.indexOf("const FILMS = [");
if (from === -1) return null;
const ids = [...body.slice(from).matchAll(/^\s{4}id: "([^"]+)"/gm)].map((m) => m[1]);
return ids.length ? ids : null;
} catch {
return null;
}
}
async function plan() {
console.log(`refresh ${[...parts].join(", ")}${rough ? " (films at 24 frames)" : ""}`);
console.log(`site ${siteDir}`);
console.log("");
if (parts.has("stills") || parts.has("cards")) {
await run("list", NODE, [script("shots.mjs"), "--list"]);
console.log("");
}
if (parts.has("cards")) {
console.log("cards 2 renders of the running app, then og.html over each at 1200x630");
console.log(" og-tera.png tera.lumbridgecorp.com");
console.log(" og-office.png office.lumbridgecorp.com");
console.log("");
}
if (parts.has("films")) {
const ids = await filmIds();
console.log(`films ${ids ? `${ids.length} reels: ${ids.join(", ")}` : "reel list is films.mjs's own"}`);
console.log(` ${NODE} ${relative(ROOT, script("films.mjs"))}${rough ? " --frames 24" : ""}`);
console.log("");
}
console.log("nothing was rendered — this was --dry-run");
}
// ---- Provenance --------------------------------------------------------------
/**
* Re-stamp the SHA-256 of the artifacts this run generated, and nothing else.
*
* `PROVENANCE.json` records a hash for every tracked binary, and
* `npm run provenance` fails when one does not match. That is the right gate —
* it is what stops an art file arriving from somewhere nobody can name — but the
* two share cards are *outputs of this pipeline*, so every legitimate re-shoot
* left the gate red and a human had to work out which of two hashes went in
* which of two records. That is precisely the remembered procedure this script
* exists to delete.
*
* The rule is deliberately narrow, so this stays bookkeeping rather than a hole
* in the gate. An entry is re-stamped only when **all** of these hold:
*
* - the file is one this run wrote, in a directory this run owns;
* - `origin` is `repository-generated` — never a copied upstream item;
* - `generator` names a script this run actually executed.
*
* Anything else that has drifted is reported and left exactly as it was, because
* a hash that changed without this pipeline running is the thing the gate is for.
*/
async function restampProvenance(written, ranGenerators) {
const path = join(ROOT, "PROVENANCE.json");
let body;
let manifest;
try {
body = await readFile(path, "utf8");
manifest = JSON.parse(body);
} catch (error) {
console.log(`\nprovenance could not be read (${error.message}) — left alone`);
return;
}
const stamped = [];
const refused = [];
for (const entry of manifest.distributedArtifacts ?? []) {
const full = join(ROOT, entry.path);
const now = written.get(full);
if (!now || now.sha256 === entry.sha256) continue;
const mine =
entry.origin === "repository-generated" &&
ranGenerators.some((generator) => entry.generator === generator);
if (!mine) {
refused.push(entry.path);
continue;
}
/*
* Substitute the hash in the *text*, not by re-serialising the object.
* `JSON.stringify(…, null, 2)` reflows every array in the file, so a
* two-hash update arrived as a fourteen-line diff across records this run
* had nothing to do with — which is the last thing a licensing manifest
* should do. A SHA-256 is 64 hex characters and unique in this file, so a
* literal replacement is exact.
*/
if (!body.includes(entry.sha256)) {
refused.push(`${entry.path} (recorded hash not found verbatim)`);
continue;
}
body = body.replace(entry.sha256, now.sha256);
stamped.push(entry.path);
}
if (stamped.length) {
await writeFile(path, body, "utf8");
console.log(`\nprovenance re-stamped ${stamped.length}: ${stamped.join(", ")}`);
}
if (refused.length) {
console.log(
`\nprovenance ${refused.length} artifact(s) no longer match their recorded hash and were NOT` +
` touched:\n ${refused.join("\n ")}\n` +
" Nothing this run generated wrote them. Find out what did before committing.",
);
}
}
// ---- The summary -------------------------------------------------------------
function report(before, after, timings) {
const paths = [...new Set([...before.keys(), ...after.keys()])].sort();
const added = [];
const changed = [];
const same = [];
const gone = [];
for (const path of paths) {
const was = before.get(path);
const now = after.get(path);
if (!was) added.push({ path, now });
else if (!now) gone.push({ path, was });
else if (was.hash !== now.hash) changed.push({ path, was, now });
else same.push({ path });
}
const kb = (n) => `${Math.round(n / 1024)}kB`;
const show = (entries, label, line) => {
if (!entries.length) return;
console.log(`\n${label} (${entries.length})`);
for (const entry of entries) console.log(` ${line(entry)}`);
};
console.log("\n" + "─".repeat(72));
for (const [step, seconds] of timings) console.log(`${step.padEnd(10)} ${seconds}s`);
show(added, "new", ({ path, now }) => `${relative(ROOT, path).padEnd(58)} ${kb(now.size)}`);
show(
changed,
"changed",
({ path, was, now }) => {
const delta = now.size - was.size;
const sign = delta > 0 ? "+" : "";
return `${relative(ROOT, path).padEnd(58)} ${kb(now.size)} (${sign}${kb(delta)})`;
},
);
show(gone, "no longer written", ({ path }) => relative(ROOT, path));
if (same.length) console.log(`\nbyte-identical (${same.length}) — nothing about these moved`);
if (!added.length && !changed.length) {
console.log("\nnothing changed. If you expected a change, check that `npm run build` ran:");
console.log("these scripts photograph dist/, not src/, and --skip-build is a way to shoot a stale one.");
}
console.log("");
console.log("Commit tera first, then re-run the manifests so they name a clean sha:");
console.log(" node scripts/brand-assets/shots.mjs --manifest-only");
console.log(" node scripts/brand-assets/films.mjs --manifest-only");
console.log("Then commit lumbridge-v4 and deploy it.");
}
// ---- Run ---------------------------------------------------------------------
if (dryRun) {
await plan();
process.exit(0);
}
const before = await fingerprint();
const timings = [];
try {
if (!skipBuild) {
// First, and not optional without saying so: every script below photographs
// `dist/`, so skipping the build is how a "re-shoot" publishes the old engine.
const built = await run("build", "npm", ["run", "build"]);
timings.push(["build", built.seconds]);
} else {
console.log("build skipped (--skip-build) — the stills, cards and films will photograph the dist/ already on disk\n");
}
if (parts.has("stills")) {
const done = await run("stills", NODE, [script("shots.mjs"), "--site", siteDir]);
timings.push(["stills", done.seconds]);
}
if (parts.has("cards")) {
const done = await run("cards", NODE, [script("capture.mjs")]);
timings.push(["cards", done.seconds]);
}
if (parts.has("films")) {
const args = [script("films.mjs"), "--site", siteDir];
if (rough) args.push("--frames", "24");
const done = await run("films", NODE, args);
timings.push(["films", done.seconds]);
}
} catch (error) {
if (error instanceof StepFailure) {
console.error("\n" + "━".repeat(72));
console.error(`FAILED at ${error.step}: ${error.headline}`);
console.error("━".repeat(72));
console.error(error.detail);
console.error("");
process.exit(1);
}
throw error;
}
const after = await fingerprint();
report(before, after, timings);
await restampProvenance(
after,
timings.map(([step]) => step).flatMap((step) =>
step === "cards"
? ["scripts/brand-assets/capture.mjs"]
: step === "stills"
? ["scripts/brand-assets/shots.mjs"]
: step === "films"
? ["scripts/brand-assets/films.mjs"]
: [],
),
);