Archilyzer · Source

archilyzer

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

commit 803bdb283911bbd617593cf29ec1f04f9af244dc
parent 054fb5b404a6a425113f0352e0e17b54e9ade060
Author: I Mean I'm Just Saying <imeanimjustsaying@kiwifarms.st>
Date:   Thu,  8 Oct 2026 22:52:03 -0400

umtool: e2e for timed notes, edit notes and timeline edits

video-notes-fixture (generated, built, schedule, take alt) and timeline-fixture
(teaser, three clips, a post); video-notes.spec.ts and timeline-edit.spec.ts.
A deliverable's moment resolves against its own variant's schedule.

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

Diffstat:
Mumtool/e2e/fixtures/make-fixture.mjs | 61+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Aumtool/e2e/timeline-edit.spec.ts | 136+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Aumtool/e2e/video-notes.spec.ts | 151++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mumtool/lib/report/moments.mjs | 6+++++-
4 files changed, 353 insertions(+), 1 deletion(-)

diff --git a/umtool/e2e/fixtures/make-fixture.mjs b/umtool/e2e/fixtures/make-fixture.mjs @@ -1815,10 +1815,71 @@ take("intro-a", { group: "opening", order: 5, label: "Cold open", kind: "similar take("bad-kind", { group: "finale", order: 4, label: "Bad", kind: "maybe" }); mkdirSync(path.join(TAKES, "takes", "current", "out"), { recursive: true }); +// -- THE VIDEO-NOTES AND TIMELINE FIXTURES ------------------------------------ +// +// video-notes-fixture is a GENERATED manifest (`generatedBy`) with a built cut +// and its schedule, and a take with its own: timed notes resolve against them, +// and every edit made to it leaves an `edit` note (video-notes.spec.ts). +// timeline-fixture is hand-written, with a teaser, three clips, a post riding +// on the second and the deck on: the structural edits' subject +// (timeline-edit.spec.ts). Both are written by specs; nothing else reads them. +const twoSeconds = (file, hz) => + ff([ + "-f", "lavfi", "-i", "testsrc=size=320x180:rate=15:duration=2", + "-f", "lavfi", "-i", `sine=frequency=${hz}:duration=2`, + "-c:v", "libx264", "-pix_fmt", "yuv420p", "-c:a", "aac", "-shortest", + "-movflags", "+faststart", + file, + ]); +const NOTES_SCHEDULE = { + version: 1, + kind: "deck", + estimated: false, + fps: 15, + transition: 0, + total: 2, + segments: [ + { id: "k1", type: "card", start: 0, duration: 0.5, end: 0.5, title: "Opening" }, + { id: "n01", type: "clip", start: 0.5, duration: 0.7, end: 1.2, title: "The first claim" }, + { id: "n02", type: "clip", start: 1.2, duration: 0.8, end: 2, title: "The second claim" }, + ], +}; +const VNOTES = writeProject("video-notes-fixture", { + ...manifest("video-notes-fixture", "The Video Notes Fixture", { siteOrigin: "https://archive.example" }, [ + { type: "card", id: "k1", heading: "Opening" }, + { type: "clip", id: "n01", video: "vid1", start: 0, end: 3, cite: 0, section: 0, lock: true, quote: "This is a complete sentence." }, + { type: "clip", id: "n02", video: "vid1", start: 9, end: 12, cite: 9, section: 0, lock: true, quote: "Another whole sentence entirely." }, + ]), + generatedBy: "polemics/video/make-videos.py", +}); +mkdirSync(path.join(VNOTES, "out", "sourced"), { recursive: true }); +twoSeconds(path.join(VNOTES, "out", "video-notes-fixture.mp4"), 300); +writeFileSync(path.join(VNOTES, "out", "sourced", "schedule.json"), JSON.stringify(NOTES_SCHEDULE, null, 2)); +{ + const dir = path.join(VNOTES, "takes", "alt"); + mkdirSync(path.join(dir, "out", "sourced"), { recursive: true }); + writeFileSync( + path.join(dir, "take.json"), + JSON.stringify({ id: "alt", group: "cut", order: 1, label: "Alternate", kind: "similar", summary: "Tighter.", preview: "preview.mp4", seconds: 2 }, null, 2), + ); + twoSeconds(path.join(dir, "preview.mp4"), 360); + writeFileSync(path.join(dir, "out", "sourced", "schedule.json"), JSON.stringify(NOTES_SCHEDULE, null, 2)); +} +writeProject("timeline-fixture", { + ...manifest("timeline-fixture", "The Timeline Fixture", { siteOrigin: "https://archive.example" }, [ + { type: "teaser", id: "t1", lines: ["THE PROMISE"] }, + { type: "clip", id: "a01", video: "vid1", start: 0, end: 3, cite: 0, section: 1, sectionEnter: true, lock: true, quote: "one" }, + { type: "clip", id: "a02", video: "vid1", start: 9, end: 12, cite: 9, section: 1, lock: true, quote: "two" }, + { type: "clip", id: "a03", video: "vid1", start: 12, end: 15, cite: 12, section: 2, sectionEnter: true, lock: true, quote: "three" }, + ]), + posts: [{ id: "p1", platform: "x", author: "Someone", handle: "@someone", date: "2024-01-02", text: "A post.", url: "https://x.com/someone/status/1", attachTo: "a02" }], +}); + const { sites: SITES } = makeSitesFixture({ dest, reports, channels: CHANNELS }); console.log(`fixture at ${dest}`); console.log(` SITES_DIR=${SITES}`); +console.log(` video-notes-fixture (generated, built, schedule + take alt), timeline-fixture (teaser, a01-a03, post p1)`); if (planned) console.log(` planned clip (used in a build): ${planned}`); console.log(` videos/: alpha (4 cuts, 3 variants), beta (2 cuts), deck (1 cut, 2 variants)`); console.log(` deck: 1 spec error, 1 stale recipe, 1 unjudged variant, 1 judged one`); diff --git a/umtool/e2e/timeline-edit.spec.ts b/umtool/e2e/timeline-edit.spec.ts @@ -0,0 +1,136 @@ +import { test, expect, type Page } from "@playwright/test"; +import { copyFileSync, existsSync, readFileSync, readdirSync, rmSync, writeFileSync } from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +// --------------------------------------------------------------------------- +// Structural edits to a report video (lib/report/manifest.mjs "STRUCTURE", +// POST /api/report/timeline): +// +// timeline-fixture hand-written: teaser t1, clips a01 a02 a03, post p1 on +// a02. WRITES its manifest and revisions/; every test +// starts from the fixture's manifest. +// +// Re-order with alt+↓ and by dragging, undo, the row menu (duplicate, remove, +// insert after), a refusal in the build's words, and the teaser, posts and +// fact-check editors. +// --------------------------------------------------------------------------- + +const HERE = path.dirname(fileURLToPath(import.meta.url)); +const DIR = path.join(HERE, "..", ".e2e-song", "reports", "timeline-fixture"); +const MANIFEST = path.join(DIR, "video.manifest.json"); +const PRISTINE = path.join(DIR, "video.manifest.pristine"); +const PAGE = "/browse/reports/timeline-fixture"; + +type Entry = Record<string, unknown> & { id: string }; +const manifest = () => JSON.parse(readFileSync(MANIFEST, "utf8")) as { timeline: Entry[]; posts?: Entry[]; render: Record<string, unknown> }; +const order = () => manifest().timeline.map((e) => e.id); +const rows = (page: Page) => page.locator("li[data-entry][data-kind]"); +const shownOrder = (page: Page) => rows(page).evaluateAll((els) => els.map((e) => e.getAttribute("data-entry"))); + +test.beforeAll(() => { + if (!existsSync(PRISTINE)) copyFileSync(MANIFEST, PRISTINE); +}); +test.beforeEach(() => { + copyFileSync(PRISTINE, MANIFEST); + rmSync(path.join(DIR, "revisions"), { recursive: true, force: true }); + rmSync(path.join(DIR, "notes.json"), { force: true }); +}); + +async function open(page: Page) { + await page.goto(PAGE); + await expect(page.getByTestId("timeline-undo")).toBeEnabled(); + // The list has read its token once the page is hydrated. + await page.waitForLoadState("networkidle"); +} + +test("alt+↓ moves a row, recomputes sectionEnter, and Undo puts it back byte for byte", async ({ page }) => { + const before = readFileSync(MANIFEST, "utf8"); + await open(page); + await page.locator("li[data-entry='a01'][data-kind]").focus(); + await page.keyboard.press("Alt+ArrowDown"); + await expect.poll(order).toEqual(["t1", "a02", "a01", "a03"]); + await expect.poll(() => shownOrder(page)).toEqual(["t1", "a02", "a01", "a03"]); + const m = manifest(); + expect(m.timeline[1].sectionEnter).toBe(true); + expect("sectionEnter" in m.timeline[2]).toBe(false); + expect(readdirSync(path.join(DIR, "revisions")).some((n) => n.includes("auto-before-move"))).toBe(true); + + await page.getByTestId("timeline-undo").click(); + await expect.poll(() => readFileSync(MANIFEST, "utf8")).toBe(before); + await expect.poll(() => shownOrder(page)).toEqual(["t1", "a01", "a02", "a03"]); +}); + +test("a row dragged by its handle lands where it is dropped", async ({ page }) => { + await open(page); + await page.locator("[data-drag-handle='a03']").dragTo(page.locator("li[data-entry='a01'][data-kind]")); + await expect.poll(order).toEqual(["t1", "a03", "a01", "a02"]); +}); + +test("the row menu duplicates, removes, inserts — and a refusal is in the build's words", async ({ page }) => { + await open(page); + await page.locator("[data-row-menu='a03']").click(); + await page.locator("[data-row-action='duplicate']").click(); + await expect.poll(order).toEqual(["t1", "a01", "a02", "a03", "a03-copy"]); + + await expect(page.locator("[data-row-menu='a03-copy']")).toBeVisible(); + await page.locator("[data-row-menu='a03-copy']").click(); + await page.locator("[data-row-action='remove']").click(); + await expect.poll(order).toEqual(["t1", "a01", "a02", "a03"]); + + await page.locator("[data-row-menu='a03']").click(); + await page.locator("[data-row-action='insert']").click(); + await page.getByTestId("insert-after-a03").fill("testchan/vid1@3-6"); + await page.getByTestId("insert-after-a03").press("Enter"); + await expect.poll(order).toEqual(["t1", "a01", "a02", "a03", "vid1-3"]); + expect(manifest().timeline[4]).toEqual({ type: "clip", id: "vid1-3", channel: "testchan", video: "vid1", start: 3, end: 6 }); + + // p1 rides on a02: removing it is refused, and nothing is written. + const was = readFileSync(MANIFEST, "utf8"); + await page.locator("[data-row-menu='a02']").click(); + await page.locator("[data-row-action='remove']").click(); + await expect(page.getByTestId("timeline-error")).toContainText("attachTo"); + expect(readFileSync(MANIFEST, "utf8")).toBe(was); +}); + +test("the teaser's lines and beat, edited in place; an empty teaser is refused", async ({ page }) => { + await open(page); + await page.getByTestId("structure-folded").locator("summary").click(); + await page.getByTestId("teaser-lines-t1").fill("THE PROMISE\nAND WHAT HAPPENED"); + await page.getByTestId("teaser-beat-t1").fill("1.2"); + await page.getByTestId("teaser-save-t1").click(); + await expect.poll(() => manifest().timeline[0].lines).toEqual(["THE PROMISE", "AND WHAT HAPPENED"]); + expect(manifest().timeline[0].beat).toBe(1.2); + + await page.getByTestId("teaser-lines-t1").fill(""); + await page.getByTestId("teaser-save-t1").click(); + await expect(page.getByTestId("structure-error")).toContainText("lines must be a list"); +}); + +test("a post added, then removed; the fact-check's labels once the deck is on", async ({ page }) => { + // The fact-check is drawn by the deck: turn it on in the fixture first. + const m = manifest(); + m.render.chrome = { engine: "hyperframes", layout: "deck" }; + writeFileSync(MANIFEST, JSON.stringify(m, null, 2) + "\n"); + + await open(page); + await page.getByTestId("structure-folded").locator("summary").click(); + await page.getByTestId("post-add").click(); + const fresh = page.locator("[data-post-row='new']"); + await fresh.getByTestId("post-id").fill("p2"); + await fresh.getByTestId("post-date").fill("2024-02-03"); + await fresh.getByTestId("post-url").fill("https://x.com/someone/status/2"); + await fresh.getByTestId("post-text").fill("Another post."); + await fresh.getByTestId("post-save").click(); + await expect.poll(() => (manifest().posts ?? []).map((p) => p.id)).toEqual(["p1", "p2"]); + + await page.locator("[data-post-row='p2']").getByTestId("post-remove").click(); + await expect.poll(() => (manifest().posts ?? []).map((p) => p.id)).toEqual(["p1"]); + + await page.getByTestId("fc-label-CONTRADICTED").fill("NOPE"); + await page.getByTestId("fc-color-CONTRADICTED").fill("#ff0000"); + await page.getByTestId("fc-save").click(); + await expect + .poll(() => (manifest().render.chrome as { factcheck?: unknown }).factcheck) + .toEqual({ verdicts: { CONTRADICTED: { label: "NOPE", color: "#ff0000" } } }); +}); diff --git a/umtool/e2e/video-notes.spec.ts b/umtool/e2e/video-notes.spec.ts @@ -0,0 +1,151 @@ +import { test, expect, type Locator, type Page } from "@playwright/test"; +import { copyFileSync, existsSync, readFileSync, rmSync } from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +// --------------------------------------------------------------------------- +// Notes on a report video (lib/annotations, components/notes): +// +// video-notes-fixture a GENERATED manifest (`generatedBy`) with a built cut, +// its schedule, and one take (`alt`) with its own. WRITES +// its notes.json; every test starts from the fixture's +// manifest and no notes. +// +// What is proved: a timed note at a second of the built cut and of a take's +// preview, resolved to the entry on screen; take notes and row notes; and that +// an edit made here to a generated manifest leaves an `edit` note, coalesced, +// and gone again when the edit is put back. +// --------------------------------------------------------------------------- + +const HERE = path.dirname(fileURLToPath(import.meta.url)); +const DIR = path.join(HERE, "..", ".e2e-song", "reports", "video-notes-fixture"); +const MANIFEST = path.join(DIR, "video.manifest.json"); +const PRISTINE = path.join(DIR, "video.manifest.pristine"); +const NOTES = path.join(DIR, "notes.json"); +const PROJECT = "reports/video-notes-fixture"; +const PAGE = `/browse/${PROJECT}`; + +type Note = { id: string; status: string; author: string; text: string; anchor: Record<string, unknown>; replies: unknown[] }; +const notesOnDisk = (): Note[] => (existsSync(NOTES) ? JSON.parse(readFileSync(NOTES, "utf8")).notes : []); + +test.beforeAll(() => { + if (!existsSync(PRISTINE)) copyFileSync(MANIFEST, PRISTINE); +}); +test.beforeEach(() => { + copyFileSync(PRISTINE, MANIFEST); + rmSync(NOTES, { force: true }); + rmSync(path.join(DIR, "revisions"), { recursive: true, force: true }); +}); + +/** Seek a <video> to `t` and wait for it to land. */ +async function seek(video: Locator, t: number) { + await video.evaluate(async (el: HTMLVideoElement, at: number) => { + if (el.readyState < 1) await new Promise((r) => el.addEventListener("loadedmetadata", r, { once: true })); + el.currentTime = at; + await new Promise((r) => el.addEventListener("seeked", r, { once: true })); + }, t); +} + +async function addTimedNote(page: Page, scope: Locator, text: string, via: "button" | "key") { + if (via === "button") await scope.locator("[data-action='mark']").click(); + else { + await scope.locator("video").focus(); + await page.keyboard.press("n"); + } + const input = scope.getByTestId("timed-note-input"); + await input.fill(text); + await input.press("Enter"); +} + +test("the generated banner is on the project page and the clip bench", async ({ page }) => { + await page.goto(PAGE); + await expect(page.getByTestId("generated-banner").first()).toContainText( + "Generated by polemics/video/make-videos.py; a rebuild of manifests overwrites edits made here.", + ); + await page.goto(`${PAGE}/clip/n01`); + await expect(page.getByTestId("generated-banner")).toContainText("polemics/video/make-videos.py"); +}); + +test("a timed note on the built cut resolves to the entry on screen and its source", async ({ page }) => { + await page.goto(PAGE); + const video = page.getByTestId("onscreen-final-video"); + await expect(video).toBeVisible(); + const scope = page.locator("[data-timed-notes='out/video-notes-fixture.mp4']"); + await seek(video, 0.8); + await addTimedNote(page, scope, "the claim card is late", "button"); + + await expect(scope.locator("[data-mark-entry='n01']")).toContainText("the claim card is late"); + await expect(scope.locator("[data-mark-entry='n01']")).toContainText("The first claim"); + await expect(scope.locator("[data-tick]")).toHaveCount(1); + + const [n] = notesOnDisk(); + expect(n.author).toBe("operator"); + expect(n.anchor).toMatchObject({ kind: "moment", file: "out/video-notes-fixture.mp4", t: 0.8, entry: "n01" }); + expect(n.anchor.resolved).toMatchObject({ title: "The first claim", channel: "testchan", video: "vid1", sourceT: 0.3 }); + expect(String((n.anchor.resolved as { url: string }).url)).toBe("https://archive.example/?v=testchan%2Fvid1&t=0"); + expect((n.anchor.resolved as { approx?: boolean }).approx).toBeUndefined(); + + // Delete it: the last note takes the file with it. + await scope.locator(`[data-note-id='${n.id}'] [data-note-action='delete']`).click(); + await expect(scope.locator("[data-mark-at]")).toHaveCount(0); + await expect.poll(() => existsSync(NOTES)).toBe(false); +}); + +test("a take: notes on the take, and a timed note on its preview with `n`", async ({ page }) => { + await page.goto(`${PAGE}/takes`); + const card = page.locator("[data-take='alt']"); + await seek(card.locator("video"), 1.5); + await addTimedNote(page, card, "second claim runs long", "key"); + await expect(card.locator("[data-mark-entry='n02']")).toContainText("second claim runs long"); + + await card.locator("[data-anchored-notes='alt'] [data-action='toggle-notes']").click(); + await card.getByTestId("note-input-alt").fill("prefer this one, but tighter"); + await card.getByTestId("note-input-alt").press("Enter"); + await expect(card.locator("[data-anchored-notes='alt']")).toHaveAttribute("data-open-notes", "1"); + + const notes = notesOnDisk(); + expect(notes.map((n) => n.anchor.kind).sort()).toEqual(["moment", "take"]); + expect(notes.find((n) => n.anchor.kind === "moment")!.anchor).toMatchObject({ file: "takes/alt/preview.mp4", take: "alt", entry: "n02" }); + expect(notes.find((n) => n.anchor.kind === "take")!.anchor).toEqual({ kind: "take", take: "alt" }); + + // Resolve the take note; it stays, shown as resolved. + const takeNote = notes.find((n) => n.anchor.kind === "take")!; + await card.locator(`[data-note-id='${takeNote.id}'] [data-note-action='resolve']`).click(); + await expect(card.locator(`[data-note-id='${takeNote.id}']`)).toHaveAttribute("data-note-status", "resolved"); + await expect(card.locator("[data-anchored-notes='alt']")).toHaveAttribute("data-open-notes", "0"); + expect(notesOnDisk().find((n) => n.id === takeNote.id)!.status).toBe("resolved"); +}); + +test("an edit to a generated manifest leaves one edit note, coalesced, gone when put back", async ({ request }) => { + const token = async () => (await (await request.get(`/api/report/clip?project=${PROJECT}&clip=n01`)).json()).token as string; + const put = async (title: string) => + request.put("/api/report/window", { data: { project: PROJECT, clip: "n01", token: await token(), title } }); + + let r = await (await put("A new title")).json(); + expect(r.editNotes).toMatchObject({ generatedBy: "polemics/video/make-videos.py", added: 1 }); + r = await (await put("A newer title")).json(); + expect(r.editNotes).toMatchObject({ added: 0, updated: 1 }); + const [n] = notesOnDisk(); + expect(notesOnDisk()).toHaveLength(1); + expect(n.anchor).toEqual({ kind: "edit", entry: "n01", field: "title", from: null, to: "A newer title" }); + expect(n.text).toContain("polemics/video/make-videos.py"); + + const doc = JSON.parse(readFileSync(NOTES, "utf8")); + expect(doc.source).toMatchObject({ manifest: expect.stringContaining("video-notes-fixture/video.manifest.json") }); + + r = await (await put("")).json(); + expect(r.editNotes).toMatchObject({ deleted: 1 }); + expect(existsSync(NOTES)).toBe(false); +}); + +test("a row note on the project page, counted on the row and kept across a reload", async ({ page }) => { + await page.goto(PAGE); + const row = page.locator("[data-anchored-notes='n02']"); + await row.locator("[data-action='toggle-notes']").click(); + await page.getByTestId("note-input-n02").fill("check the date on this one"); + await page.getByTestId("note-input-n02").press("Enter"); + await expect(row).toHaveAttribute("data-open-notes", "1"); + await page.reload(); + await expect(page.locator("[data-anchored-notes='n02']")).toHaveAttribute("data-open-notes", "1"); + expect(notesOnDisk()[0].anchor).toEqual({ kind: "entry", entry: "n02" }); +}); diff --git a/umtool/lib/report/moments.mjs b/umtool/lib/report/moments.mjs @@ -68,7 +68,11 @@ export async function scheduleForFile(projectDir, rel) { else { const dirs = await readdir(/* turbopackIgnore: true */ path.join(/* turbopackIgnore: true */ base, "out"), { withFileTypes: true }).catch(() => []); variants = dirs.filter((d) => d.isDirectory() || d.isSymbolicLink()).map((d) => d.name); - variants.sort((a, b) => (a === DEFAULT_VARIANT ? -1 : b === DEFAULT_VARIANT ? 1 : a.localeCompare(b))); + // A deliverable names its cut (`<slug>-full.mp4`; the default cut is + // `<slug>.mp4`): that variant's schedule first, then the default's. + const named = (v) => path.basename(String(rel)).endsWith(`-${v}.mp4`); + const rank = (v) => (named(v) ? 0 : v === DEFAULT_VARIANT ? 1 : 2); + variants.sort((a, b) => rank(a) - rank(b) || a.localeCompare(b)); } for (const v of variants) { const file = path.join(/* turbopackIgnore: true */ base, "out", v, "schedule.json");