/** * 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"] : [], ), );