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:
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 });
+ }
+});