Archilyzer · Source

archilyzer

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

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:
Mumtool/components/projects/OnscreenSection.tsx | 14+++++++++++++-
Mumtool/docs/e2e.md | 10++++++++--
Mumtool/e2e/clip-bench.spec.ts | 8++++----
Mumtool/e2e/fixtures/make-fixture.mjs | 135+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++--
Aumtool/e2e/onscreen.spec.ts | 489+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mumtool/e2e/projects.spec.ts | 15++++++++++-----
Mumtool/e2e/report-fetch-via-editor.spec.ts | 4++--
Mumtool/playwright.config.ts | 5+++++
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.