Archilyzer · Source

archilyzer

Archilyzer
git clone https://archilyzer.pages.dev/source/archilyzer.git
Log | Files | Refs | README | LICENSE

commit 3c1d76db02c062ecb42e6d17a90bb4ac0459325d
parent caa1739f4c30674a15474ab1f1763db1ebfec5b5
Author: I Mean I'm Just Saying <imeanimjustsaying@kiwifarms.st>
Date:   Wed, 30 Sep 2026 21:17:24 -0400

deck S4: the on-screen writers — updateOnscreen, updateChrome, updateClip's onscreen

lib/report/manifest.mjs gains the deck's two writers, both through the
manifest lock, the .bak, the atomic write and the stale-token guard:

- updateOnscreen(dir, { id: {title?, subtitle?} | null }, { token }) — any
  entry type; a row replaces the entry's whole `onscreen`, normalised by
  deck.mjs's normalizeOnscreen, and nothing left deletes the key. An unknown
  id or a refused value fails the whole batch before anything is written.
  Every variant twin carrying the id gets the text.
- updateChrome(dir, chrome | null, { token }) — refused with validateChrome's
  sentences (ChromeRefused carries them as `errors`), checked against the rest
  of the render block; null removes render.chrome. Stored as given, so the
  defaults are not written out.

updateClip accepts `onscreen` by the same rule, and PUT /api/report/window
whitelists it. Unit tests under umtool/lib/report run in `pnpm test:scripts`.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

Diffstat:
Mpackage.json | 2+-
Mumtool/app/api/report/window/route.ts | 3+++
Mumtool/lib/report/manifest.mjs | 132+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Aumtool/lib/report/manifest.test.mjs | 296+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
4 files changed, 432 insertions(+), 1 deletion(-)

diff --git a/package.json b/package.json @@ -22,7 +22,7 @@ "e2e": "node scripts/worktree.mjs run -- pnpm --filter editor run e2e", "wt": "node scripts/worktree.mjs", "e2e:sharded": "node scripts/run-sharded-e2e.mjs", - "test:scripts": "node --test scripts/*.test.mjs umtool/report-to-video/*.test.mjs", + "test:scripts": "node --test scripts/*.test.mjs umtool/report-to-video/*.test.mjs umtool/lib/report/*.test.mjs", "lint": "pnpm --filter export run lint", "ops": "node scripts/archilyzer-ops.mjs" }, diff --git a/umtool/app/api/report/window/route.ts b/umtool/app/api/report/window/route.ts @@ -50,6 +50,9 @@ export async function PUT(request: Request) { // Whether the walk has looked at this clip: "confirmed", or empty to clear // it. A non-empty `correction` is the other answer and needs no value. "verdict", + // What the deck says over this clip: { title?, subtitle? }, or null to + // clear it. Normalised and checked by the writer, like the date. + "onscreen", ]) { if (body[k] !== undefined) patch[k] = body[k]; } diff --git a/umtool/lib/report/manifest.mjs b/umtool/lib/report/manifest.mjs @@ -30,6 +30,7 @@ import { rolesGaps, } from "umtool-report-to-video/ledger-totals"; import { isCalendarDate } from "umtool-report-to-video/attribution"; +import { normalizeOnscreen, validateChrome } from "umtool-report-to-video/deck"; // Its own write queue, not lib/state.ts's. // @@ -317,6 +318,13 @@ export async function updateClip(dir, clipId, patch, { token = null } = {}) { throw new Error("an incorrect verdict needs its note: say what the report got wrong"); } + // ---- what the deck will say --------------------------------------------- + // + // The bench's On-screen fields, written in the same sitting as the header + // fields above. setOnscreen is updateOnscreen's rule, so a clip edited here + // and a row saved from the On-screen table cannot store different shapes. + if (patch.onscreen !== undefined) setOnscreen(entry, patch.onscreen); + const nextToken = await writeManifestAtomic(dir, manifest); return { entry, before, token: nextToken }; }); @@ -420,3 +428,127 @@ export async function updateClaim(dir, claimId, patch, { token = null } = {}) { return { entry, token: nextToken }; }); } + + +// --------------------------------------------------------------------------- +// The DECK: per-entry on-screen text, and the render.chrome block that turns +// the deck on. +// +// Both are checked by deck.mjs, imported rather than restated -- the build +// refuses a manifest with the same two functions, so a value this file accepts +// is one the build accepts, and the other way round. +// --------------------------------------------------------------------------- + +/** + * Store one entry's `onscreen`, normalised. The value REPLACES the entry's + * whole `onscreen` -- `{ title }` alone clears a subtitle override -- because + * the editors send a row, not a field. Nothing left (null, `{}`, blanks) + * DELETES the key, the way an empty attribution field does. + */ +function setOnscreen(entry, value) { + const v = normalizeOnscreen(value); + if (v) entry.onscreen = v; + else delete entry.onscreen; + return v; +} + +/** + * Patch the on-screen text of any number of timeline entries, in one write. + * + * Any entry type: a card's or a still's deck title is as much the author's as + * a clip's. The batch is ALL OR NOTHING -- an unknown id, or a value + * normalizeOnscreen refuses, fails the whole call before anything is written, + * because a table saved with one row silently dropped reads as saved. + * + * Ids are matched against the WHOLE timeline, every variant's entries + * included: an entry only the `full` cut shows still has a title. + * + * @param {string} dir + * @param {Record<string, { title?: string, subtitle?: string } | null>} onscreen + * @param {{ token?: string | null }} [opts] + * @returns {Promise<{ onscreen: Record<string, { title?: string, subtitle?: string } | null>, token: string | null }>} + */ +export async function updateOnscreen(dir, onscreen, { token = null } = {}) { + if (!onscreen || typeof onscreen !== "object" || Array.isArray(onscreen)) { + throw new Error("onscreen must be an object of entry id → { title, subtitle } or null"); + } + const ids = Object.keys(onscreen); + if (!ids.length) throw new Error("nothing to change"); + // Normalised BEFORE the lock: a bad value is the caller's error whatever the + // file says, and refusing it needs no read. + const next = {}; + for (const id of ids) { + try { + next[id] = normalizeOnscreen(onscreen[id]); + } catch (e) { + throw new Error(`${id}: ${e instanceof Error ? e.message : String(e)}`); + } + } + + return withManifestLock(async () => { + const current = await manifestToken(dir); + if (token !== null && current !== token) throw new StaleToken(token, current); + + const manifest = JSON.parse(await readFile(manifestFile(dir), "utf8")); + const byId = new Map((manifest.timeline ?? []).map((e) => [e.id, e])); + const unknown = ids.filter((id) => !byId.has(id)); + if (unknown.length) { + throw new Error(`no timeline entry with id ${unknown.join(", ")} — nothing was written`); + } + // A timeline can repeat an id across variants (`variant: "sourced"` and + // `variant: "full"` twins). Every entry with the id gets the text: they + // are one moment in two cuts. + for (const e of manifest.timeline) { + if (e.id in next) setOnscreen(e, next[e.id]); + } + + const nextToken = await writeManifestAtomic(dir, manifest); + return { onscreen: next, token: nextToken }; + }); +} + +/** + * Set, replace or remove `render.chrome`. + * + * `null` REMOVES it, which turns the deck off and puts the cut back on the + * legacy chrome. Anything else is stored as given -- a manifest names only the + * settings it changes, so the defaults are not written out -- once + * validateChrome has nothing to say about it against the rest of the render + * block (a rail or a legacy `chromeEngine` beside the deck is refused there, + * and so is footage that does not fit above it). + * + * @param {string} dir + * @param {Record<string, unknown> | null} chrome + * @param {{ token?: string | null }} [opts] + * @returns {Promise<{ chrome: Record<string, unknown> | null, token: string | null }>} + */ +export async function updateChrome(dir, chrome, { token = null } = {}) { + if (chrome === undefined) throw new Error("chrome must be an object, or null to remove it"); + return withManifestLock(async () => { + const current = await manifestToken(dir); + if (token !== null && current !== token) throw new StaleToken(token, current); + + const manifest = JSON.parse(await readFile(manifestFile(dir), "utf8")); + const { chrome: _old, ...renderWithoutChrome } = manifest.render ?? {}; + if (chrome === null) { + if (manifest.render) delete manifest.render.chrome; + } else { + const errors = validateChrome(chrome, renderWithoutChrome); + if (errors.length) throw new ChromeRefused(errors); + manifest.render = { ...(manifest.render ?? {}), chrome }; + } + + const nextToken = await writeManifestAtomic(dir, manifest); + return { chrome: manifest.render?.chrome ?? null, token: nextToken }; + }); +} + +/** validateChrome's sentences, thrown whole so a route can return each one. */ +export class ChromeRefused extends Error { + /** @param {string[]} errors */ + constructor(errors) { + super(`render.chrome: ${errors.join("; ")}`); + this.name = "ChromeRefused"; + this.errors = errors; + } +} diff --git a/umtool/lib/report/manifest.test.mjs b/umtool/lib/report/manifest.test.mjs @@ -0,0 +1,296 @@ +// The deck's writers: updateOnscreen, updateChrome, and updateClip's +// `onscreen`. Each one goes through the lock, the backup, the atomic write and +// the stale-token guard, and each refusal leaves the file byte-for-byte as it +// was. +// +// Run with: pnpm test:scripts +import assert from "node:assert/strict"; +import { mkdtemp, readFile, rm, stat, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import test from "node:test"; + +import { + ChromeRefused, + MANIFEST_NAME, + StaleToken, + manifestToken, + updateChrome, + updateClip, + updateOnscreen, +} from "./manifest.mjs"; + +const base = () => ({ + slug: "t", + provenance: { siteOrigin: "https://example.test", channelSlug: "chan" }, + render: { width: 1920, height: 1080, fps: 30, transition: 0.5 }, + timeline: [ + { type: "card", id: "k1", heading: "Opening", sub: "a card" }, + { type: "clip", id: "c01", video: "v1", start: 10, end: 20 }, + { type: "image", id: "i1", file: "x.png", seconds: 4 }, + { type: "clip", id: "c02", video: "v2", start: 30, end: 41, onscreen: { title: "Kept" } }, + ], +}); + +async function project(manifest = base()) { + const dir = await mkdtemp(path.join(tmpdir(), "umtool-deck-")); + await writeFile(path.join(dir, MANIFEST_NAME), JSON.stringify(manifest, null, 2) + "\n"); + return dir; +} +const readRaw = (dir) => readFile(path.join(dir, MANIFEST_NAME), "utf8"); +const read = async (dir) => JSON.parse(await readRaw(dir)); +const entry = (m, id) => m.timeline.find((e) => e.id === id); + +test("updateOnscreen: round trip on every entry type, trimmed, in the CLI's formatting", async () => { + const dir = await project(); + try { + const token = await manifestToken(dir); + const res = await updateOnscreen( + dir, + { + k1: { title: " Card title " }, + c01: { title: "County approves pre-application", subtitle: " Override · 2024 " }, + i1: { subtitle: "Still" }, + }, + { token }, + ); + assert.deepEqual(res.onscreen.k1, { title: "Card title" }); + assert.equal(res.token, await manifestToken(dir)); + + const raw = await readRaw(dir); + assert.ok(raw.endsWith("}\n"), "trailing newline kept"); + assert.ok(raw.startsWith('{\n "slug"'), "two-space indent kept"); + const m = JSON.parse(raw); + assert.deepEqual(entry(m, "k1").onscreen, { title: "Card title" }); + assert.deepEqual(entry(m, "c01").onscreen, { + title: "County approves pre-application", + subtitle: "Override · 2024", + }); + assert.deepEqual(entry(m, "i1").onscreen, { subtitle: "Still" }); + // Untouched rows stay as they were. + assert.deepEqual(entry(m, "c02").onscreen, { title: "Kept" }); + // The backup of the previous state sits beside it. + assert.ok(await stat(path.join(dir, `${MANIFEST_NAME}.bak`))); + } finally { + await rm(dir, { recursive: true, force: true }); + } +}); + +test("updateOnscreen: a row replaces the whole onscreen; empty and null delete the key", async () => { + const dir = await project(); + try { + await updateOnscreen(dir, { c01: { title: "T", subtitle: "S" } }); + await updateOnscreen(dir, { c01: { title: "T2" } }); + assert.deepEqual(entry(await read(dir), "c01").onscreen, { title: "T2" }); + + await updateOnscreen(dir, { c01: { title: " ", subtitle: "" }, c02: null }); + const m = await read(dir); + assert.equal("onscreen" in entry(m, "c01"), false); + assert.equal("onscreen" in entry(m, "c02"), false); + + await updateOnscreen(dir, { k1: { title: "x" } }); + await updateOnscreen(dir, { k1: {} }); + assert.equal("onscreen" in entry(await read(dir), "k1"), false); + } finally { + await rm(dir, { recursive: true, force: true }); + } +}); + +test("updateOnscreen: an unknown id refuses the WHOLE batch and writes nothing", async () => { + const dir = await project(); + try { + const before = await readRaw(dir); + await assert.rejects( + updateOnscreen(dir, { c01: { title: "would be written" }, nope: { title: "x" } }), + /no timeline entry with id nope/, + ); + assert.equal(await readRaw(dir), before); + } finally { + await rm(dir, { recursive: true, force: true }); + } +}); + +test("updateOnscreen: a value the deck refuses fails the batch, naming the entry", async () => { + const dir = await project(); + try { + const before = await readRaw(dir); + await assert.rejects(updateOnscreen(dir, { c01: { title: "ok" }, k1: { title: "two\nlines" } }), /k1: .*one line/); + await assert.rejects(updateOnscreen(dir, { c01: { heading: "x" } }), /c01: onscreen\.heading is not/); + await assert.rejects(updateOnscreen(dir, { c01: { title: "x".repeat(201) } }), /at most 200/); + await assert.rejects(updateOnscreen(dir, {}), /nothing to change/); + await assert.rejects(updateOnscreen(dir, null), /must be an object/); + assert.equal(await readRaw(dir), before); + } finally { + await rm(dir, { recursive: true, force: true }); + } +}); + +test("updateOnscreen: a stale token is refused and writes nothing", async () => { + const dir = await project(); + try { + const before = await readRaw(dir); + await assert.rejects(updateOnscreen(dir, { c01: { title: "x" } }, { token: "1" }), (e) => { + assert.ok(e instanceof StaleToken); + assert.equal(e.expected, "1"); + return true; + }); + assert.equal(await readRaw(dir), before); + } finally { + await rm(dir, { recursive: true, force: true }); + } +}); + +test("updateOnscreen: every variant twin with the id gets the text", async () => { + const m = base(); + m.timeline.push({ type: "clip", id: "c01", variant: "full", video: "v1", start: 9, end: 22 }); + m.timeline[1].variant = "sourced"; + const dir = await project(m); + try { + await updateOnscreen(dir, { c01: { title: "Both" } }); + const out = (await read(dir)).timeline.filter((e) => e.id === "c01"); + assert.equal(out.length, 2); + for (const e of out) assert.deepEqual(e.onscreen, { title: "Both" }); + } finally { + await rm(dir, { recursive: true, force: true }); + } +}); + +test("updateClip: onscreen is normalised, and empty or null deletes the key", async () => { + const dir = await project(); + try { + const res = await updateClip(dir, "c01", { onscreen: { title: " Bench title " } }); + assert.deepEqual(res.entry.onscreen, { title: "Bench title" }); + assert.deepEqual(entry(await read(dir), "c01").onscreen, { title: "Bench title" }); + + await updateClip(dir, "c01", { onscreen: { title: "", subtitle: " " } }); + assert.equal("onscreen" in entry(await read(dir), "c01"), false); + + await updateClip(dir, "c02", { onscreen: null }); + assert.equal("onscreen" in entry(await read(dir), "c02"), false); + + const before = await readRaw(dir); + await assert.rejects(updateClip(dir, "c01", { onscreen: { title: "a\nb" } }), /one line/); + await assert.rejects(updateClip(dir, "c01", { onscreen: "a string" }), /must be an object/); + // A refused value writes nothing. + assert.equal(await readRaw(dir), before); + } finally { + await rm(dir, { recursive: true, force: true }); + } +}); + +const DECK = { engine: "hyperframes", layout: "deck", deck: { height: 180, title: { size: 60 } } }; + +test("updateChrome: round trip stores the block as given; null removes it", async () => { + const dir = await project(); + try { + const token = await manifestToken(dir); + const res = await updateChrome(dir, DECK, { token }); + assert.deepEqual(res.chrome, DECK); + assert.equal(res.token, await manifestToken(dir)); + let m = await read(dir); + assert.deepEqual(m.render.chrome, DECK); + // The rest of the render block is untouched and keeps its order. + assert.deepEqual(Object.keys(m.render), ["width", "height", "fps", "transition", "chrome"]); + + // Replacing keeps the key where it was. + await updateChrome(dir, { engine: "hyperframes", layout: "deck" }); + m = await read(dir); + assert.deepEqual(m.render.chrome, { engine: "hyperframes", layout: "deck" }); + + const off = await updateChrome(dir, null); + assert.equal(off.chrome, null); + m = await read(dir); + assert.equal("chrome" in m.render, false); + assert.equal(m.render.fps, 30); + } finally { + await rm(dir, { recursive: true, force: true }); + } +}); + +test("updateChrome: a manifest with no render block gets one", async () => { + const m = base(); + delete m.render; + const dir = await project(m); + try { + await updateChrome(dir, DECK); + assert.deepEqual((await read(dir)).render, { chrome: DECK }); + } finally { + await rm(dir, { recursive: true, force: true }); + } +}); + +test("updateChrome: validateChrome's sentences come back whole, and nothing is written", async () => { + const dir = await project(); + try { + const before = await readRaw(dir); + const refused = async (chrome, ...want) => { + await assert.rejects(updateChrome(dir, chrome), (e) => { + assert.ok(e instanceof ChromeRefused, String(e)); + for (const w of want) assert.ok(e.errors.some((s) => w.test(s)), `${w} in ${JSON.stringify(e.errors)}`); + return true; + }); + assert.equal(await readRaw(dir), before); + }; + await refused( + { engine: "hyperframes", layout: "deck", deck: { height: 50, footageScle: 0.8 } }, + /deck\.height must be a whole number/, + /deck\.footageScle is not a deck setting/, + ); + await refused({ engine: "ffmpeg", layout: "deck" }, /engine must be "hyperframes"/); + await refused({ engine: "hyperframes", layout: "deck", deck: { qr: { size: 300 } } }, /qr\.size 300 does not fit/); + await refused("deck", /must be an object/); + await assert.rejects(updateChrome(dir, undefined), /an object, or null/); + } finally { + await rm(dir, { recursive: true, force: true }); + } +}); + +test("updateChrome: refused beside a rail or the legacy chromeEngine — checked against the REST of render", async () => { + const m = base(); + m.render.rail = { kind: "x" }; + m.render.chromeEngine = "hyperframes"; + const dir = await project(m); + try { + await assert.rejects(updateChrome(dir, DECK), (e) => { + assert.ok(e instanceof ChromeRefused); + assert.ok(e.errors.some((s) => /render\.rail cannot both be set/.test(s))); + assert.ok(e.errors.some((s) => /remove chromeEngine/.test(s))); + return true; + }); + // Turning the deck OFF is never refused: it is how such a manifest is fixed. + await updateChrome(dir, null); + } finally { + await rm(dir, { recursive: true, force: true }); + } +}); + +test("updateChrome: a stale token is refused and writes nothing", async () => { + const dir = await project(); + try { + const before = await readRaw(dir); + await assert.rejects(updateChrome(dir, DECK, { token: "1" }), StaleToken); + await assert.rejects(updateChrome(dir, null, { token: "1" }), StaleToken); + assert.equal(await readRaw(dir), before); + } finally { + await rm(dir, { recursive: true, force: true }); + } +}); + +test("the writers queue: concurrent saves of different fields all land", async () => { + const dir = await project(); + try { + await Promise.all([ + updateOnscreen(dir, { k1: { title: "A" } }), + updateChrome(dir, DECK), + updateClip(dir, "c01", { onscreen: { title: "B" } }), + updateOnscreen(dir, { i1: { title: "C" } }), + ]); + const m = await read(dir); + assert.deepEqual(entry(m, "k1").onscreen, { title: "A" }); + assert.deepEqual(entry(m, "c01").onscreen, { title: "B" }); + assert.deepEqual(entry(m, "i1").onscreen, { title: "C" }); + assert.deepEqual(m.render.chrome, DECK); + } finally { + await rm(dir, { recursive: true, force: true }); + } +});