commit 663e13ff65ac23b25fb123600af2d5c89123fa4e
parent 64d316d777c8929baaa2148572ab1a1a61e1662a
Author: I Mean I'm Just Saying <imeanimjustsaying@kiwifarms.st>
Date: Wed, 30 Sep 2026 23:34:52 -0400
Merge deck/s6-e2e (deck slice S6) — onscreen.spec.ts with deck fixtures and a HyperFrames stub (mixed RGB/RGBA frames); the On-screen table's reload after a 409 keeps only real edits; timeline-row locators scoped past the new table rows; 87/87 twice; reviewed
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Diffstat:
8 files changed, 664 insertions(+), 16 deletions(-)
diff --git a/umtool/components/projects/OnscreenSection.tsx b/umtool/components/projects/OnscreenSection.tsx
@@ -421,6 +421,11 @@ export default function OnscreenSection({
const [formDirty, setFormDirty] = useState(false);
const [errors, setErrors] = useState<string[]>([]);
const [rows, setRows] = useState<Row[]>([]);
+ // The rows as last saved, for loadRows to tell an edit from an old value.
+ const rowsRef = useRef<Row[]>([]);
+ useEffect(() => {
+ rowsRef.current = rows;
+ }, [rows]);
const [maxChars, setMaxChars] = useState(48);
const [drafts, setDrafts] = useState<Record<string, Draft>>({});
const [note, setNote] = useState<string | null>(null);
@@ -470,6 +475,11 @@ export default function OnscreenSection({
/**
* The table's rows. `keep` keeps every unsaved edit on top of what is now
* saved -- the reload after a 409 must not throw away what was typed.
+ *
+ * An EDIT is a draft that differs from what this page last read as saved.
+ * A row nobody touched here takes the newly saved value: kept as its old
+ * draft it would read as an edit, and the next save would revert the other
+ * writer's change -- the very write the stale token exists to protect.
*/
const loadRows = useCallback(
async (keep: boolean) => {
@@ -479,6 +489,7 @@ export default function OnscreenSection({
setLoadError(String(j.error ?? r.status));
return;
}
+ const before = new Map(rowsRef.current.map((row) => [row.id, draftOf(row.onscreen)]));
token.current = j.token;
setRows(j.rows);
setMaxChars(j.maxChars);
@@ -487,7 +498,8 @@ export default function OnscreenSection({
for (const row of j.rows) {
const saved = draftOf(row.onscreen);
const old = prev[row.id];
- next[row.id] = keep && old ? old : saved;
+ const was = before.get(row.id);
+ next[row.id] = keep && old && was && !sameDraft(old, was) ? old : saved;
}
return next;
});
diff --git a/umtool/docs/e2e.md b/umtool/docs/e2e.md
@@ -33,6 +33,8 @@ rendered over a deliverable would be indistinguishable from a person doing it.
| `report-fixture` | read-only. 4 clips: c01 ends mid-sentence, c04 does too but sets `lockEnd`, c03's source has no punctuation |
| `bench-fixture` | the clip bench **writes** — windows, locks, attribution and corrections |
| `build-fixture` | the build **writes** |
+| `onscreen-fixture` | the On-screen section and the bench's on-screen fields **write** — 1920×1080 with the deck on, never built (the preview's estimate) |
+| `onscreen-build-fixture` | built with the deck, then re-rendered on-screen (`--chrome-only`) |
| `gone-fixture` | its source is gone — the preflight must block it |
| `no-origin-fixture` / `localhost-fixture` | the two defects that shipped |
| `bike-fixture` | the third kind |
@@ -45,8 +47,12 @@ which file playwright ran first — and the failure named the wrong thing entire
## Stubs
-`YTDLP_BIN` and `QRENCODE_BIN` point at node scripts the fixture writes, so the
-whole build chain runs **offline and deterministically**. They are node, not bash:
+`YTDLP_BIN`, `QRENCODE_BIN` and `HYPERFRAMES_BIN` point at node scripts the fixture
+writes, so the whole build chain runs **offline and deterministically**. The
+HyperFrames stub writes the exact frame sequence the build checks, alternating
+RGB and RGBA PNGs (the mix a real render produces, and the one `-reinit_filter 0`
+exists for), and logs every argv to `bin/hyperframes.invocations`. The deck's
+true still is the system chromium (`CHROME`), not a stub. They are node, not bash:
the yt-dlp stub does fractional arithmetic on `--download-sections *FROM-TO`, and
doing that in bash means awk, which means three layers of quoting inside a
generated file. It got mangled once.
diff --git a/umtool/e2e/clip-bench.spec.ts b/umtool/e2e/clip-bench.spec.ts
@@ -271,7 +271,7 @@ test("a bench save shows up on the project page", async ({ page, request }) => {
});
await page.goto(`/browse/${PROJECT}`);
- const row = page.locator("[data-entry=c01]");
+ const row = page.locator("[data-entry=c01][data-kind]");
await expect(row).toContainText("end pinned");
// lockEnd is the acknowledgement, so the row's warning goes with it.
await expect(row).toHaveAttribute("data-mid-sentence", "0");
@@ -404,9 +404,9 @@ test("`ready N of M` counts the fetched clips that still need judgement", async
await page.goto(`/browse/${WALK}`);
await expect(page.locator("[data-ready-count]")).toHaveText("ready 2 of 3 needing judgement");
// The pill is the same question the walk asks, per row.
- await expect(page.locator("[data-entry=w02]")).toHaveAttribute("data-fetched", "0");
- await expect(page.locator("[data-entry=w02]")).toContainText("not fetched yet");
- await expect(page.locator("[data-entry=w01]")).toHaveAttribute("data-fetched", "1");
+ await expect(page.locator("[data-entry=w02][data-kind]")).toHaveAttribute("data-fetched", "0");
+ await expect(page.locator("[data-entry=w02][data-kind]")).toContainText("not fetched yet");
+ await expect(page.locator("[data-entry=w01][data-kind]")).toHaveAttribute("data-fetched", "1");
// And the walk starts where the walk actually goes.
await expect(page.locator("[data-walk-start=w01]")).toHaveAttribute(
"href",
diff --git a/umtool/e2e/fixtures/make-fixture.mjs b/umtool/e2e/fixtures/make-fixture.mjs
@@ -1376,6 +1376,136 @@ writeProject(
]),
);
+// -- THE ON-SCREEN DECK -------------------------------------------------------
+//
+// A stub HYPERFRAMES_BIN, so a deck build renders offline in a second instead
+// of fetching a renderer with npx and driving a browser per frame.
+//
+// It keeps the renderer's contract, which is what the build checks: exactly
+// round(data-duration * fps) files `frame_%06d.png` (numbered from 1), at the
+// composition's own size, in --output. Every argv is logged, so a spec can
+// tell a render from a cache hit.
+//
+// The frames ALTERNATE between RGBA and RGB PNGs on purpose. A real render's
+// sequence mixes the two (a frame with nothing translucent in it is written
+// without alpha), and when it does ffmpeg rebuilds the overlay graph per
+// format change and drops frames -- the deck came out shorter than the cut.
+// `-reinit_filter 0` + `format=rgba` is the fix, and a stub of one format
+// would never exercise it.
+//
+// The review still is the system chromium's own screenshot (CHROME), not a
+// stub: it is ~1 s, and a still that only proved a file was written would say
+// nothing about the composition.
+writeFileSync(
+ path.join(BIN, "hyperframes"),
+ `#!/usr/bin/env node
+// Fixture stub for the HyperFrames renderer. Deterministic, offline.
+import { spawnSync } from "node:child_process";
+import { appendFileSync, copyFileSync, mkdirSync, readFileSync, rmSync } from "node:fs";
+import path from "node:path";
+
+const argv = process.argv.slice(2);
+appendFileSync(${JSON.stringify(path.join(BIN, "hyperframes.invocations"))}, argv.join(" ") + "\\n");
+
+if (argv.includes("--version")) {
+ process.stdout.write("0.0.0-fixture\\n");
+ process.exit(0);
+}
+const val = (n) => {
+ const i = argv.indexOf(n);
+ return i < 0 ? null : argv[i + 1];
+};
+if (argv[0] !== "render" || val("--format") !== "png-sequence") {
+ process.stderr.write("stub: only 'render --format png-sequence' is implemented\\n");
+ process.exit(2);
+}
+const fps = Number(val("--fps"));
+const out = val("--output");
+const proj = argv[argv.length - 1];
+const html = readFileSync(path.join(proj, "index.html"), "utf8");
+const num = (re, d) => {
+ const m = re.exec(html);
+ return m ? Number(m[1]) : d;
+};
+const width = num(/data-width="(\\d+)"/, 1920);
+const height = num(/data-height="(\\d+)"/, 190);
+const duration = num(/data-composition-id="[^"]*"[^>]*data-duration="([\\d.]+)"/, 0);
+if (!(fps > 0) || !out || !(duration > 0)) {
+ process.stderr.write(\`stub: fps \${fps}, output \${out}, duration \${duration}\\n\`);
+ process.exit(2);
+}
+const frames = Math.round(duration * fps);
+mkdirSync(out, { recursive: true });
+
+// Two source frames, one of each format, then copied out alternately.
+const tmp = path.join(out, ".stub");
+mkdirSync(tmp, { recursive: true });
+const make = (file, color, pixFmt) => {
+ const r = spawnSync("ffmpeg", ["-nostdin", "-v", "error", "-y",
+ "-f", "lavfi", "-i", \`color=c=\${color}:size=\${width}x\${height}\`,
+ "-frames:v", "1", "-pix_fmt", pixFmt, file], { stdio: "inherit" });
+ if (r.status !== 0) process.exit(r.status ?? 1);
+};
+const rgba = path.join(tmp, "rgba.png");
+const rgb = path.join(tmp, "rgb.png");
+make(rgba, "0x1a2030@0.8", "rgba");
+make(rgb, "0x2a3040", "rgb24");
+for (let i = 1; i <= frames; i += 1) {
+ copyFileSync(i % 2 ? rgba : rgb, path.join(out, \`frame_\${String(i).padStart(6, "0")}.png\`));
+}
+rmSync(tmp, { recursive: true, force: true });
+process.stderr.write(\`stub: \${frames} frames \${width}x\${height} -> \${out}\\n\`);
+`,
+ { mode: 0o755 },
+);
+
+// The deck as the pipeline draws it: a 1920x1080 frame, because that is what
+// the default panel fits (190 px under footage scaled to 0.82) -- at the other
+// fixtures' 640x360 the switch's `deck: {}` is refused before anything else
+// can be tested. The source windows stay 320x180; they are scaled into the
+// footage box like any other.
+//
+// The deck REFUSES to draw without its two faces (they are copied in beside
+// the composition, never left to local()), so without a font on this machine
+// there is no deck to test and onscreen.spec.ts skips, naming why.
+const deckManifest = (slug, title, timeline) => {
+ const m = manifest(slug, title, { siteOrigin: "https://archive.example" }, timeline);
+ m.render = {
+ ...m.render,
+ width: 1920,
+ height: 1080,
+ chrome: { engine: "hyperframes", layout: "deck", deck: {} },
+ };
+ delete m.render.headerHeight;
+ return m;
+};
+
+// onscreen-fixture: the On-screen section and the bench's fields WRITE here --
+// the switch, the settings, the table, a stale token. Never built: its
+// schedule is the estimate, which is the state a report is in when titles are
+// first written. A card, because a row is any entry and not only a clip.
+const ONSCREEN = writeProject(
+ "onscreen-fixture",
+ deckManifest("onscreen-fixture", "The On-screen Fixture", [
+ { type: "clip", id: "c01", video: "vid1", start: 3.0, end: 6.0, cite: 3, section: 0, lock: true, quote: "and because" },
+ { type: "clip", id: "c02", video: "vid1", start: 9.0, end: 12.0, cite: 9, section: 0, lock: true, quote: "another whole sentence" },
+ { type: "card", id: "k01", style: "chapter", seconds: 3, heading: "A card" },
+ ]),
+);
+
+// onscreen-build-fixture: BUILT by the spec, then re-rendered on-screen over
+// the segments that build left. Its own project because a build stamps out/
+// and the section specs above must not depend on whether it ran first. Clips
+// only: a card segment needs ImageMagick with Pango, which the build specs
+// keep out of the fixture.
+const ONSCREEN_BUILD = writeProject(
+ "onscreen-build-fixture",
+ deckManifest("onscreen-build-fixture", "The On-screen Build Fixture", [
+ { type: "clip", id: "c01", video: "vid1", start: 3.0, end: 6.0, cite: 3, section: 0, lock: true, quote: "and because" },
+ { type: "clip", id: "c02", video: "vid1", start: 9.0, end: 12.0, cite: 9, section: 0, lock: true, quote: "another whole sentence" },
+ ]),
+);
+
mkdirSync(path.join(reports, "bike-fixture"), { recursive: true });
writeFileSync(
path.join(reports, "bike-fixture", "sweep-report.md"),
@@ -1415,7 +1545,7 @@ ff([
// intermediates and are excluded by name.
mkdirSync(path.join(reports, "no-origin-fixture", "out"), { recursive: true });
-for (const dir of [BENCH, BUILD]) {
+for (const dir of [BENCH, BUILD, ONSCREEN, ONSCREEN_BUILD]) {
mkdirSync(path.join(dir, "out", "clips-raw"), { recursive: true });
copyFileSync(
path.join(REPORT, "out", "clips-raw", "vid1_0.00-9.00.mp4"),
@@ -1526,7 +1656,7 @@ console.log(` flagged source: ${flagged ? flagged.video : "none — no asr/"}`)
console.log(` SONG_CODE_DIR=${path.join(dest, "code")}`);
console.log(` SONG_DIR=${path.join(dest, "data")}`);
console.log(` SONG_REPORTS_DIR=${reports}`);
-console.log(` YTDLP_BIN=${path.join(BIN, "yt-dlp")} QRENCODE_BIN=${path.join(BIN, "qrencode")}`);
+console.log(` YTDLP_BIN=${path.join(BIN, "yt-dlp")} QRENCODE_BIN=${path.join(BIN, "qrencode")} HYPERFRAMES_BIN=${path.join(BIN, "hyperframes")}`);
console.log(` CHANNELS_DIR=${CHANNELS} (testchan/vid1 punctuated, vid2 not; vid3/vid4/vid5 for the editor fetch)`);
console.log(` projects: report-fixture (4 clips, 1 mid-sentence), no-origin-fixture,`);
console.log(` localhost-fixture, bike-fixture (sweep), find/ (shadowed),`);
@@ -1535,5 +1665,6 @@ console.log(` walk-fixture (read-only: w01/w04 walkable, w02 unfetche
console.log(` editor-fetch-{,many-,reuse-}fixture (nothing cached — the editor fetch's subjects),`);
console.log(` longform-fixture (cue gap, legacy .bak, ffmeta), longform-edit-fixture, dash-fixture`);
console.log(` deliver-fixture (writable: a01/a02 to cut, a03 unfetched, b01 shared, b02 incorrect, b03 unjudged)`);
+console.log(` onscreen-fixture (writable, deck on, unbuilt), onscreen-build-fixture (built with the deck)`);
console.log(` deliver-stop-fixture (writable: six confirmed clips to cut, for Stop and resume)`);
console.log(` ${taken} candidate files copied, 2 mix tracks synthesised`);
diff --git a/umtool/e2e/onscreen.spec.ts b/umtool/e2e/onscreen.spec.ts
@@ -0,0 +1,489 @@
+import { test, expect, type APIRequestContext, type Locator, type Page } from "@playwright/test";
+import { execFileSync } from "node:child_process";
+import { existsSync, readdirSync, readFileSync, statSync } from "node:fs";
+import path from "node:path";
+import { fileURLToPath } from "node:url";
+
+// ---------------------------------------------------------------------------
+// ON-SCREEN: the report cut's deck (render.chrome), as umtool edits it.
+//
+// NOT deck.spec.ts -- that is the song kind's deck, an unrelated thing that
+// owns the word in this app; see the header of OnscreenSection.tsx.
+//
+// Two projects, both made by make-fixture.mjs at 1920x1080 with the deck on:
+//
+// onscreen-fixture the section and the bench WRITE here. Never built,
+// so its preview is drawn from the estimate.
+// onscreen-build-fixture built by the last test (offline, ~10 s), then
+// re-rendered on-screen over its own segments.
+//
+// The renderer is a stub (HYPERFRAMES_BIN, see make-fixture.mjs): it writes the
+// frame sequence the build checks, alternating RGB and RGBA. The true still is
+// the system chromium's own screenshot, not a stub.
+//
+// The report fixtures need none of the song project's bulk data, so nothing
+// here skips on SONG_DIR. What the deck DOES need is a font (it refuses to draw
+// without its faces) and, for the still, chromium -- each test that needs one
+// says so and skips when this machine has none.
+// ---------------------------------------------------------------------------
+
+const HERE = path.dirname(fileURLToPath(import.meta.url));
+const FIXTURE = path.join(HERE, "..", ".e2e-song");
+const PROJECT = "reports/onscreen-fixture";
+const DIR = path.join(FIXTURE, "reports", "onscreen-fixture");
+const BUILD_PROJECT = "reports/onscreen-build-fixture";
+const BUILD_DIR = path.join(FIXTURE, "reports", "onscreen-build-fixture");
+const INVOCATIONS = path.join(FIXTURE, "bin", "hyperframes.invocations");
+// The still route's browser, as compose-chrome resolves it. The server inherits
+// this process's environment, so the two agree.
+const CHROME = process.env.CHROME ?? "/usr/bin/chromium";
+
+const DECK_ON = { engine: "hyperframes", layout: "deck", deck: {} };
+
+type Entry = { id: string; type: string; onscreen?: { title?: string; subtitle?: string } };
+type Manifest = { render: Record<string, unknown> & { chrome?: unknown }; timeline: Entry[] };
+
+const readManifest = (dir = DIR): Manifest =>
+ JSON.parse(readFileSync(path.join(dir, "video.manifest.json"), "utf8"));
+const entry = (id: string, dir = DIR) => readManifest(dir).timeline.find((e) => e.id === id)!;
+const enc = encodeURIComponent;
+
+test.beforeEach(() => {
+ // The first page of a run compiles in dev mode (~20 s under load), and the
+ // section composes and still-shoots on top of that.
+ test.setTimeout(120_000);
+ test.skip(
+ !readManifest().render.fontRegular,
+ "the deck refuses to draw without render.fontRegular/fontBold, and make-fixture.mjs found no font on this machine",
+ );
+});
+
+// ---- state through the routes, so each test starts from a known manifest ----
+
+async function chromeToken(request: APIRequestContext, project: string): Promise<string> {
+ const j = (await (await request.get(`/api/report/chrome?project=${enc(project)}`)).json()) as { token: string };
+ return j.token;
+}
+
+async function putChrome(request: APIRequestContext, project: string, chrome: unknown) {
+ const r = await request.put("/api/report/chrome", {
+ data: { project, chrome, token: await chromeToken(request, project) },
+ });
+ expect(r.ok(), await r.text()).toBeTruthy();
+}
+
+async function putOnscreen(
+ request: APIRequestContext,
+ project: string,
+ onscreen: Record<string, { title?: string; subtitle?: string } | null>,
+) {
+ const r = await request.put("/api/report/onscreen", {
+ data: { project, onscreen, token: await chromeToken(request, project) },
+ });
+ expect(r.ok(), await r.text()).toBeTruthy();
+}
+
+/** The section, read and settled: the manifest loaded and the table filled. */
+async function openSection(page: Page, project = PROJECT) {
+ await page.goto(`/browse/${project}`);
+ const section = page.getByTestId("onscreen-section");
+ await expect(section).toHaveAttribute("data-onscreen", /on|off/);
+ await expect(page.getByTestId("onscreen-row").first()).toBeAttached();
+ return section;
+}
+
+const row = (page: Page, id: string) => page.locator(`[data-testid="onscreen-row"][data-entry="${id}"]`);
+
+/**
+ * fill() on a controlled input right after a goto can lose its first
+ * keystroke to a hydration re-render (docs/e2e.md, "Gotchas"), so assert the
+ * value and retry before anything commits on it.
+ */
+async function fillSure(input: Locator, value: string) {
+ await expect(async () => {
+ await input.fill(value);
+ await expect(input).toHaveValue(value, { timeout: 1000 });
+ }).toPass({ timeout: 15_000 });
+}
+
+// ---------------------------------------------------------------------------
+
+test("the switch writes render.chrome, and turning it off takes the key out", async ({ page, request }) => {
+ await putChrome(request, PROJECT, DECK_ON);
+ const section = await openSection(page);
+ const toggle = page.getByTestId("onscreen-toggle");
+ await expect(section).toHaveAttribute("data-onscreen", "on");
+ await expect(toggle).toBeChecked();
+ await expect(toggle).toBeEnabled();
+
+ await toggle.click();
+ await expect(section).toHaveAttribute("data-onscreen", "off");
+ // Off is the key REMOVED, not set to null: every manifest without it builds
+ // as before, and that is a promise about the absence of the key.
+ await expect.poll(() => "chrome" in readManifest().render).toBe(false);
+ // Nothing else in render moved.
+ expect(readManifest().render.width).toBe(1920);
+
+ await page.reload();
+ await expect(section).toHaveAttribute("data-onscreen", "off");
+ await expect(toggle).not.toBeChecked();
+
+ await expect(toggle).toBeEnabled();
+ await toggle.click();
+ await expect(section).toHaveAttribute("data-onscreen", "on");
+ await expect.poll(() => readManifest().render.chrome).toEqual(DECK_ON);
+
+ await page.reload();
+ await expect(section).toHaveAttribute("data-onscreen", "on");
+ await expect(toggle).toBeChecked();
+});
+
+test("settings save only what differs from the defaults; a refused value shows the validator's sentence", async ({
+ page,
+ request,
+}) => {
+ await putChrome(request, PROJECT, DECK_ON);
+ await openSection(page);
+ const defaults = (await (await request.get(`/api/report/chrome?project=${enc(PROJECT)}`)).json()) as {
+ defaults: { title: { maxChars: number } };
+ };
+
+ const maxChars = page.getByTestId("onscreen-setting-title.maxChars");
+ await expect(maxChars).toHaveValue(String(defaults.defaults.title.maxChars));
+ const want = defaults.defaults.title.maxChars === 20 ? 21 : 20;
+ await fillSure(maxChars, String(want));
+ await page.getByTestId("onscreen-settings-save").click();
+
+ await expect.poll(() => (readManifest().render.chrome as { deck: unknown }).deck).toEqual({
+ title: { maxChars: want },
+ });
+ // The table's counter follows the saved setting.
+ await expect(page.getByTestId("onscreen-count").first()).toContainText(`/${want}`);
+
+ // Refused: the build's own sentence, beside the form, and nothing written.
+ await fillSure(page.getByTestId("onscreen-setting-height"), "999");
+ await page.getByTestId("onscreen-settings-save").click();
+ await expect(page.getByTestId("onscreen-errors")).toContainText(
+ "render.chrome.deck.height must be a whole number of pixels from 120 to 400",
+ );
+ expect((readManifest().render.chrome as { deck: unknown }).deck).toEqual({ title: { maxChars: want } });
+
+ await putChrome(request, PROJECT, DECK_ON);
+});
+
+test("a table edit saves onscreen; an emptied row deletes the key", async ({ page, request }) => {
+ await putChrome(request, PROJECT, DECK_ON);
+ await putOnscreen(request, PROJECT, { c01: null, c02: null, k01: null });
+ await openSection(page);
+
+ const title = row(page, "c01").getByTestId("onscreen-title");
+ await fillSure(title, "County approves the pre-application");
+ await expect(row(page, "c01")).toHaveAttribute("data-dirty", "1");
+ // Any entry, not only a clip -- and the writer trims.
+ await fillSure(row(page, "k01").getByTestId("onscreen-subtitle"), " a card's own line ");
+
+ await page.getByTestId("onscreen-save").click();
+ await expect.poll(() => entry("c01").onscreen).toEqual({ title: "County approves the pre-application" });
+ expect(entry("k01").onscreen).toEqual({ subtitle: "a card's own line" });
+ await expect(row(page, "c01")).toHaveAttribute("data-dirty", "0");
+ await expect(row(page, "k01").getByTestId("onscreen-subtitle")).toHaveValue("a card's own line");
+
+ // The counter warns past maxChars; it does not refuse.
+ const limit = ((await (await request.get(`/api/report/onscreen?project=${enc(PROJECT)}`)).json()) as {
+ maxChars: number;
+ }).maxChars;
+ await fillSure(title, "x".repeat(limit + 1));
+ await expect(row(page, "c01").getByTestId("onscreen-count")).toHaveAttribute("data-over", "1");
+
+ // Emptied: the key goes, it is not left as {} or {title: ""}.
+ await fillSure(title, "");
+ await expect(row(page, "c01")).toHaveAttribute("data-dirty", "1");
+ await page.getByTestId("onscreen-save").click();
+ await expect.poll(() => "onscreen" in entry("c01")).toBe(false);
+ await expect(row(page, "c01")).toHaveAttribute("data-dirty", "0");
+ expect(entry("k01").onscreen).toEqual({ subtitle: "a card's own line" });
+
+ await putOnscreen(request, PROJECT, { k01: null });
+});
+
+test("a stale token is a 409; reloading keeps the edit and does not undo the other writer", async ({
+ page,
+ request,
+}) => {
+ await putChrome(request, PROJECT, DECK_ON);
+ await putOnscreen(request, PROJECT, { c01: null, c02: null });
+ await openSection(page);
+
+ // Somebody else writes after this page read its token.
+ await putOnscreen(request, PROJECT, { c02: { title: "Written elsewhere" } });
+
+ await fillSure(row(page, "c01").getByTestId("onscreen-title"), "Mine");
+ const refused = page.waitForResponse(
+ (r) => r.url().includes("/api/report/onscreen") && r.request().method() === "PUT",
+ );
+ await page.getByTestId("onscreen-save").click();
+ expect((await refused).status()).toBe(409);
+ await expect(page.getByTestId("onscreen-note")).toContainText("the manifest changed since you opened this");
+ expect("onscreen" in entry("c01")).toBe(false);
+ expect(entry("c02").onscreen).toEqual({ title: "Written elsewhere" });
+
+ await page.getByTestId("onscreen-reload").click();
+ await expect(page.getByTestId("onscreen-reload")).toHaveCount(0);
+ // The unsaved edit survives the reload...
+ await expect(row(page, "c01").getByTestId("onscreen-title")).toHaveValue("Mine");
+ await expect(row(page, "c01")).toHaveAttribute("data-dirty", "1");
+ // ...and a row nobody touched here shows what the other writer saved. Kept
+ // as the old draft, it would read as an edit and the next save would quietly
+ // revert the very write the token exists to protect.
+ await expect(row(page, "c02").getByTestId("onscreen-title")).toHaveValue("Written elsewhere");
+ await expect(row(page, "c02")).toHaveAttribute("data-dirty", "0");
+
+ const saved = page.waitForResponse(
+ (r) => r.url().includes("/api/report/onscreen") && r.request().method() === "PUT",
+ );
+ await page.getByTestId("onscreen-save").click();
+ expect((await saved).status()).toBe(200);
+ await expect.poll(() => entry("c01").onscreen).toEqual({ title: "Mine" });
+ expect(entry("c02").onscreen).toEqual({ title: "Written elsewhere" });
+
+ await putOnscreen(request, PROJECT, { c01: null, c02: null });
+});
+
+test("the preview is the composition: it reports ready, and typing reaches it before a save", async ({
+ page,
+ request,
+}) => {
+ await putChrome(request, PROJECT, DECK_ON);
+ await putOnscreen(request, PROJECT, { c01: null, c02: null, k01: null });
+ await openSection(page);
+
+ await expect(page.getByTestId("onscreen-preview")).toHaveAttribute("data-deck-ready", "1", { timeout: 30_000 });
+ // Never built, so the clock is the manifest's estimate, and says so.
+ await expect(page.getByTestId("onscreen-estimated")).toBeVisible();
+ const frame = page.frameLocator('[data-testid="onscreen-preview-iframe"]');
+
+ // One node per entry, and a card's title is its heading -- the same text the
+ // table offers as the placeholder.
+ for (const id of ["c01", "c02", "k01"]) await expect(frame.locator(`[data-seg="${id}"]`)).toHaveCount(1);
+ const cardAuto = await row(page, "k01").getByTestId("onscreen-title").getAttribute("placeholder");
+ expect(cardAuto).toBe("A card");
+ await expect(frame.locator('[data-seg="k01"] .deck-title')).toHaveText(cardAuto!);
+
+ // Typing is patched into the frame by postMessage: nothing is saved.
+ await fillSure(row(page, "c02").getByTestId("onscreen-title"), "Typed, not saved");
+ await expect(frame.locator('[data-seg="c02"] .deck-title')).toHaveText("Typed, not saved");
+ await fillSure(row(page, "c02").getByTestId("onscreen-subtitle"), "a line under it");
+ await expect(frame.locator('[data-seg="c02"] .deck-sub')).toHaveText("a line under it");
+ // Focusing a row moves the scrubber to that entry.
+ await expect(page.getByTestId("onscreen-current")).toHaveText("c02");
+ expect("onscreen" in entry("c02")).toBe(false);
+
+ // The scrubber is the composition's clock: its end is the schedule's total.
+ const max = Number(await page.getByTestId("onscreen-scrubber").getAttribute("max"));
+ const pv = (await (
+ await request.post("/api/report/chrome/preview", { data: { project: PROJECT } })
+ ).json()) as { schedule: { total: number } };
+ expect(max).toBeCloseTo(pv.schedule.total, 3);
+});
+
+test("a true still is the render browser's PNG of the deck region", async ({ page, request }) => {
+ test.skip(!existsSync(CHROME), `the true still needs chromium at ${CHROME} (set CHROME)`);
+ await putChrome(request, PROJECT, DECK_ON);
+
+ // The route: a PNG exactly the deck region's size, at the entry's middle.
+ const pv = (await (
+ await request.post("/api/report/chrome/preview", { data: { project: PROJECT } })
+ ).json()) as {
+ geometry: { deck: { width: number; height: number } };
+ schedule: { segments: { id: string; start: number; duration: number }[] };
+ };
+ const r = await request.get(`/api/report/still?project=${enc(PROJECT)}&clip=c01`);
+ expect(r.status(), await r.text()).toBe(200);
+ expect(r.headers()["content-type"]).toBe("image/png");
+ const png = await r.body();
+ expect(png.subarray(1, 4).toString("latin1")).toBe("PNG");
+ expect(png.readUInt32BE(16)).toBe(pv.geometry.deck.width);
+ expect(png.readUInt32BE(20)).toBe(pv.geometry.deck.height);
+ const c01 = pv.schedule.segments.find((s) => s.id === "c01")!;
+ expect(Number(r.headers()["x-still-at"])).toBeCloseTo(c01.start + c01.duration / 2, 2);
+
+ // And the button shows it.
+ await openSection(page);
+ await expect(page.getByTestId("onscreen-preview")).toHaveAttribute("data-deck-ready", "1", { timeout: 30_000 });
+ await page.getByTestId("onscreen-true-still").click();
+ const img = page.getByTestId("onscreen-still");
+ await expect(img).toBeVisible({ timeout: 30_000 });
+ await expect
+ .poll(() => img.evaluate((el) => (el as HTMLImageElement).naturalWidth))
+ .toBe(pv.geometry.deck.width);
+});
+
+test("the clip bench's on-screen fields preview live and save through the window route", async ({
+ page,
+ request,
+}) => {
+ await putChrome(request, PROJECT, DECK_ON);
+ await putOnscreen(request, PROJECT, { c01: null });
+ await page.goto(`/browse/${PROJECT}/clip/c01`);
+
+ await expect(page.getByTestId("bench-onscreen")).toHaveAttribute("data-deck-on", "1");
+ await expect(page.getByTestId("bench-deck-strip")).toHaveAttribute("data-deck-ready", "1", { timeout: 30_000 });
+ const strip = page.frameLocator('[data-testid="bench-deck-strip-iframe"]');
+
+ const title = page.getByTestId("bench-onscreen-title");
+ await fillSure(title, "From the bench");
+ await expect(strip.locator('[data-seg="c01"] .deck-title')).toHaveText("From the bench");
+ expect("onscreen" in entry("c01")).toBe(false);
+
+ const put = page.waitForRequest((r) => r.url().includes("/api/report/window") && r.method() === "PUT");
+ await title.press("Enter");
+ expect((await put).postDataJSON().onscreen).toEqual({ title: "From the bench" });
+ await expect.poll(() => entry("c01").onscreen).toEqual({ title: "From the bench" });
+
+ // Emptied, the key goes.
+ await fillSure(title, "");
+ const cleared = page.waitForRequest((r) => r.url().includes("/api/report/window") && r.method() === "PUT");
+ await title.press("Enter");
+ expect((await cleared).postDataJSON().onscreen).toBeNull();
+ await expect.poll(() => "onscreen" in entry("c01")).toBe(false);
+});
+
+// ---- built: the deck rendered by a build, then re-rendered on its own --------
+
+type Job = {
+ id: string;
+ state: string;
+ error: string | null;
+ steps: { label: string; argv: string[] }[];
+ events: { ev: string; phase?: string }[];
+};
+
+async function waitForJob(request: APIRequestContext, id: string, ms = 180_000): Promise<Job> {
+ const until = Date.now() + ms;
+ while (Date.now() < until) {
+ const j = (await (await request.get(`/api/report/build?job=${id}`)).json()) as Job;
+ if (j.state !== "running") return j;
+ await new Promise((r) => setTimeout(r, 400));
+ }
+ throw new Error("the job never finished");
+}
+
+const renders = () =>
+ existsSync(INVOCATIONS)
+ ? readFileSync(INVOCATIONS, "utf8")
+ .split("\n")
+ .filter((l) => l.includes("onscreen-build-fixture"))
+ : [];
+const VARIANT_OUT = path.join(BUILD_DIR, "out", "sourced");
+const FRAMES = path.join(VARIANT_OUT, "chrome", "deck-frames");
+const FINAL = path.join(BUILD_DIR, "out", "onscreen-build-fixture.mp4");
+const readSchedule = () =>
+ JSON.parse(readFileSync(path.join(VARIANT_OUT, "schedule.json"), "utf8")) as {
+ kind: string;
+ estimated: boolean;
+ fps: number;
+ transition: number;
+ total: number;
+ segments: { id: string; title: string }[];
+ };
+const frameFiles = () => readdirSync(FRAMES).filter((f) => /^frame_\d{6}\.png$/.test(f));
+const probeSeconds = (file: string) =>
+ Number(
+ execFileSync("ffprobe", ["-v", "error", "-show_entries", "format=duration", "-of", "csv=p=0", file])
+ .toString()
+ .trim(),
+ );
+
+test("re-render on-screen runs --chrome-only over a built cut, to done, rebuilding no segment", async ({
+ page,
+ request,
+}) => {
+ test.setTimeout(300_000);
+
+ // The chain it asks for: no preflight, no resolve, --chrome-only, verified.
+ const dry = (await (
+ await request.post("/api/report/build?dry=1", {
+ data: { project: BUILD_PROJECT, preset: "final", options: { chromeOnly: true } },
+ })
+ ).json()) as { steps: { label: string; argv: string[] }[] };
+ const chain = dry.steps.map((s) => s.argv.join(" ")).join("\n");
+ expect(chain).not.toContain("check-availability.mjs");
+ expect(chain).not.toContain("resolve-windows.mjs");
+ const dryBuild = dry.steps.find((s) => s.argv.join(" ").includes("build-video.mjs"))!;
+ expect(dryBuild.argv).toContain("--chrome-only");
+ expect(dryBuild.label).toBe("re-render on-screen");
+ expect(dry.steps.at(-1)!.argv.join(" ")).toContain("verify-build.mjs");
+
+ // A full build first, as hard cuts: the deck is composed, rendered by the
+ // stub and laid over the concat in one command.
+ const before = renders().length;
+ const start = await request.post("/api/report/build?replace=1", {
+ data: { project: BUILD_PROJECT, preset: "fast" },
+ });
+ expect(start.ok(), await start.text()).toBeTruthy();
+ const built = await waitForJob(request, ((await start.json()) as { job: Job }).job.id);
+ expect(built.state, built.error ?? "").toBe("done");
+ expect(renders().length).toBe(before + 1);
+ const argv = renders().at(-1)!;
+ expect(argv).toContain("render --format png-sequence");
+ expect(argv).toContain("--fps 15");
+ expect(argv).toContain("--no-browser-gpu");
+ expect(argv).toContain(`--output ${FRAMES}`);
+ expect(built.events.filter((e) => e.ev === "chrome").map((e) => e.phase)).toEqual(
+ expect.arrayContaining(["schedule", "compose", "render", "overlay"]),
+ );
+
+ const first = readSchedule();
+ expect(first.kind).toBe("deck");
+ expect(first.estimated).toBe(false);
+ expect(first.transition).toBe(0);
+ expect(frameFiles().length).toBe(Math.round(first.total * first.fps));
+ // The stub's frames alternate RGB and RGBA. Without `-reinit_filter 0` the
+ // overlay drops frames at every format change and the cut comes out short;
+ // the build asserts the length, and so does this.
+ expect(Math.abs(probeSeconds(FINAL) - first.total)).toBeLessThan(2 / first.fps);
+
+ // A saved title moves the deck's cache key, so the re-render must render.
+ await putOnscreen(request, BUILD_PROJECT, { c02: { title: "Re-rendered title" } });
+ const segs = ["c01", "c02"].map((id) => path.join(VARIANT_OUT, "segments", `${id}.mp4`));
+ const segMtimes = segs.map((s) => statSync(s).mtimeMs);
+ const finalMtime = statSync(FINAL).mtimeMs;
+
+ await openSection(page, BUILD_PROJECT);
+ await expect(page.getByTestId("onscreen-final-video")).toBeVisible();
+ // As final: the crossfaded concat, so the overlay rides the xfade chain --
+ // the other half of the -reinit_filter fix from the hard cut above.
+ await page.getByTestId("onscreen-rerender-preset").selectOption("final");
+ const posted = page.waitForResponse(
+ (r) => r.url().endsWith("/api/report/build") && r.request().method() === "POST",
+ );
+ await page.getByTestId("onscreen-rerender").click();
+ const res = await posted;
+ expect(res.request().postDataJSON().options).toMatchObject({ chromeOnly: true });
+ const { job } = (await res.json()) as { job: Job };
+ const realBuild = job.steps.find((s) => s.argv.join(" ").includes("build-video.mjs"))!;
+ expect(realBuild.argv).toContain("--chrome-only");
+ expect(job.steps.map((s) => s.argv.join(" ")).join("\n")).not.toContain("check-availability.mjs");
+
+ await expect(page.getByTestId("onscreen-job")).toHaveAttribute("data-job-state", /done|failed/, {
+ timeout: 240_000,
+ });
+ const done = await waitForJob(request, job.id);
+ expect(done.state, `${done.error ?? ""}\n${(done as unknown as { log: string[] }).log?.slice(-20).join("\n")}`).toBe(
+ "done",
+ );
+ await expect(page.getByTestId("onscreen-job")).toHaveAttribute("data-job-state", "done");
+
+ // It rendered again, and over the SAME segments.
+ expect(renders().length).toBe(before + 2);
+ expect(segs.map((s) => statSync(s).mtimeMs)).toEqual(segMtimes);
+ expect(statSync(FINAL).mtimeMs).toBeGreaterThan(finalMtime);
+
+ const second = readSchedule();
+ expect(second.transition).toBeGreaterThan(0);
+ expect(second.total).toBeLessThan(first.total);
+ expect(second.segments.find((s) => s.id === "c02")!.title).toBe("Re-rendered title");
+ expect(frameFiles().length).toBe(Math.round(second.total * second.fps));
+ expect(Math.abs(probeSeconds(FINAL) - second.total)).toBeLessThan(2 / second.fps);
+ await expect(page.getByTestId("onscreen-final-video")).toBeVisible();
+});
diff --git a/umtool/e2e/projects.spec.ts b/umtool/e2e/projects.spec.ts
@@ -490,14 +490,19 @@ test("a timeline entry of an unknown type renders, rather than crashing the page
const res = await page.goto("/browse/reports/report-fixture");
expect(res?.status()).toBe(200);
- await expect(page.locator("[data-entry=z01]")).toHaveAttribute("data-kind", "zz-unknown");
- await expect(page.locator("[data-entry=z01]")).toContainText("An entry type from the future");
- await expect(page.locator("[data-entry=k01]")).toHaveAttribute("data-kind", "card");
+ // The TIMELINE's entries, which carry a kind. The On-screen section's table
+ // marks its rows with data-entry too (one per entry, any type), so an
+ // unscoped [data-entry] counts every entry twice.
+ const entry = (id?: string) => page.locator(id ? `[data-entry=${id}][data-kind]` : "[data-entry][data-kind]");
+
+ await expect(entry("z01")).toHaveAttribute("data-kind", "zz-unknown");
+ await expect(entry("z01")).toContainText("An entry type from the future");
+ await expect(entry("k01")).toHaveAttribute("data-kind", "card");
// And it is COUNTED, not silently dropped: a card saying "4 clips · 1 card"
// about a 6-entry timeline would be lying by omission.
- await expect(page.locator("[data-entry]")).toHaveCount(6);
- await expect(page.locator("[data-entry=c01]")).toHaveAttribute("data-kind", "clip");
+ await expect(entry()).toHaveCount(6);
+ await expect(entry("c01")).toHaveAttribute("data-kind", "clip");
});
// The brand preset (report-to-video/brand.mjs, `render.brand`) is offered where
diff --git a/umtool/e2e/report-fetch-via-editor.spec.ts b/umtool/e2e/report-fetch-via-editor.spec.ts
@@ -130,7 +130,7 @@ test("the fetch route asks the editor, carries the provenance, and runs no yt-dl
// The page reads the corpus window as cached, so the walk stops skipping it.
await page.goto(`/browse/${PROJECT}`);
- await expect(page.locator('[data-entry="e01"]')).toHaveAttribute(
+ await expect(page.locator('[data-entry="e01"][data-kind]')).toHaveAttribute(
"data-fetched",
"1",
);
@@ -181,7 +181,7 @@ test("the project page fetches the unfetched clips one at a time, and Stop halts
// bytes in the corpus instead of in one project's out/.
await page.reload();
await expect(page.locator("[data-ready-count]")).toContainText("ready 2 of 2");
- await expect(page.locator('[data-entry="f02"]')).toHaveAttribute(
+ await expect(page.locator('[data-entry="f02"][data-kind]')).toHaveAttribute(
"data-fetched",
"1",
);
diff --git a/umtool/playwright.config.ts b/umtool/playwright.config.ts
@@ -79,6 +79,11 @@ export default defineConfig({
// Stub binaries, so a build spec is offline and deterministic. The
// pipeline already reads both as overrides; the fixture writes them.
`YTDLP_BIN=${FIXTURE}/bin/yt-dlp QRENCODE_BIN=${FIXTURE}/bin/qrencode ` +
+ // The on-screen deck's renderer, stubbed the same way: it writes the
+ // frame sequence the build checks for, so a deck build is offline and
+ // takes a second (onscreen.spec.ts). Its review stills use the system
+ // chromium (CHROME, default /usr/bin/chromium), which is not stubbed.
+ `HYPERFRAMES_BIN=${FIXTURE}/bin/hyperframes ` +
// Where a clip window is fetched FROM, and the shared secret it is asked
// with. Set here rather than in a step's env: jobView() echoes a step's
// env back to the browser, and this is a token.