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 b7f5c41da5 feat(brand): a shot can take a body, so the last hand-made stills are generated
Three pictures on /arena — the chase camera behind the EV, the crow in flight, the
aircraft over the board — were the only imagery on the site with no generator
script anywhere in either repo. They were made by hand, and so they were a single
daylight frame served to dark-mode readers too, with no pair to choose from.

`shots.mjs` gains a `play` capability beside `aim`: a shot names a control mode,
the script clicks the app's own mode dock, and only then shoots — with the HUD
deliberately left in frame, because on these three the body and its readout ARE
the subject. `#mode-dock` and `#play-hud` are in FURNITURE.BARE rather than
CLUTTER for that reason, and there is a comment at the `hide()` call so a later
tidy-up does not crop the subject out.

Three guards, each verified by deliberately making it fire:

  - the mode must be OFFERED, not merely present — asking for `drive` on the Bay
    Area board now says `"drive" is not offered here — the dock is showing
    [overview, actor]`;
  - `aria-pressed="true"` AND `#play-hud` unhidden, the same pair
    `performance-budget.mjs` waits on;
  - the HUD's own body chip, so asking for `Explore` when an anonymous visitor
    gets a crow says `the play HUD says "Crow", not "Explore"`.

The body guard runs twice, at the click and again at the shutter, because a
completed route or a bound ends a mode mid-wait and hands back an orbit wide shot
under the right filename.

`untilProgress` exists because the first delivery drifted: `run: 9000` gave 55% of
the leg in a 1x preview and 69% in the 2x render — a different stretch of road,
with a town's edge across the top of the frame. Road traffic runs from page load,
so a duration is really "boot plus a bit". Waiting on the app's own route
percentage lands preview and delivery on the same frame; overshoot throws rather
than shooting. The crow and the aircraft are built inert, so a duration is exact
for them and only the car needed the gate — measured, not assumed.

The run log now echoes the HUD (`[Drive] 56 mph · US-101 | assisted · 55% ·
chase`), because "shot drive-101-day" is equally true of a parked car, a finished
route and a correct frame.

The crow moved from the state board to the Bay Area. On California a one-metre
bird stands against ground drawn at 1,919 m to the unit with an oak the size of a
hill behind it, and the night frame would have been a black rectangle with a bird
in it. `crow-nav-v1`'s `source` said "Tera / California" and that was wrong on the
environment's own terms as well — `crowNav.ts` runs a ±120 m box with no terrain
at all.

One frame is honestly weak and is recorded rather than dressed up: `drive-101`
at night. From behind, on an unlit corridor, the EV is two tail lamps and a roof
strip. Four hours were shot between 21:15 and 20:05 and the corridor's ground is
equally black at all of them, so the hour is not the lever; both real fixes are in
the engine and are in TODO.md. It is still the honest night frame.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-22 13:30:23 -07:00

498 lines
21 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 three failures this must never report as success
*
* All three 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.
*
* 3. **A `play` guard firing.** Three stills take control of a body before the
* shutter opens — the EV on US-101, the crow over the Bay, the aircraft on the
* corridor — and `enterPlay()` in `shots.mjs` refuses to shoot when the dock
* did not hand over the mode, when the HUD names a different body, or when the
* route ran out during the wait. Every one of those otherwise delivers an orbit
* wide shot under a filename that says an agent is driving. Same treatment as
* the chapter guard, and for the same reason: re-running cannot fix it.
*
* ### 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 "/;
/**
* `enterPlay()` in `shots.mjs` refusing to shoot a body it did not get.
*
* The third headline, and here for the same reason the second one is: three of
* the stills are pictures of an agent driving, flying and gliding, and every way
* that can go wrong produces a frame that still renders, still looks deliberate
* and is of the wrong thing — an overview wide shot with `drive-101-day` written
* on it. Re-running does not fix any of them. Either the dock stopped offering
* the mode, or the body is not the one the caption describes, or the leg ran out
* from under the wait.
*/
const BODY_REFUSED =
/is not offered here|the mode dock has no|the dock did not take|the play HUD says|did not survive the wait|was already \d+% done|carries no percentage/;
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 && BODY_REFUSED.test(log)) {
rejectRun(
new StepFailure(
step,
"a play guard fired — a shot did not get the body it asked for",
"Three stills take control of something before the shutter opens: the EV on US-101, the\n" +
"crow over the Bay, the aircraft on the California corridor. Each asks the mode dock for a\n" +
"mode, waits for `aria-pressed` and the play HUD, and asserts which body the HUD names.\n" +
"One of those checks refused, and re-running will not fix it: the mode is unavailable on\n" +
"that board, or the body changed, or the route ran out during the wait. A frame shot past\n" +
"any of those is an orbit wide shot with the right filename.\n" +
"`node scripts/brand-assets/shots.mjs --list` prints what each shot asks for. Do not\n" +
"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"]
: [],
),
);