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:
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");