commit 5f34b0da817fc8672b9b6551d3d495960bcf5ac4
parent 1efe8fa5ee3f48136e33c8fd90e48151416c6502
Author: I Mean I'm Just Saying <imeanimjustsaying@kiwifarms.st>
Date: Thu, 8 Oct 2026 23:43:30 -0400
Merge umtool/articles-b (Track B: timed notes, row notes, generated guard, take notes, structural edits)
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Diffstat:
37 files changed, 3028 insertions(+), 46 deletions(-)
diff --git a/umtool/app/api/report/chrome/route.ts b/umtool/app/api/report/chrome/route.ts
@@ -1,3 +1,4 @@
+import { withEditNotes } from "@/lib/report/guard";
import { ChromeRefused, StaleToken, manifestToken, updateChrome } from "@/lib/report/manifest.mjs";
import { resolveReport } from "@/lib/report/serve.mjs";
import { DECK_DEFAULTS, deckOn, resolveDeck, validateChrome } from "umtool-report-to-video/deck";
@@ -53,15 +54,18 @@ export async function PUT(request: Request) {
if ("error" in r) return Response.json({ error: r.error }, { status: r.status });
try {
- const res = await updateChrome(r.project.dir, (body.chrome ?? null) as Record<string, unknown> | null, {
- token: body.token === undefined ? null : String(body.token),
- });
+ const { result: res, editNotes } = await withEditNotes(r.project, () =>
+ updateChrome(r.project.dir, (body.chrome ?? null) as Record<string, unknown> | null, {
+ token: body.token === undefined ? null : String(body.token),
+ }),
+ );
return Response.json(
{
ok: true,
chrome: res.chrome,
deck: res.chrome ? resolveDeck({ ...(r.manifest.render ?? {}), chrome: res.chrome }) : null,
token: res.token,
+ editNotes,
},
{ headers: noStore },
);
diff --git a/umtool/app/api/report/claim/route.ts b/umtool/app/api/report/claim/route.ts
@@ -1,3 +1,4 @@
+import { withEditNotes } from "@/lib/report/guard";
import { StaleToken, manifestToken, updateClaim } from "@/lib/report/manifest.mjs";
import { resolveClaim } from "@/lib/report/serve.mjs";
import { readClaimDetail } from "@/lib/projects/report.mjs";
@@ -50,11 +51,16 @@ export async function PUT(request: Request) {
}
try {
- const { entry, token } = await updateClaim(r.project.dir, claimId, patch, {
- token: body.token === undefined ? null : String(body.token),
- });
+ const {
+ result: { entry, token },
+ editNotes,
+ } = await withEditNotes(r.project, () =>
+ updateClaim(r.project.dir, claimId, patch, {
+ token: body.token === undefined ? null : String(body.token),
+ }),
+ );
const detail = await readClaimDetail(r.project.dir, claimId);
- return Response.json({ claim: entry, gaps: detail?.gaps ?? [], token });
+ return Response.json({ claim: entry, gaps: detail?.gaps ?? [], token, editNotes });
} catch (err) {
// A stale token is a 409 and never a silent overwrite.
if (err instanceof StaleToken) {
diff --git a/umtool/app/api/report/cut/route.ts b/umtool/app/api/report/cut/route.ts
@@ -1,3 +1,4 @@
+import { withEditNotes } from "@/lib/report/guard";
import { StaleToken, updateClip } from "@/lib/report/manifest.mjs";
import { cuesInWindow } from "@/lib/projects/report.mjs";
import { resolveClip } from "@/lib/report/serve.mjs";
@@ -63,14 +64,16 @@ export async function POST(request: Request) {
}
try {
- const res = await updateClip(
- project.dir,
- clipId,
- { cutStart: hit.cutStart, cutEnd: hit.cutEnd },
- { token: body.token === undefined ? null : String(body.token) },
+ const { result: res, editNotes } = await withEditNotes(project, () =>
+ updateClip(
+ project.dir,
+ clipId,
+ { cutStart: hit.cutStart, cutEnd: hit.cutEnd },
+ { token: body.token === undefined ? null : String(body.token) },
+ ),
);
return Response.json(
- { ok: true, entry: res.entry, token: res.token, score: hit.score, matched: hit.matched },
+ { ok: true, entry: res.entry, token: res.token, score: hit.score, matched: hit.matched, editNotes },
{ headers: { "cache-control": "no-store" } },
);
} catch (e) {
diff --git a/umtool/app/api/report/moment/route.ts b/umtool/app/api/report/moment/route.ts
@@ -0,0 +1,26 @@
+import { projectRef } from "@/lib/projects";
+import { resolveMomentOnFile } from "@/lib/report/moments.mjs";
+
+export const dynamic = "force-dynamic";
+
+// GET ?project=<id>&file=<project-relative mp4>&t=<seconds>[&duration=<seconds>]
+//
+// What is on screen at `t` of a rendered cut (lib/report/moments.mjs): the
+// entry, its onscreen title and quote, the source second and its archive link,
+// and `approx` when the file and the build's schedule disagree. A timed note
+// stores this at write time. `{ entry: null }` when the file has no schedule
+// (a preview made without a build) -- the note then keeps `t` alone.
+export async function GET(request: Request) {
+ const url = new URL(request.url);
+ const project = await projectRef(url.searchParams.get("project") ?? "");
+ if (!project) return Response.json({ error: "no such project" }, { status: 404 });
+ const file = url.searchParams.get("file") ?? "";
+ if (!file || file.startsWith("/") || file.split("/").some((s) => s === ".." || s === "")) {
+ return Response.json({ error: "file must be relative to the project" }, { status: 400 });
+ }
+ const t = Number(url.searchParams.get("t"));
+ if (!Number.isFinite(t) || t < 0) return Response.json({ error: "t must be seconds ≥ 0" }, { status: 400 });
+ const d = Number(url.searchParams.get("duration"));
+ const r = await resolveMomentOnFile(project.dir, file, t, Number.isFinite(d) && d > 0 ? d : null);
+ return Response.json(r, { headers: { "cache-control": "no-store" } });
+}
diff --git a/umtool/app/api/report/onscreen/route.ts b/umtool/app/api/report/onscreen/route.ts
@@ -1,3 +1,4 @@
+import { withEditNotes } from "@/lib/report/guard";
import { StaleToken, manifestToken, updateOnscreen } from "@/lib/report/manifest.mjs";
import { postRows, scheduleForPreview } from "@/lib/report/onscreen.mjs";
import { resolveReport } from "@/lib/report/serve.mjs";
@@ -86,12 +87,14 @@ export async function PUT(request: Request) {
if ("error" in r) return Response.json({ error: r.error }, { status: r.status });
try {
- const res = await updateOnscreen(
- r.project.dir,
- body.onscreen as Record<string, { title?: string; subtitle?: string; claim?: Claim | null } | null>,
- { token: body.token === undefined ? null : String(body.token) },
+ const { result: res, editNotes } = await withEditNotes(r.project, () =>
+ updateOnscreen(
+ r.project.dir,
+ body.onscreen as Record<string, { title?: string; subtitle?: string; claim?: Claim | null } | null>,
+ { token: body.token === undefined ? null : String(body.token) },
+ ),
);
- return Response.json({ ok: true, onscreen: res.onscreen, claims: res.claims, token: res.token }, { headers: noStore });
+ return Response.json({ ok: true, onscreen: res.onscreen, claims: res.claims, token: res.token, editNotes }, { headers: noStore });
} catch (e) {
if (e instanceof StaleToken) {
return Response.json(
diff --git a/umtool/app/api/report/posts/route.ts b/umtool/app/api/report/posts/route.ts
@@ -1,3 +1,4 @@
+import { withEditNotes } from "@/lib/report/guard";
import { PostsRefused, StaleToken, updatePosts } from "@/lib/report/manifest.mjs";
import { resolveReport } from "@/lib/report/serve.mjs";
@@ -28,12 +29,14 @@ export async function PUT(request: Request) {
if ("error" in r) return Response.json({ error: r.error }, { status: r.status });
try {
- const res = await updatePosts(
- r.project.dir,
- body.posts as Record<string, { attachTo?: string | null; hide?: boolean }>,
- { token: body.token === undefined ? null : String(body.token) },
+ const { result: res, editNotes } = await withEditNotes(r.project, () =>
+ updatePosts(
+ r.project.dir,
+ body.posts as Record<string, { attachTo?: string | null; hide?: boolean }>,
+ { token: body.token === undefined ? null : String(body.token) },
+ ),
);
- return Response.json({ ok: true, posts: res.posts, token: res.token }, { headers: noStore });
+ return Response.json({ ok: true, posts: res.posts, token: res.token, editNotes }, { headers: noStore });
} catch (e) {
if (e instanceof StaleToken) {
return Response.json(
diff --git a/umtool/app/api/report/timeline/route.ts b/umtool/app/api/report/timeline/route.ts
@@ -0,0 +1,134 @@
+import { invalidateProjects } from "@/lib/projects";
+import { withEditNotes } from "@/lib/report/guard";
+import {
+ StaleToken,
+ StructureRefused,
+ duplicateEntry,
+ insertEntry,
+ manifestToken,
+ moveEntry,
+ removeEntry,
+ removePost,
+ undoStructural,
+ updateFactcheck,
+ updateTeaser,
+ upsertPost,
+} from "@/lib/report/manifest.mjs";
+import { resolveReport } from "@/lib/report/serve.mjs";
+import { deckOn } from "umtool-report-to-video/deck";
+import { VERDICTS, resolveFactcheck } from "umtool-report-to-video/factcheck";
+
+export const dynamic = "force-dynamic";
+
+// The STRUCTURE of the cut: what is in the timeline and in what order, a
+// teaser's lines, the posts, the fact-check's labels (lib/report/manifest.mjs,
+// "STRUCTURE"). The window route deliberately cannot move an entry; this is
+// the different button it points at.
+//
+// GET ?project=<id> the token, the timeline's ids in order, the
+// teasers, the posts and the fact-check block
+// POST { project, token, op, ... } one op:
+// move { id, at?, toIndex } toIndex is the index AFTER the move
+// remove { id, at? }
+// duplicate { id, at? }
+// insert { afterId: id | null, at?, entry: "<channel>/<video>@<start>-<end>" | { type, … } }
+// teaser { id, at?, patch: { lines?, beat?, dip?, tail?, tailWait? } }
+// post { post: { id, platform, date, text, url, … } } add or replace
+// post-remove { id }
+// factcheck { factcheck: { verdicts?, stamp?, tally? } | null }
+// undo {} restore the newest auto snapshot
+//
+// Every op snapshots the manifest first (`revisions/<stamp>-auto-before-<op>`),
+// is checked by the build's own validators, and on a GENERATED manifest leaves
+// an `edit` note for the agent (lib/report/guard.ts). A stale token is a 409.
+
+type Body = Record<string, unknown>;
+const noStore = { "cache-control": "no-store" };
+
+export async function GET(request: Request) {
+ const url = new URL(request.url);
+ const r = await resolveReport(url.searchParams.get("project") ?? "");
+ if ("error" in r) return Response.json({ error: r.error }, { status: r.status });
+ const entries = (r.manifest.timeline ?? []) as Record<string, unknown>[];
+ const timeline = entries.map((e) => ({ id: String(e.id), type: String(e.type ?? "entry") }));
+ // What the structure editors edit: each teaser as written, the posts, and
+ // the fact-check block with every default filled (the form's placeholders).
+ const teasers = entries
+ .map((e, at) => ({ e, at }))
+ .filter(({ e }) => e.type === "teaser")
+ .map(({ e, at }) => ({ at, id: String(e.id), lines: e.lines ?? [], beat: e.beat ?? null, dip: e.dip ?? null, tail: e.tail ?? null, tailWait: e.tailWait ?? null }));
+ const render = (r.manifest.render ?? {}) as Record<string, unknown>;
+ return Response.json(
+ {
+ timeline,
+ teasers,
+ posts: Array.isArray(r.manifest.posts) ? r.manifest.posts : [],
+ deckOn: deckOn(render),
+ factcheck: (render.chrome as { factcheck?: unknown } | undefined)?.factcheck ?? null,
+ factcheckResolved: resolveFactcheck(render),
+ verdicts: VERDICTS,
+ generatedBy: r.manifest.generatedBy ?? null,
+ token: await manifestToken(r.project.dir),
+ },
+ { headers: noStore },
+ );
+}
+
+const atOf = (v: unknown) => (v === undefined || v === null || v === "" ? null : Number(v));
+
+export async function POST(request: Request) {
+ let body: Body;
+ try {
+ body = await request.json();
+ } catch {
+ return Response.json({ error: "expected JSON" }, { status: 400 });
+ }
+ const r = await resolveReport(String(body.project ?? ""));
+ if ("error" in r) return Response.json({ error: r.error }, { status: r.status });
+ const dir = r.project.dir;
+ // A string, or no guard at all: `String(null)` would be the token "null",
+ // which matches no file and refuses every write.
+ const token = typeof body.token === "string" ? body.token : null;
+ const id = String(body.id ?? "");
+ const at = atOf(body.at);
+
+ const run = (): Promise<Record<string, unknown>> => {
+ switch (body.op) {
+ case "move":
+ return moveEntry(dir, id, Number(body.toIndex), { token, at });
+ case "remove":
+ return removeEntry(dir, id, { token, at });
+ case "duplicate":
+ return duplicateEntry(dir, id, { token, at });
+ case "insert":
+ return insertEntry(dir, body.afterId ? String(body.afterId) : null, body.entry, { token, at });
+ case "teaser":
+ return updateTeaser(dir, id, body.patch as Record<string, unknown>, { token, at });
+ case "post":
+ return upsertPost(dir, body.post as Record<string, unknown>, { token });
+ case "post-remove":
+ return removePost(dir, id, { token });
+ case "factcheck":
+ return updateFactcheck(dir, (body.factcheck ?? null) as Record<string, unknown> | null, { token });
+ case "undo":
+ return undoStructural(dir, { token });
+ default:
+ throw new Error("op must be move, remove, duplicate, insert, teaser, post, post-remove, factcheck or undo");
+ }
+ };
+
+ try {
+ const { result, editNotes } = await withEditNotes(r.project, run);
+ // revisions/ changed: the project page lists them.
+ invalidateProjects();
+ return Response.json({ ok: true, ...result, editNotes }, { headers: noStore });
+ } catch (e) {
+ if (e instanceof StaleToken) {
+ return Response.json({ error: e.message, expected: e.expected, got: e.got, stale: true }, { status: 409 });
+ }
+ if (e instanceof StructureRefused) {
+ return Response.json({ error: e.message, errors: e.errors }, { status: 400 });
+ }
+ return Response.json({ error: e instanceof Error ? e.message : String(e) }, { status: 400 });
+ }
+}
diff --git a/umtool/app/api/report/window/route.ts b/umtool/app/api/report/window/route.ts
@@ -1,3 +1,4 @@
+import { withEditNotes } from "@/lib/report/guard";
import { StaleToken, updateClip } from "@/lib/report/manifest.mjs";
import { resolveClip } from "@/lib/report/serve.mjs";
@@ -63,11 +64,13 @@ export async function PUT(request: Request) {
if (!Object.keys(patch).length) return Response.json({ error: "nothing to change" }, { status: 400 });
try {
- const res = await updateClip(r.project.dir, clipId, patch, {
- token: body.token === undefined ? null : String(body.token),
- });
+ const { result: res, editNotes } = await withEditNotes(r.project, () =>
+ updateClip(r.project.dir, clipId, patch, {
+ token: body.token === undefined ? null : String(body.token),
+ }),
+ );
return Response.json(
- { ok: true, entry: res.entry, before: res.before, token: res.token },
+ { ok: true, entry: res.entry, before: res.before, token: res.token, editNotes },
{ headers: { "cache-control": "no-store" } },
);
} catch (e) {
diff --git a/umtool/components/notes/AnchoredNotes.tsx b/umtool/components/notes/AnchoredNotes.tsx
@@ -0,0 +1,74 @@
+"use client";
+
+import { useState } from "react";
+import { badgeVariants } from "@/components/ui/badge";
+import type { Anchor, Note } from "@/lib/annotations/types";
+import type { UseNotes } from "@/lib/annotations/useNotes";
+import { NoteComposer, NoteThread } from "./NoteThread";
+
+// The notes on one THING: a timeline row (`entry`), a take (`take`). A count
+// of the open ones, folded; unfolded, every note on it and a box for another.
+
+type Pin = { kind: "entry"; entry: string } | { kind: "take"; take: string };
+
+export const notesOn = (notes: Note[], pin: Pin) =>
+ notes.filter((n) => {
+ const a = n.anchor as Anchor;
+ if (pin.kind === "entry") {
+ return (a.kind === "entry" && a.entry === pin.entry) || (a.kind === "edit" && a.entry === pin.entry);
+ }
+ return (a.kind === "take" && a.take === pin.take) || (a.kind === "moment" && a.take === pin.take);
+ });
+
+export default function AnchoredNotes({
+ notes,
+ pin,
+ startOpen = false,
+ label = "notes",
+}: {
+ notes: UseNotes;
+ pin: Pin;
+ startOpen?: boolean;
+ label?: string;
+}) {
+ const [open, setOpen] = useState(startOpen);
+ const mine = notesOn(notes.notes, pin).filter((n) => n.anchor.kind !== "moment");
+ const openCount = mine.filter((n) => n.status === "open").length;
+ const key = pin.kind === "entry" ? pin.entry : pin.take;
+ return (
+ <div data-anchored-notes={key} data-open-notes={openCount} className="text-[11px]">
+ <button
+ type="button"
+ data-action="toggle-notes"
+ onClick={() => setOpen((o) => !o)}
+ className={openCount ? badgeVariants({ variant: "open", size: "sm" }) : "text-[var(--color-sel)] hover:underline"}
+ >
+ {openCount ? `${openCount} open ${openCount === 1 ? "note" : "notes"}` : mine.length ? `${label} (${mine.length})` : `+ ${label.replace(/s$/, "")}`}
+ </button>
+ {open && (
+ <div className="mt-1 space-y-1 rounded bg-[var(--color-panel-2)] p-2">
+ {mine.map((n) => (
+ <NoteThread
+ key={n.id}
+ note={n}
+ write={notes.write}
+ busy={notes.busy}
+ head={
+ n.anchor.kind === "edit" ? (
+ <span className={badgeVariants({ variant: "info", size: "sm" })}>edit · {n.anchor.field}</span>
+ ) : null
+ }
+ />
+ ))}
+ <NoteComposer
+ busy={notes.busy}
+ placeholder={pin.kind === "entry" ? `a note on ${key}` : `a note on this take`}
+ testId={`note-input-${key}`}
+ onAdd={(text) => void notes.write({ op: "add", text, anchor: pin })}
+ />
+ {notes.error && <div className="text-[var(--color-bad)]">{notes.error}</div>}
+ </div>
+ )}
+ </div>
+ );
+}
diff --git a/umtool/components/notes/GeneratedBanner.tsx b/umtool/components/notes/GeneratedBanner.tsx
@@ -0,0 +1,15 @@
+// One line, wherever a generated manifest can be edited: the project page,
+// the clip bench, the On-screen section. Edits are still allowed; each one
+// leaves an `edit` note for the agent that runs the generator
+// (lib/report/guard.ts).
+export default function GeneratedBanner({ generatedBy }: { generatedBy: string | null | undefined }) {
+ if (!generatedBy) return null;
+ return (
+ <p
+ data-testid="generated-banner"
+ className="rounded border border-[var(--color-dirty)] px-2 py-1 text-[11px] text-[var(--color-dirty)]"
+ >
+ Generated by <code className="font-mono">{generatedBy}</code>; a rebuild of manifests overwrites edits made here.
+ </p>
+ );
+}
diff --git a/umtool/components/notes/NoteThread.tsx b/umtool/components/notes/NoteThread.tsx
@@ -0,0 +1,136 @@
+"use client";
+
+import { useState } from "react";
+import { badgeVariants } from "@/components/ui/badge";
+import type { Note, NoteOp } from "@/lib/annotations/types";
+
+// One note: its text, who wrote it, its status, the replies, and what can be
+// done to it. Shared by the timed notes, the row notes and the take notes.
+
+const STATUS_TONE = { open: "open", resolved: "info", wontfix: "info" } as const;
+
+export function NoteThread({
+ note,
+ write,
+ busy,
+ head,
+}: {
+ note: Note;
+ write: (op: NoteOp) => Promise<unknown>;
+ busy: boolean;
+ /** What the note is on, drawn before its text (a timestamp, an entry id). */
+ head?: React.ReactNode;
+}) {
+ const [reply, setReply] = useState<string | null>(null);
+ const open = note.status === "open";
+ return (
+ <div
+ data-note-id={note.id}
+ data-note-status={note.status}
+ className={`space-y-0.5 text-[12px] ${open ? "" : "opacity-60"}`}
+ >
+ <div className="flex flex-wrap items-baseline gap-1.5">
+ {head}
+ {note.author === "agent" && <span className={badgeVariants({ variant: "meter", size: "sm" })}>agent</span>}
+ {!open && <span className={badgeVariants({ variant: STATUS_TONE[note.status], size: "sm" })}>{note.status}</span>}
+ <span className="flex-1 whitespace-pre-wrap text-[var(--color-text)]">{note.text}</span>
+ <span className="flex gap-1.5 text-[10px]">
+ {open ? (
+ <>
+ <button type="button" data-note-action="resolve" disabled={busy} onClick={() => void write({ op: "status", id: note.id, status: "resolved" })} className="text-[var(--color-good)] hover:underline disabled:opacity-40">
+ resolve
+ </button>
+ <button type="button" data-note-action="wontfix" disabled={busy} onClick={() => void write({ op: "status", id: note.id, status: "wontfix" })} className="text-[var(--color-dim)] hover:underline disabled:opacity-40">
+ won’t fix
+ </button>
+ </>
+ ) : (
+ <button type="button" data-note-action="reopen" disabled={busy} onClick={() => void write({ op: "status", id: note.id, status: "open" })} className="text-[var(--color-sel)] hover:underline disabled:opacity-40">
+ reopen
+ </button>
+ )}
+ <button type="button" data-note-action="reply" disabled={busy} onClick={() => setReply((r) => (r === null ? "" : null))} className="text-[var(--color-sel)] hover:underline disabled:opacity-40">
+ reply
+ </button>
+ {note.author === "operator" && (
+ <button type="button" data-note-action="delete" disabled={busy} onClick={() => void write({ op: "delete", id: note.id })} className="text-[var(--color-dim)] hover:text-[var(--color-bad)] disabled:opacity-40">
+ delete
+ </button>
+ )}
+ </span>
+ </div>
+ {note.replies.map((r, i) => (
+ <div key={i} data-note-reply={i} className="ml-4 flex flex-wrap items-baseline gap-1.5 text-[11px]">
+ {r.author === "agent" && <span className={badgeVariants({ variant: "meter", size: "sm" })}>agent</span>}
+ <span className="whitespace-pre-wrap text-[var(--color-dim)]">{r.text}</span>
+ </div>
+ ))}
+ {reply !== null && (
+ <input
+ autoFocus
+ value={reply}
+ data-note-reply-input=""
+ onChange={(e) => setReply(e.target.value)}
+ onKeyDown={(e) => {
+ if (e.key === "Enter" && reply.trim()) {
+ void write({ op: "reply", id: note.id, text: reply });
+ setReply(null);
+ }
+ if (e.key === "Escape") setReply(null);
+ }}
+ placeholder="reply — Enter to send"
+ className="ml-4 w-[calc(100%-1rem)] rounded border border-[var(--color-line)] bg-[var(--color-ink)] px-1.5 py-0.5 text-[12px] text-[var(--color-text)] outline-none focus:border-[var(--color-sel)]"
+ />
+ )}
+ </div>
+ );
+}
+
+/** A one-line composer: Enter adds, Escape cancels. */
+export function NoteComposer({
+ onAdd,
+ onCancel,
+ busy,
+ placeholder,
+ head,
+ testId,
+}: {
+ onAdd: (text: string) => void;
+ onCancel?: () => void;
+ busy: boolean;
+ placeholder: string;
+ head?: React.ReactNode;
+ testId?: string;
+}) {
+ const [text, setText] = useState("");
+ const add = () => {
+ if (!text.trim()) return;
+ onAdd(text);
+ setText("");
+ };
+ return (
+ <div className="flex flex-wrap items-center gap-2">
+ {head}
+ <input
+ autoFocus
+ value={text}
+ data-testid={testId}
+ onChange={(e) => setText(e.target.value)}
+ onKeyDown={(e) => {
+ if (e.key === "Enter") add();
+ if (e.key === "Escape") onCancel?.();
+ }}
+ placeholder={placeholder}
+ className="min-w-[14rem] flex-1 rounded border border-[var(--color-line)] bg-[var(--color-ink)] px-1.5 py-0.5 text-[12px] text-[var(--color-text)] outline-none focus:border-[var(--color-sel)]"
+ />
+ <button
+ type="button"
+ disabled={busy || !text.trim()}
+ onClick={add}
+ className="rounded border border-[var(--color-good)] px-2 py-0.5 text-[11px] text-[var(--color-good)] disabled:opacity-40"
+ >
+ add
+ </button>
+ </div>
+ );
+}
diff --git a/umtool/components/notes/NotesProvider.tsx b/umtool/components/notes/NotesProvider.tsx
@@ -0,0 +1,56 @@
+"use client";
+
+import { createContext, useContext, useEffect } from "react";
+import { notesQuery, useNotes, type NotesTarget, type UseNotes } from "@/lib/annotations/useNotes";
+
+// ONE handle on a notes.json per page.
+//
+// Every write carries the token its handle last read, so two handles on the
+// same file in one page -- the timeline's row notes and the final video's
+// timed notes, say -- would 409 each other on every other write. A page wraps
+// itself in <NotesProvider target>, and every notes component under it shares
+// that handle; a component outside any provider (or under one for another
+// file) opens its own.
+
+const Ctx = createContext<{ key: string; notes: UseNotes } | null>(null);
+
+/** Something on the page wrote to a project's notes server-side (an edit note): re-read them. */
+export const NOTES_CHANGED = "umtool:notes-changed";
+export function announceNotesChanged(project: string) {
+ window.dispatchEvent(new CustomEvent(NOTES_CHANGED, { detail: { project } }));
+}
+
+/** Re-read `notes` when the page says its project's notes changed under it. */
+export function useNotesRefresh(target: NotesTarget | null, notes: UseNotes) {
+ const project = target && "project" in target ? target.project : null;
+ const { reload } = notes;
+ useEffect(() => {
+ if (!project) return;
+ const on = (e: Event) => {
+ if ((e as CustomEvent).detail?.project === project) void reload();
+ };
+ window.addEventListener(NOTES_CHANGED, on);
+ return () => window.removeEventListener(NOTES_CHANGED, on);
+ }, [project, reload]);
+}
+
+export function NotesProvider({ target, children }: { target: NotesTarget; children: React.ReactNode }) {
+ const notes = useNotes(target);
+ useNotesRefresh(target, notes);
+ return <Ctx.Provider value={{ key: notesQuery(target), notes }}>{children}</Ctx.Provider>;
+}
+
+/** The page's shared handle for `target`, or a handle of this component's own. */
+export function useSharedNotes(target: NotesTarget | null): UseNotes {
+ const ctx = useContext(Ctx);
+ const shared = !!(ctx && target && ctx.key === notesQuery(target));
+ const own = useNotes(shared ? null : target);
+ useNotesRefresh(shared ? null : target, own);
+ return shared ? ctx!.notes : own;
+}
+
+/** True when a write's response says the server wrote notes too (an edit on a generated manifest). */
+export const wroteNotes = (j: unknown): boolean => {
+ const e = (j as { editNotes?: { added?: number; updated?: number; deleted?: number } | null })?.editNotes;
+ return !!e && (e.added ?? 0) + (e.updated ?? 0) + (e.deleted ?? 0) > 0;
+};
diff --git a/umtool/components/notes/TimedNotes.tsx b/umtool/components/notes/TimedNotes.tsx
@@ -0,0 +1,234 @@
+"use client";
+
+import { useCallback, useEffect, useState } from "react";
+import { badgeVariants } from "@/components/ui/badge";
+import type { Anchor, MomentResolved, Note } from "@/lib/annotations/types";
+import type { NotesTarget, UseNotes } from "@/lib/annotations/useNotes";
+import { useSharedNotes } from "./NotesProvider";
+import { NoteComposer, NoteThread } from "./NoteThread";
+
+// Notes at a SECOND of a rendered video, in the notes.json of whatever the
+// video belongs to (a report-video project, an article). The song tool's
+// MomentMarks keeps its own store; this is the same idea bound to
+// lib/annotations, so an agent reads it back with everything else.
+//
+// `n` (with the video focused) or "Mark" opens a note at the playhead. When
+// the video is a report project's build, the second is RESOLVED at write time
+// (/api/report/moment: the entry on screen, its title and quote, the source
+// second and its archive link) and stored in the note -- a rebuild moves
+// entries, and the agent must see what was on screen when the key was pressed.
+// A file with no build schedule keeps `t` alone.
+
+const ts = (t: number) => {
+ const m = Math.floor(t / 60);
+ const s = t - m * 60;
+ return `${m}:${s.toFixed(1).padStart(4, "0")}`;
+};
+
+type MomentAnchor = Extract<Anchor, { kind: "moment" }>;
+const isMomentOn = (file: string) => (n: Note): n is Note & { anchor: MomentAnchor } =>
+ n.anchor.kind === "moment" && n.anchor.file === file;
+
+export default function TimedNotes({
+ notes,
+ file,
+ video,
+ take,
+ resolveProject,
+}: {
+ notes: UseNotes;
+ /** The file's path as the anchor stores it: project-relative (`takes/<id>/preview.mp4`), or the article's `video.mp4`. */
+ file: string;
+ video: HTMLVideoElement | null;
+ /** The take this file is a preview of, stored on the anchor. */
+ take?: string;
+ /** Resolve each mark against this project's build schedule. */
+ resolveProject?: string | null;
+}) {
+ const [duration, setDuration] = useState<number | null>(null);
+ const [pending, setPending] = useState<number | null>(null);
+ const marks = notes.notes.filter(isMomentOn(file)).sort((a, b) => a.anchor.t - b.anchor.t);
+
+ const mark = useCallback(() => {
+ if (!video) return;
+ setPending(Number(video.currentTime.toFixed(2)));
+ }, [video]);
+
+ useEffect(() => {
+ if (!video) return;
+ const dur = () => setDuration(Number.isFinite(video.duration) ? video.duration : null);
+ const key = (e: KeyboardEvent) => {
+ if ((e.key === "n" || e.key === "N") && !e.ctrlKey && !e.metaKey && !e.altKey) {
+ e.preventDefault();
+ e.stopPropagation();
+ mark();
+ }
+ };
+ dur();
+ video.addEventListener("loadedmetadata", dur);
+ video.addEventListener("durationchange", dur);
+ video.addEventListener("keydown", key);
+ return () => {
+ video.removeEventListener("loadedmetadata", dur);
+ video.removeEventListener("durationchange", dur);
+ video.removeEventListener("keydown", key);
+ };
+ }, [video, mark]);
+
+ const seek = (t: number) => {
+ if (!video) return;
+ video.currentTime = t;
+ video.focus();
+ };
+
+ const add = async (t: number, text: string) => {
+ let resolved: (MomentResolved & { entry?: string | null }) | null = null;
+ if (resolveProject) {
+ const q = new URLSearchParams({ project: resolveProject, file, t: String(t) });
+ if (duration) q.set("duration", String(duration));
+ resolved = await fetch(`/api/report/moment?${q}`, { cache: "no-store" })
+ .then((r) => (r.ok ? r.json() : null))
+ .catch(() => null);
+ }
+ const anchor: MomentAnchor = { kind: "moment", file, t };
+ if (take) anchor.take = take;
+ if (resolved?.entry) {
+ anchor.entry = resolved.entry;
+ const { entry: _e, ...rest } = resolved as Record<string, unknown>;
+ delete rest.schedule;
+ anchor.resolved = rest as MomentResolved;
+ }
+ await notes.write({ op: "add", text, anchor });
+ };
+
+ return (
+ <div data-timed-notes={file} data-marks={marks.length} className="space-y-1">
+ {/* the tick strip: where the marks are, under the player */}
+ {duration ? (
+ <div className="relative h-2 rounded bg-[var(--color-panel-2)]" data-tick-strip="">
+ {marks.map((n) => (
+ <button
+ key={n.id}
+ type="button"
+ title={`${ts(n.anchor.t)} — ${n.text}`}
+ onClick={() => seek(n.anchor.t)}
+ data-tick={n.anchor.t}
+ className={`absolute top-0 h-2 w-1 -translate-x-1/2 rounded ${n.status === "open" ? "bg-[var(--color-dirty)]" : "bg-[var(--color-dim)]"}`}
+ style={{ left: `${Math.min(100, (n.anchor.t / duration) * 100)}%` }}
+ />
+ ))}
+ </div>
+ ) : null}
+ <div className="flex flex-wrap items-center gap-2 text-[11px]">
+ <button
+ type="button"
+ data-action="mark"
+ disabled={!video}
+ onClick={mark}
+ className="rounded border border-[var(--color-sel)] px-2 py-0.5 text-[11px] text-[var(--color-sel)] disabled:opacity-40"
+ >
+ Mark <kbd>n</kbd>
+ </button>
+ {marks.length > 0 && (
+ <span className="text-[var(--color-dim)]">
+ {marks.filter((m) => m.status === "open").length} open of {marks.length}
+ </span>
+ )}
+ {notes.error && <span className="text-[var(--color-bad)]">{notes.error}</span>}
+ </div>
+ {pending !== null && (
+ <div className="rounded bg-[var(--color-panel-2)] p-2">
+ <NoteComposer
+ busy={notes.busy}
+ placeholder="what is wrong here"
+ testId="timed-note-input"
+ head={<span className="num text-[11px] text-[var(--color-meter)]">{ts(pending)}</span>}
+ onCancel={() => setPending(null)}
+ onAdd={(text) => {
+ const t = pending;
+ setPending(null);
+ void add(t, text);
+ }}
+ />
+ </div>
+ )}
+ {marks.length > 0 && (
+ <ul className="space-y-1">
+ {marks.map((n) => {
+ const r = n.anchor.resolved;
+ return (
+ <li key={n.id} data-mark-at={n.anchor.t} data-mark-entry={n.anchor.entry ?? ""}>
+ <NoteThread
+ note={n}
+ write={notes.write}
+ busy={notes.busy}
+ head={
+ <>
+ <button
+ type="button"
+ onClick={() => seek(n.anchor.t)}
+ className="num text-[11px] text-[var(--color-sel)] underline"
+ title="seek here"
+ >
+ {ts(n.anchor.t)}
+ </button>
+ {n.anchor.entry && (
+ <span className="font-mono text-[11px] text-[var(--color-dim)]" title={r?.quote ?? undefined}>
+ {n.anchor.entry}
+ {r?.title ? ` · ${r.title}` : ""}
+ </span>
+ )}
+ {r?.approx && <span className={badgeVariants({ variant: "info", size: "sm" })}>approx</span>}
+ {r?.url && (
+ <a href={r.url} target="_blank" rel="noreferrer" className="text-[11px] text-[var(--color-sel)] hover:underline">
+ source
+ </a>
+ )}
+ </>
+ }
+ />
+ </li>
+ );
+ })}
+ </ul>
+ )}
+ </div>
+ );
+}
+
+/**
+ * A video with its timed notes under it: what the project's final cut, a
+ * take's preview or an article's report video mounts. `notes` defaults to the
+ * page's shared handle for `target` (NotesProvider), else one of its own.
+ */
+export function TimedVideo({
+ target,
+ file,
+ src,
+ take,
+ resolveProject,
+ notes: given,
+ poster,
+ testId,
+ className = "aspect-video w-full rounded border border-[var(--color-line)] bg-black",
+}: {
+ target: NotesTarget;
+ file: string;
+ src: string;
+ take?: string;
+ resolveProject?: string | null;
+ notes?: UseNotes;
+ poster?: string;
+ testId?: string;
+ className?: string;
+}) {
+ const own = useSharedNotes(given ? null : target);
+ const notes = given ?? own;
+ const [el, setEl] = useState<HTMLVideoElement | null>(null);
+ return (
+ <div className="space-y-1">
+ <video ref={setEl} data-testid={testId} src={src} poster={poster} controls preload="metadata" playsInline className={className} />
+ <TimedNotes notes={notes} file={file} video={el} take={take} resolveProject={resolveProject} />
+ </div>
+ );
+}
diff --git a/umtool/components/projects/ClipBench.tsx b/umtool/components/projects/ClipBench.tsx
@@ -1,5 +1,8 @@
"use client";
+import AnchoredNotes from "@/components/notes/AnchoredNotes";
+import GeneratedBanner from "@/components/notes/GeneratedBanner";
+import { useSharedNotes, wroteNotes } from "@/components/notes/NotesProvider";
import { useCallback, useEffect, useRef, useState } from "react";
import Link from "next/link";
import { useRouter } from "next/navigation";
@@ -256,6 +259,8 @@ export type ClipBenchData = {
siblings: Sibling[];
/** Is the cut built with the on-screen panel (`render.chrome`)? */
deckOn: boolean;
+ /** The manifest's `generatedBy`: edits here are noted for the agent that runs it. */
+ generatedBy?: string | null;
};
const hms = (t: number) => {
@@ -377,6 +382,9 @@ type Session = {
};
export default function ClipBench({ data }: { data: ClipBenchData }) {
+ // The project's notes: this clip's row notes, and the edit notes a save on a
+ // generated manifest leaves (the save says so, and they are re-read).
+ const notes = useSharedNotes({ project: data.project });
const [clip, setClip] = useState<Clip>(data.clip);
const [windows, setWindows] = useState<Win[]>(data.windows);
const [cues, setCues] = useState<Cue[]>(data.cues);
@@ -1084,6 +1092,7 @@ export default function ClipBench({ data }: { data: ClipBenchData }) {
});
const j = (await r.json()) as Record<string, unknown>;
setBusy(null);
+ if (wroteNotes(j)) void notes.reload();
if (!r.ok) {
setNote(
j.stale
@@ -1134,7 +1143,7 @@ export default function ClipBench({ data }: { data: ClipBenchData }) {
void refresh();
return true;
},
- [data.project, clip],
+ [data.project, clip, notes.reload],
);
/**
@@ -1484,7 +1493,7 @@ export default function ClipBench({ data }: { data: ClipBenchData }) {
// Returned, not just stored: "did anything actually arrive" is a question
// the caller has to answer before it claims a fetch worked.
return j;
- }, [data.project, clip.id]);
+ }, [data.project, clip.id, notes.reload]);
// ---- fetching more --------------------------------------------------------
//
@@ -1684,6 +1693,7 @@ export default function ClipBench({ data }: { data: ClipBenchData }) {
}
setClip((prev) => fromEntry(prev, j.entry ?? {}));
token.current = String(j.token ?? "");
+ if (wroteNotes(j)) void notes.reload();
setCutScore(j.score ?? null);
setNote(`cut to the quote (match ${(j.score ?? 0).toFixed(2)})`);
}, [data.project, clip.id]);
@@ -1849,6 +1859,7 @@ export default function ClipBench({ data }: { data: ClipBenchData }) {
data-verdict={verdict}
className="flex flex-col gap-2 lg:h-full lg:min-h-0 lg:overflow-hidden"
>
+ <GeneratedBanner generatedBy={data.generatedBy} />
{/* ---- where you are, where you can go, and what will be burned in ---- */}
<div className="flex flex-wrap items-center gap-x-3 gap-y-1 text-[11px]">
<span className="num font-mono text-[var(--color-text)]">
@@ -2942,6 +2953,9 @@ export default function ClipBench({ data }: { data: ClipBenchData }) {
})}
</div>
+ {/* ---- notes on this clip, for whoever acts on them ---- */}
+ <AnchoredNotes notes={notes} pin={{ kind: "entry", entry: clip.id }} startOpen={false} />
+
{/* ---- on-screen: what the panel under the footage says ----
Beside the header's fields because it is the same sitting: the
words you hear are the words a title should summarise. Both
diff --git a/umtool/components/projects/ClipBenchPage.tsx b/umtool/components/projects/ClipBenchPage.tsx
@@ -105,6 +105,7 @@ export default async function ClipBenchPage({
// Whether the cut is built with the on-screen panel. Decides whether the
// bench composes a preview of it; the fields are there either way.
deckOn: deckOn(manifest.render ?? {}),
+ generatedBy: typeof manifest.generatedBy === "string" ? manifest.generatedBy : null,
view,
windows: windows.map((w: { name: string; from: number; to: number }) => ({
name: w.name,
diff --git a/umtool/components/projects/OnscreenSection.tsx b/umtool/components/projects/OnscreenSection.tsx
@@ -1,5 +1,10 @@
"use client";
+import GeneratedBanner from "@/components/notes/GeneratedBanner";
+import { TimedVideo } from "@/components/notes/TimedNotes";
+import StructureEditors from "./StructureEditors";
+import { MANIFEST_CHANGED } from "./timelineApi";
+import { announceNotesChanged, wroteNotes } from "@/components/notes/NotesProvider";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { badgeVariants } from "@/components/ui/badge";
import { buttonVariants } from "@/components/ui/button";
@@ -772,8 +777,11 @@ export default function OnscreenSection({
project,
entries,
built,
+ generatedBy = null,
}: {
project: string;
+ /** The manifest's `generatedBy`: its edits are noted for the agent, and the section says so. */
+ generatedBy?: string | null;
/**
* Is the default cut's deliverable on disk? The server already knows, and
* asking the video route about a file that is not there is a 404 in the
@@ -821,6 +829,9 @@ export default function OnscreenSection({
const [job, setJob] = useState<JobView | null>(null);
const [jobError, setJobError] = useState<string | null>(null);
const [finalV, setFinalV] = useState<number | null | "none">(null);
+ // The built file's project-relative path (the video route's x-video): what a
+ // timed note on it is anchored to.
+ const [finalRel, setFinalRel] = useState<string | null>(null);
// The switch as pressed, while its write is in flight: the box follows the
// hand at once and falls back to the manifest's answer if the write fails.
const [switching, setSwitching] = useState<boolean | null>(null);
@@ -933,6 +944,7 @@ export default function OnscreenSection({
const loadFinal = useCallback(async () => {
const r = await fetch(`/api/report/video?${q}&kind=final`, { method: "HEAD", cache: "no-store" }).catch(() => null);
const m = r?.ok ? r.headers.get("x-video-mtime") : null;
+ setFinalRel(r?.ok ? r.headers.get("x-video") : null);
setFinalV(m ? Number(m) : "none");
}, [q]);
@@ -959,6 +971,28 @@ export default function OnscreenSection({
// `built` and `variant` are read once per load; loadFinal already follows the variant.
}, [loadChrome, loadRows, loadFinal, recompose]);
+ // A structural write elsewhere on the page (the timeline, the teaser and
+ // posts editors) changed the manifest: re-read the tokens and the rows,
+ // keeping every unsaved edit here, or the next save would 409.
+ const formDirtyRef = useRef(formDirty);
+ formDirtyRef.current = formDirty;
+ useEffect(() => {
+ const on = (e: Event) => {
+ if ((e as CustomEvent).detail?.project !== project) return;
+ void (async () => {
+ const r = await fetch(`/api/report/chrome?project=${encodeURIComponent(project)}`, { cache: "no-store" });
+ const j = (await r.json().catch(() => null)) as (ChromeDoc & { error?: string }) | null;
+ if (r.ok && j) {
+ token.current = j.token;
+ if (!formDirtyRef.current) setDoc(j);
+ }
+ await loadRows(true);
+ })();
+ };
+ window.addEventListener(MANIFEST_CHANGED, on);
+ return () => window.removeEventListener(MANIFEST_CHANGED, on);
+ }, [project, loadRows]);
+
// ---- writing ------------------------------------------------------------
const queued = useCallback(<T,>(fn: () => Promise<T>): Promise<T> => {
const run = saving.current.catch(() => null).then(fn);
@@ -978,6 +1012,7 @@ export default function OnscreenSection({
body: JSON.stringify({ project, chrome, token: token.current }),
});
const j = (await r.json()) as Record<string, unknown>;
+ if (wroteNotes(j)) announceNotesChanged(project);
setBusy(null);
if (!r.ok) {
if (j.stale) {
@@ -1072,6 +1107,7 @@ export default function OnscreenSection({
body: JSON.stringify({ project, onscreen: draftMap(), token: token.current }),
});
const j = (await r.json()) as Record<string, unknown>;
+ if (wroteNotes(j)) announceNotesChanged(project);
setBusy(null);
if (!r.ok) {
// The drafts stay exactly as typed. A refused batch is a typo or a
@@ -1114,6 +1150,7 @@ export default function OnscreenSection({
body: JSON.stringify({ project, posts: postsDraftMap(), token: token.current }),
});
const j = (await r.json()) as Record<string, unknown>;
+ if (wroteNotes(j)) announceNotesChanged(project);
setBusy(null);
if (!r.ok) {
if (j.stale) {
@@ -1626,6 +1663,7 @@ export default function OnscreenSection({
data-onscreen={on ? "on" : "off"}
className="space-y-3 rounded border border-[var(--color-line)] bg-[var(--color-panel)] p-3"
>
+ <GeneratedBanner generatedBy={generatedBy} />
{/* ---- the switch ---- */}
<div className="flex flex-wrap items-center gap-x-3 gap-y-1">
<h2 className="micro">on-screen</h2>
@@ -2050,12 +2088,12 @@ export default function OnscreenSection({
<div className="space-y-1">
<span className="micro">the built video</span>
{typeof finalV === "number" ? (
- <video
- data-testid="onscreen-final-video"
+ <TimedVideo
+ target={{ project }}
+ testId="onscreen-final-video"
+ file={finalRel ?? `out/${variant || "sourced"}/final.mp4`}
src={`/api/report/video?${q}&kind=final&v=${finalV}`}
- controls
- preload="metadata"
- className="aspect-video w-full rounded border border-[var(--color-line)] bg-black"
+ resolveProject={project}
/>
) : (
<p data-testid="onscreen-no-final" className="text-[11px] text-[var(--color-dim)]">
@@ -2093,6 +2131,14 @@ export default function OnscreenSection({
<div className="mt-1.5">{postsBlock}</div>
</details>
)}
+
+ {/* ---- the lists: teasers, posts, fact-check labels ---- */}
+ <details data-testid="structure-folded">
+ <summary className="cursor-pointer text-[11px] text-[var(--color-dim)]">teasers, posts and fact-check labels</summary>
+ <div className="mt-1.5">
+ <StructureEditors project={project} />
+ </div>
+ </details>
</section>
);
}
diff --git a/umtool/components/projects/ReportProject.tsx b/umtool/components/projects/ReportProject.tsx
@@ -24,6 +24,9 @@ import OnscreenSection from "./OnscreenSection";
import ReportBuildChain from "./ReportBuildChain";
import SnapshotButton from "./SnapshotButton";
import TagCitedButton from "./TagCitedButton";
+import { RowControls, RowNotes, TimelineList } from "./TimelineEditor";
+import GeneratedBanner from "@/components/notes/GeneratedBanner";
+import { NotesProvider } from "@/components/notes/NotesProvider";
import { decisionsForProject } from "@/lib/projects";
import { badgeVariants, type BadgeVariants } from "@/components/ui/badge";
import { buttonVariants } from "@/components/ui/button";
@@ -257,7 +260,9 @@ export default async function ReportProject({
)}
</div>
+ <NotesProvider target={{ project: project.id }}>
<main className="deck-main flex-1 space-y-4 p-4">
+ <GeneratedBanner generatedBy={typeof m.generatedBy === "string" ? m.generatedBy : null} />
{/* --- the author's own account of the cut, first ------------------ */}
{readme && (
<section data-readme="" className="rounded border border-[var(--color-line)] bg-[var(--color-panel)] px-3 py-2">
@@ -430,6 +435,7 @@ export default async function ReportProject({
because the panel is part of the video that gets delivered. */}
<OnscreenSection
project={project.id}
+ generatedBy={typeof m.generatedBy === "string" ? m.generatedBy : null}
entries={entries.map((e) => ({ id: e.id, kind: e.kind, segment: !!e.segment }))}
built={!!build.built}
/>
@@ -459,8 +465,8 @@ export default async function ReportProject({
/>
</div>
)}
- <ul className="space-y-1">
- {entries.map((e) => {
+ <TimelineList project={project.id} count={entries.length}>
+ {entries.map((e, at) => {
// Anything that is not a CLIP renders generically. The timeline's
// vocabulary is open -- one real manifest carries `scroll` and
// `chart` beside its cards -- and a page that only knows two words
@@ -468,17 +474,23 @@ export default async function ReportProject({
if (e.kind !== "clip") {
return (
<li
- key={e.id}
+ key={`${e.id}@${at}`}
data-entry={e.id}
data-kind={e.kind}
- className="flex flex-wrap items-baseline gap-2 rounded border border-dashed border-[var(--color-line)] px-3 py-1.5 text-[12px]"
+ data-at={at}
+ tabIndex={0}
+ className="flex flex-wrap items-baseline gap-2 rounded border border-dashed border-[var(--color-line)] px-3 py-1.5 text-[12px] outline-none focus:border-[var(--color-sel)]"
>
+ <RowControls id={e.id} at={at} />
<span className="font-mono text-[var(--color-dim)]">{e.id}</span>
<Pill>{e.style ? `${e.kind} · ${e.style}` : e.kind}</Pill>
<span className="text-[var(--color-text)]">
{e.heading ?? e.title ?? e.label ?? ""}
</span>
{e.seconds != null && <span className="num micro ml-auto">{e.seconds}s</span>}
+ <div className="basis-full">
+ <RowNotes id={e.id} project={project.id} />
+ </div>
</li>
);
}
@@ -487,15 +499,18 @@ export default async function ReportProject({
const midSentence = e.endsSentence === false && !e.lockEnd && !e.lock;
return (
<li
- key={e.id}
+ key={`${e.id}@${at}`}
data-entry={e.id}
data-kind="clip"
+ data-at={at}
+ tabIndex={0}
data-cached={e.cached ? "1" : "0"}
data-fetched={e.fetched ? "1" : "0"}
data-mid-sentence={midSentence ? "1" : "0"}
- className="rounded border border-[var(--color-line)] bg-[var(--color-panel)] px-3 py-1.5"
+ className="rounded border border-[var(--color-line)] bg-[var(--color-panel)] px-3 py-1.5 outline-none focus:border-[var(--color-sel)]"
>
<div className="flex flex-wrap items-baseline gap-2 text-[12px]">
+ <RowControls id={e.id} at={at} />
<span className="font-mono text-[var(--color-sel)]">{e.id}</span>
<span className="num font-mono text-[11px] text-[var(--color-dim)]">
{e.video} {hms(e.start)}–{hms(e.end)} ({(e.end - e.start).toFixed(1)}s)
@@ -568,10 +583,11 @@ export default async function ReportProject({
set <code className="font-mono">lock</code> if this window is deliberate
</p>
)}
+ <RowNotes id={e.id} project={project.id} />
</li>
);
})}
- </ul>
+ </TimelineList>
<div className="mt-2">
<Link
href={`/browse/${project.id}${showAll ? "" : "?all=1"}`}
@@ -771,6 +787,7 @@ export default async function ReportProject({
</section>
)}
</main>
+ </NotesProvider>
</div>
);
}
diff --git a/umtool/components/projects/StructureEditors.tsx b/umtool/components/projects/StructureEditors.tsx
@@ -0,0 +1,286 @@
+"use client";
+
+import { useRouter } from "next/navigation";
+import { useCallback, useEffect, useRef, useState } from "react";
+import { announceNotesChanged, wroteNotes } from "@/components/notes/NotesProvider";
+import { buttonVariants } from "@/components/ui/button";
+import { MANIFEST_CHANGED, announceManifestChanged, refusalOf, timelineOp } from "./timelineApi";
+
+// The parts of the cut that are lists, edited in place: each teaser's lines
+// and timing, the posts, and the fact-check's labels and colours. Every save
+// is one structural write (POST /api/report/timeline): checked by the build's
+// own validators, snapshotted first, and -- on a generated manifest -- noted
+// for the agent.
+
+type Teaser = { at: number; id: string; lines: unknown[]; beat: number | null; dip: { fade: number; black: number } | null; tail: string | null; tailWait: number | null };
+type Post = Record<string, unknown> & { id: string };
+type Verdict = { label: string; color: string };
+type Doc = {
+ teasers: Teaser[];
+ posts: Post[];
+ deckOn: boolean;
+ factcheck: { verdicts?: Record<string, Partial<Verdict>>; stamp?: Record<string, unknown>; tally?: Record<string, unknown> } | null;
+ factcheckResolved: { verdicts: Record<string, Verdict>; stamp: { seconds: number; position: string }; tally: { show: boolean; position: string } };
+ verdicts: string[];
+ token: string | null;
+};
+
+const input =
+ "rounded border border-[var(--color-line)] bg-[var(--color-ink)] px-1.5 py-0.5 text-[12px] text-[var(--color-text)] outline-none focus:border-[var(--color-sel)]";
+const num = (v: string) => (v.trim() === "" ? null : Number(v));
+
+export default function StructureEditors({ project }: { project: string }) {
+ const router = useRouter();
+ const [doc, setDoc] = useState<Doc | null>(null);
+ const [error, setError] = useState<string | null>(null);
+ const [busy, setBusy] = useState(false);
+
+ const load = useCallback(async () => {
+ const r = await fetch(`/api/report/timeline?project=${encodeURIComponent(project)}`, { cache: "no-store" });
+ const j = await r.json();
+ if (r.ok) setDoc(j as Doc);
+ else setError(String(j.error ?? r.status));
+ }, [project]);
+
+ useEffect(() => {
+ void load();
+ const on = (e: Event) => {
+ if ((e as CustomEvent).detail?.project === project) void load();
+ };
+ window.addEventListener(MANIFEST_CHANGED, on);
+ return () => window.removeEventListener(MANIFEST_CHANGED, on);
+ }, [load, project]);
+
+ const run = async (op: string, args: Record<string, unknown>) => {
+ setBusy(true);
+ setError(null);
+ const res = await timelineOp(project, doc?.token ?? null, op, args);
+ setBusy(false);
+ if (!res.ok) {
+ setError(refusalOf(res));
+ if (res.json.stale) await load();
+ return false;
+ }
+ // Adopt the new token now: the reload the announcement starts may land
+ // after the next save is pressed.
+ if (typeof res.json.token === "string") setDoc((d) => (d ? { ...d, token: res.json.token as string } : d));
+ announceManifestChanged(project);
+ if (wroteNotes(res.json)) announceNotesChanged(project);
+ router.refresh();
+ return true;
+ };
+
+ if (!doc) return error ? <p className="text-[11px] text-[var(--color-bad)]">{error}</p> : null;
+ return (
+ <div data-testid="structure-editors" className="space-y-3">
+ {error && (
+ <p data-testid="structure-error" className="text-[11px] text-[var(--color-bad)]">
+ {error}
+ </p>
+ )}
+ {doc.teasers.map((t) => (
+ <TeaserEditor key={`${t.id}@${t.at}`} teaser={t} busy={busy} save={(patch) => run("teaser", { id: t.id, at: t.at, patch })} />
+ ))}
+ <PostsEditor posts={doc.posts} busy={busy} save={(post) => run("post", { post })} remove={(id) => run("post-remove", { id })} />
+ {doc.deckOn && <FactcheckEditor doc={doc} busy={busy} save={(factcheck) => run("factcheck", { factcheck })} />}
+ </div>
+ );
+}
+
+function TeaserEditor({ teaser: t, busy, save }: { teaser: Teaser; busy: boolean; save: (patch: Record<string, unknown>) => Promise<boolean> }) {
+ // Plain lines edit as one per row; a teaser whose lines carry settings
+ // (break, role, replace…) edits as the JSON it is, so nothing is dropped.
+ const plain = t.lines.every((l) => typeof l === "string");
+ const linesText = () => (plain ? (t.lines as string[]).join("\n") : JSON.stringify(t.lines, null, 2));
+ const [lines, setLines] = useState(linesText);
+ const [beat, setBeat] = useState(t.beat == null ? "" : String(t.beat));
+ const [tail, setTail] = useState(t.tail ?? "");
+ const [tailWait, setTailWait] = useState(t.tailWait == null ? "" : String(t.tailWait));
+ const [fade, setFade] = useState(t.dip ? String(t.dip.fade) : "");
+ const [black, setBlack] = useState(t.dip ? String(t.dip.black) : "");
+ const [local, setLocal] = useState<string | null>(null);
+ // Unsaved edits win over a reload: the re-read every structural write sets
+ // off can land after somebody has started typing again.
+ const dirty = useRef(false);
+ const edit = <T,>(set: (v: T) => void) => (v: T) => {
+ dirty.current = true;
+ set(v);
+ };
+ useEffect(() => {
+ if (dirty.current) return;
+ setLines(linesText());
+ setBeat(t.beat == null ? "" : String(t.beat));
+ setTail(t.tail ?? "");
+ setTailWait(t.tailWait == null ? "" : String(t.tailWait));
+ setFade(t.dip ? String(t.dip.fade) : "");
+ setBlack(t.dip ? String(t.dip.black) : "");
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [t]);
+
+ const submit = () => {
+ setLocal(null);
+ let parsed: unknown[];
+ try {
+ parsed = plain ? lines.split("\n").map((l) => l.trim()).filter(Boolean) : JSON.parse(lines);
+ } catch {
+ setLocal("the lines are not valid JSON");
+ return;
+ }
+ const dip = fade.trim() || black.trim() ? { fade: num(fade), black: num(black) } : null;
+ void save({ lines: parsed, beat: num(beat), tail: tail.trim() || null, tailWait: num(tailWait), dip }).then((ok) => {
+ if (ok) dirty.current = false;
+ });
+ };
+
+ return (
+ <div data-teaser-editor={t.id} className="space-y-1 rounded border border-[var(--color-line)] p-2">
+ <div className="micro">teaser · {t.id}</div>
+ <textarea data-testid={`teaser-lines-${t.id}`} rows={Math.min(8, Math.max(2, lines.split("\n").length))} value={lines} onChange={(e) => edit(setLines)(e.target.value)} className={`${input} w-full font-mono`} />
+ <div className="flex flex-wrap items-center gap-2 text-[11px] text-[var(--color-dim)]">
+ <label>beat <input data-testid={`teaser-beat-${t.id}`} value={beat} onChange={(e) => edit(setBeat)(e.target.value)} className={`${input} w-14`} /></label>
+ <label>tail <input value={tail} onChange={(e) => edit(setTail)(e.target.value)} className={`${input} w-16`} /></label>
+ <label>tail wait <input value={tailWait} onChange={(e) => edit(setTailWait)(e.target.value)} className={`${input} w-14`} /></label>
+ <label>dip fade <input value={fade} onChange={(e) => edit(setFade)(e.target.value)} className={`${input} w-14`} /></label>
+ <label>black <input value={black} onChange={(e) => edit(setBlack)(e.target.value)} className={`${input} w-14`} /></label>
+ <button type="button" data-testid={`teaser-save-${t.id}`} disabled={busy} onClick={submit} className={buttonVariants({ variant: "primary", size: "sm" })}>
+ Save teaser
+ </button>
+ {local && <span className="text-[var(--color-bad)]">{local}</span>}
+ </div>
+ </div>
+ );
+}
+
+const POST_FIELDS = ["platform", "author", "handle", "date", "url", "shot", "flag"] as const;
+
+function PostsEditor({
+ posts,
+ busy,
+ save,
+ remove,
+}: {
+ posts: Post[];
+ busy: boolean;
+ save: (post: Post) => Promise<boolean>;
+ remove: (id: string) => Promise<boolean>;
+}) {
+ const [adding, setAdding] = useState(false);
+ return (
+ <div data-testid="posts-editor" className="space-y-1">
+ <div className="flex items-center gap-2">
+ <span className="micro">posts — {posts.length}</span>
+ <button type="button" data-testid="post-add" onClick={() => setAdding((a) => !a)} className="text-[11px] text-[var(--color-sel)] hover:underline">
+ + post
+ </button>
+ </div>
+ {adding && <PostRow post={{ id: "", platform: "x" }} fresh busy={busy} save={async (p) => (await save(p)) && (setAdding(false), true)} />}
+ {posts.map((p) => (
+ <PostRow key={p.id} post={p} busy={busy} save={save} remove={() => void remove(p.id)} />
+ ))}
+ </div>
+ );
+}
+
+function PostRow({ post, fresh = false, busy, save, remove }: { post: Post; fresh?: boolean; busy: boolean; save: (p: Post) => Promise<boolean>; remove?: () => void }) {
+ const [d, setD] = useState<Record<string, string>>(() => {
+ const out: Record<string, string> = { id: post.id, text: String(post.text ?? "") };
+ for (const k of POST_FIELDS) out[k] = post[k] == null ? "" : String(post[k]);
+ return out;
+ });
+ const set = (k: string, v: string) => setD((x) => ({ ...x, [k]: v }));
+ return (
+ <div data-post-row={post.id || "new"} className="space-y-1 rounded border border-[var(--color-line)] p-2 text-[11px]">
+ <div className="flex flex-wrap items-center gap-1.5 text-[var(--color-dim)]">
+ {fresh ? (
+ <label>id <input data-testid="post-id" value={d.id} onChange={(e) => set("id", e.target.value)} className={`${input} w-28 font-mono`} /></label>
+ ) : (
+ <span className="font-mono text-[var(--color-text)]">{post.id}</span>
+ )}
+ <select value={d.platform} onChange={(e) => set("platform", e.target.value)} className={`${input} text-[11px]`}>
+ {["x", "bluesky", "web"].map((p) => (
+ <option key={p}>{p}</option>
+ ))}
+ </select>
+ {(["author", "handle", "date", "url", "shot", "flag"] as const).map((k) => (
+ <label key={k}>
+ {k} <input data-testid={`post-${k}`} value={d[k]} onChange={(e) => set(k, e.target.value)} className={`${input} ${k === "url" ? "w-56" : "w-28"}`} />
+ </label>
+ ))}
+ </div>
+ <textarea data-testid="post-text" rows={2} value={d.text} onChange={(e) => set("text", e.target.value)} className={`${input} w-full`} />
+ <div className="flex gap-2">
+ <button
+ type="button"
+ data-testid="post-save"
+ disabled={busy}
+ onClick={() => {
+ const p: Post = { id: d.id.trim(), text: d.text };
+ for (const k of POST_FIELDS) p[k] = d[k];
+ void save(p);
+ }}
+ className={buttonVariants({ variant: "primary", size: "sm" })}
+ >
+ {fresh ? "Add post" : "Save post"}
+ </button>
+ {remove && (
+ <button type="button" data-testid="post-remove" disabled={busy} onClick={remove} className={buttonVariants({ variant: "destructive", size: "sm" })}>
+ Remove
+ </button>
+ )}
+ </div>
+ </div>
+ );
+}
+
+function FactcheckEditor({ doc, busy, save }: { doc: Doc; busy: boolean; save: (f: Record<string, unknown> | null) => Promise<boolean> }) {
+ const given = doc.factcheck ?? {};
+ const [v, setV] = useState<Record<string, { label: string; color: string }>>(() =>
+ Object.fromEntries(doc.verdicts.map((k) => [k, { label: String(given.verdicts?.[k]?.label ?? ""), color: String(given.verdicts?.[k]?.color ?? "") }])),
+ );
+ const [seconds, setSeconds] = useState(given.stamp?.seconds == null ? "" : String(given.stamp.seconds));
+ const submit = () => {
+ const verdicts: Record<string, Record<string, string>> = {};
+ for (const [k, o] of Object.entries(v)) {
+ const one: Record<string, string> = {};
+ if (o.label.trim()) one.label = o.label.trim();
+ if (o.color.trim()) one.color = o.color.trim();
+ if (Object.keys(one).length) verdicts[k] = one;
+ }
+ // Only what differs from the defaults is written, as the deck's settings are.
+ const out: Record<string, unknown> = { ...given };
+ if (Object.keys(verdicts).length) out.verdicts = verdicts;
+ else delete out.verdicts;
+ const stamp = { ...(given.stamp ?? {}) } as Record<string, unknown>;
+ if (seconds.trim()) stamp.seconds = Number(seconds);
+ else delete stamp.seconds;
+ if (Object.keys(stamp).length) out.stamp = stamp;
+ else delete out.stamp;
+ void save(Object.keys(out).length ? out : null);
+ };
+ return (
+ <div data-testid="factcheck-editor" className="space-y-1 rounded border border-[var(--color-line)] p-2 text-[11px]">
+ <div className="micro">fact-check labels</div>
+ <div className="grid grid-cols-[max-content_1fr_max-content] items-center gap-x-2 gap-y-1">
+ {doc.verdicts.map((k) => {
+ const def = doc.factcheckResolved.verdicts[k];
+ return (
+ <div key={k} className="contents">
+ <span className="font-mono text-[var(--color-dim)]">{k}</span>
+ <input data-testid={`fc-label-${k}`} placeholder={def?.label} value={v[k]?.label ?? ""} onChange={(e) => setV((x) => ({ ...x, [k]: { ...x[k], label: e.target.value } }))} className={input} />
+ <span className="flex items-center gap-1">
+ <input data-testid={`fc-color-${k}`} placeholder={def?.color} value={v[k]?.color ?? ""} onChange={(e) => setV((x) => ({ ...x, [k]: { ...x[k], color: e.target.value } }))} className={`${input} w-20 font-mono`} />
+ <span className="inline-block h-3 w-3 rounded" style={{ background: v[k]?.color || def?.color }} />
+ </span>
+ </div>
+ );
+ })}
+ </div>
+ <div className="flex items-center gap-2 text-[var(--color-dim)]">
+ <label>stamp seconds <input value={seconds} placeholder={String(doc.factcheckResolved.stamp.seconds)} onChange={(e) => setSeconds(e.target.value)} className={`${input} w-14`} /></label>
+ <button type="button" data-testid="fc-save" disabled={busy} onClick={submit} className={buttonVariants({ variant: "primary", size: "sm" })}>
+ Save labels
+ </button>
+ </div>
+ </div>
+ );
+}
diff --git a/umtool/components/projects/TakesBench.tsx b/umtool/components/projects/TakesBench.tsx
@@ -1,6 +1,9 @@
"use client";
-import { useEffect, useRef, useState } from "react";
+import { useCallback, useEffect, useRef, useState } from "react";
+import AnchoredNotes from "@/components/notes/AnchoredNotes";
+import { NotesProvider, useSharedNotes } from "@/components/notes/NotesProvider";
+import TimedNotes from "@/components/notes/TimedNotes";
import { badgeVariants, type BadgeVariants } from "@/components/ui/badge";
import { buttonVariants } from "@/components/ui/button";
import { fmtAgo } from "@/lib/format";
@@ -128,6 +131,7 @@ export default function TakesBench({
};
return (
+ <NotesProvider target={{ project }}>
<div className="space-y-6">
{groups.map((g) => {
const tally = VERDICTS.map((v) => [v.id, g.takes.filter((t) => verdicts[t.id]?.verdict === v.id).length] as const)
@@ -166,6 +170,7 @@ export default function TakesBench({
{g.takes.map((t) => (
<TakeCard
key={t.id}
+ project={project}
take={t}
src={previewSrc(project, t)}
verdict={verdicts[t.id] ?? null}
@@ -194,10 +199,12 @@ export default function TakesBench({
);
})}
</div>
+ </NotesProvider>
);
}
function TakeCard({
+ project,
take: t,
src,
verdict,
@@ -209,6 +216,7 @@ function TakeCard({
onUnmute,
save,
}: {
+ project: string;
take: Take;
src: string;
verdict: TakeVerdict | null;
@@ -220,6 +228,16 @@ function TakeCard({
onUnmute: () => void;
save: (patch: { verdict?: Verdict | null; note?: string }) => Promise<void>;
}) {
+ const notes = useSharedNotes({ project });
+ // The element, for the timed notes; the parent's callback (audio routing)
+ // still gets it. Stable, so it runs once per mount, not once per render.
+ const [el, setEl] = useState<HTMLVideoElement | null>(null);
+ const parentRef = useRef(videoRef);
+ parentRef.current = videoRef;
+ const bindVideo = useCallback((node: HTMLVideoElement | null) => {
+ parentRef.current(node);
+ setEl(node);
+ }, []);
const [note, setNote] = useState(verdict?.note ?? "");
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
@@ -254,7 +272,7 @@ function TakeCard({
>
{t.previewSize != null ? (
<video
- ref={videoRef}
+ ref={bindVideo}
src={src}
controls
preload="metadata"
@@ -327,6 +345,14 @@ function TakeCard({
/>
</div>
{error && <span className="text-[11px] text-[var(--color-bad)]">{error}</span>}
+ {/* The conversation about this take: notes on the take as a whole, and
+ notes at a second of its preview, resolved to the entry on screen.
+ Both land in the project's notes.json, which the agent that made
+ the takes reads back (`umtool notes`) beside verdicts.json. */}
+ <AnchoredNotes notes={notes} pin={{ kind: "take", take: t.id }} label="take notes" />
+ {t.previewSize != null && (
+ <TimedNotes notes={notes} file={`takes/${t.id}/${t.preview}`} video={el} take={t.id} resolveProject={project} />
+ )}
</div>
</article>
);
diff --git a/umtool/components/projects/TimelineEditor.tsx b/umtool/components/projects/TimelineEditor.tsx
@@ -0,0 +1,205 @@
+"use client";
+
+import { useRouter } from "next/navigation";
+import { createContext, useCallback, useContext, useEffect, useRef, useState } from "react";
+import AnchoredNotes from "@/components/notes/AnchoredNotes";
+import { announceNotesChanged, useSharedNotes, wroteNotes } from "@/components/notes/NotesProvider";
+import { buttonVariants } from "@/components/ui/button";
+import { MANIFEST_CHANGED, announceManifestChanged, refusalOf, timelineOp } from "./timelineApi";
+
+// Re-ordering the cut, on the project page.
+//
+// The rows stay the server's (each `<li data-entry data-at>` the page always
+// drew); this list adds what moves them. Drag a row by its handle, or focus it
+// and press alt+↑ / alt+↓; each row's menu duplicates it, removes it, or
+// inserts a clip after it (`<channel>/<video>@<start>-<end>`). Every one is a
+// structural write (POST /api/report/timeline): snapshotted first, so "Undo"
+// restores the cut as it was before the last burst of edits.
+
+type Ctx = {
+ project: string;
+ busy: boolean;
+ run: (op: string, args: Record<string, unknown>) => Promise<boolean>;
+ dragging: React.MutableRefObject<{ id: string; at: number } | null>;
+};
+const TimelineCtx = createContext<Ctx | null>(null);
+
+const rowOf = (el: EventTarget | null) => (el instanceof Element ? (el.closest("li[data-entry][data-at]") as HTMLLIElement | null) : null);
+const rowKey = (li: HTMLLIElement) => ({ id: li.dataset.entry ?? "", at: Number(li.dataset.at) });
+
+export function TimelineList({ project, count, children }: { project: string; count: number; children: React.ReactNode }) {
+ const router = useRouter();
+ // The manifest's write token, in a ref as well as state: a key pressed before
+ // the first read came back must wait for it, not send no token at all.
+ const tokenRef = useRef<string | null>(null);
+ const [busy, setBusy] = useState(false);
+ const [error, setError] = useState<string | null>(null);
+ const [over, setOver] = useState<number | null>(null);
+ const dragging = useRef<{ id: string; at: number } | null>(null);
+
+ const loadToken = useCallback(async () => {
+ const r = await fetch(`/api/report/timeline?project=${encodeURIComponent(project)}`, { cache: "no-store" });
+ const j = await r.json().catch(() => ({}));
+ if (r.ok) tokenRef.current = typeof j.token === "string" ? j.token : null;
+ return tokenRef.current;
+ }, [project]);
+ useEffect(() => {
+ void loadToken();
+ const on = (e: Event) => {
+ if ((e as CustomEvent).detail?.project === project) void loadToken();
+ };
+ window.addEventListener(MANIFEST_CHANGED, on);
+ return () => window.removeEventListener(MANIFEST_CHANGED, on);
+ }, [loadToken, project]);
+
+ const run = useCallback(
+ async (op: string, args: Record<string, unknown>) => {
+ setBusy(true);
+ setError(null);
+ const res = await timelineOp(project, tokenRef.current ?? (await loadToken()), op, args);
+ setBusy(false);
+ if (!res.ok) {
+ setError(refusalOf(res));
+ if (res.json.stale) await loadToken();
+ return false;
+ }
+ tokenRef.current = typeof res.json.token === "string" ? res.json.token : null;
+ announceManifestChanged(project);
+ if (wroteNotes(res.json)) announceNotesChanged(project);
+ router.refresh();
+ return true;
+ },
+ [project, loadToken, router],
+ );
+
+ const onKeyDown = (e: React.KeyboardEvent) => {
+ if (!e.altKey || (e.key !== "ArrowUp" && e.key !== "ArrowDown")) return;
+ if (e.target instanceof HTMLElement && /^(INPUT|TEXTAREA|SELECT)$/.test(e.target.tagName)) return;
+ const li = rowOf(e.target);
+ if (!li || busy) return;
+ const { id, at } = rowKey(li);
+ const to = at + (e.key === "ArrowUp" ? -1 : 1);
+ if (to < 0 || to >= count) return;
+ e.preventDefault();
+ void run("move", { id, at, toIndex: to }).then((ok) => {
+ // Keep the moved row focused after the page redraws it.
+ if (ok) setTimeout(() => (document.querySelector(`li[data-at="${to}"]`) as HTMLElement | null)?.focus(), 400);
+ });
+ };
+
+ return (
+ <TimelineCtx.Provider value={{ project, busy, run, dragging }}>
+ <div className="mb-1.5 flex flex-wrap items-center gap-2 text-[11px]">
+ <button type="button" data-testid="timeline-undo" disabled={busy} onClick={() => void run("undo", {})} className={buttonVariants({ size: "sm" })}>
+ Undo last edit
+ </button>
+ <span className="text-[var(--color-dim)]">
+ drag ⠿ or <kbd>alt</kbd>+<kbd>↑</kbd>/<kbd>↓</kbd> to move
+ </span>
+ {busy && <span className="text-[var(--color-dim)]">saving…</span>}
+ {error && (
+ <span data-testid="timeline-error" className="text-[var(--color-bad)]">
+ {error}
+ </span>
+ )}
+ </div>
+ <ul
+ className="space-y-1"
+ data-testid="timeline-list"
+ data-drop-at={over ?? ""}
+ onKeyDown={onKeyDown}
+ onDragOver={(e) => {
+ if (!dragging.current) return;
+ const li = rowOf(e.target);
+ if (!li) return;
+ e.preventDefault();
+ setOver(rowKey(li).at);
+ }}
+ onDragLeave={() => setOver(null)}
+ onDrop={(e) => {
+ const from = dragging.current;
+ const li = rowOf(e.target);
+ dragging.current = null;
+ setOver(null);
+ if (!from || !li) return;
+ e.preventDefault();
+ const to = rowKey(li).at;
+ if (to !== from.at) void run("move", { id: from.id, at: from.at, toIndex: to });
+ }}
+ >
+ {children}
+ </ul>
+ </TimelineCtx.Provider>
+ );
+}
+
+/** Inside one row: the drag handle and the row's menu. */
+export function RowControls({ id, at }: { id: string; at: number }) {
+ const ctx = useContext(TimelineCtx);
+ const [menu, setMenu] = useState(false);
+ const [insert, setInsert] = useState<string | null>(null);
+ if (!ctx) return null;
+ const { busy, run, dragging } = ctx;
+ return (
+ <>
+ <span
+ draggable={!busy}
+ data-drag-handle={id}
+ title="drag to move"
+ onDragStart={(e) => {
+ dragging.current = { id, at };
+ e.dataTransfer.effectAllowed = "move";
+ e.dataTransfer.setData("text/plain", id);
+ }}
+ onDragEnd={() => (dragging.current = null)}
+ className="cursor-grab select-none text-[13px] leading-none text-[var(--color-dim)]"
+ >
+ ⠿
+ </span>
+ <span className="relative">
+ <button type="button" data-row-menu={id} onClick={() => setMenu((m) => !m)} className="px-1 text-[12px] text-[var(--color-dim)] hover:text-[var(--color-text)]" aria-label={`actions for ${id}`}>
+ ⋯
+ </button>
+ {menu && (
+ <span className="absolute right-0 z-10 mt-1 flex w-36 flex-col rounded border border-[var(--color-line)] bg-[var(--color-panel)] p-1 text-[11px] shadow">
+ <button type="button" data-row-action="duplicate" disabled={busy} onClick={() => (setMenu(false), void run("duplicate", { id, at }))} className="px-1 py-0.5 text-left hover:bg-[var(--color-panel-2)]">
+ duplicate
+ </button>
+ <button type="button" data-row-action="insert" disabled={busy} onClick={() => (setMenu(false), setInsert(""))} className="px-1 py-0.5 text-left hover:bg-[var(--color-panel-2)]">
+ insert clip after
+ </button>
+ <button type="button" data-row-action="remove" disabled={busy} onClick={() => (setMenu(false), void run("remove", { id, at }))} className="px-1 py-0.5 text-left text-[var(--color-bad)] hover:bg-[var(--color-panel-2)]">
+ remove
+ </button>
+ </span>
+ )}
+ </span>
+ {insert !== null && (
+ <input
+ autoFocus
+ data-testid={`insert-after-${id}`}
+ value={insert}
+ placeholder="<channel>/<video>@<start>-<end>"
+ onChange={(e) => setInsert(e.target.value)}
+ onKeyDown={(e) => {
+ if (e.key === "Escape") setInsert(null);
+ if (e.key === "Enter" && insert.trim()) {
+ void run("insert", { afterId: id, at, entry: insert.trim() }).then((ok) => ok && setInsert(null));
+ }
+ }}
+ className="w-64 rounded border border-[var(--color-line)] bg-[var(--color-ink)] px-1.5 py-0.5 font-mono text-[11px] text-[var(--color-text)] outline-none focus:border-[var(--color-sel)]"
+ />
+ )}
+ </>
+ );
+}
+
+/** Under one row: its notes (and the edit notes a generated manifest left on it). */
+export function RowNotes({ id, project }: { id: string; project: string }) {
+ const notes = useSharedNotes({ project });
+ return (
+ <div className="mt-1">
+ <AnchoredNotes notes={notes} pin={{ kind: "entry", entry: id }} />
+ </div>
+ );
+}
diff --git a/umtool/components/projects/timelineApi.ts b/umtool/components/projects/timelineApi.ts
@@ -0,0 +1,39 @@
+"use client";
+
+// The client's side of POST /api/report/timeline, and the event every part of
+// a project page listens to after the manifest changed under it.
+//
+// The timeline editor, the structure editors and the On-screen section each
+// hold a manifest TOKEN; a structural write changes the file, so after one
+// every other holder must re-read before its next save would 409.
+
+export const MANIFEST_CHANGED = "umtool:manifest-changed";
+
+export function announceManifestChanged(project: string) {
+ window.dispatchEvent(new CustomEvent(MANIFEST_CHANGED, { detail: { project } }));
+}
+
+export type TimelineResult = {
+ ok: boolean;
+ status: number;
+ json: Record<string, unknown> & { error?: string; errors?: string[]; stale?: boolean; token?: string };
+};
+
+export async function timelineOp(project: string, token: string | null, op: string, args: Record<string, unknown> = {}): Promise<TimelineResult> {
+ const r = await fetch("/api/report/timeline", {
+ method: "POST",
+ headers: { "content-type": "application/json" },
+ body: JSON.stringify({ project, token, op, ...args }),
+ cache: "no-store",
+ });
+ const json = (await r.json().catch(() => ({ error: `HTTP ${r.status}` }))) as TimelineResult["json"];
+ return { ok: r.ok, status: r.status, json };
+}
+
+/** A refusal in words: the build's sentences when there are several. */
+export const refusalOf = (res: TimelineResult) =>
+ res.json.stale
+ ? "the manifest changed since this page read it — reloaded; try again"
+ : res.json.errors?.length
+ ? res.json.errors.join("; ")
+ : String(res.json.error ?? `HTTP ${res.status}`);
diff --git a/umtool/e2e/fixtures/make-fixture.mjs b/umtool/e2e/fixtures/make-fixture.mjs
@@ -1815,10 +1815,78 @@ 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",
+});
+{
+ // The deck on: the On-screen section shows the built cut (and its timed
+ // notes) only under it.
+ const m = JSON.parse(readFileSync(path.join(VNOTES, "video.manifest.json"), "utf8"));
+ m.render.chrome = { engine: "hyperframes", layout: "deck" };
+ writeFileSync(path.join(VNOTES, "video.manifest.json"), JSON.stringify(m, null, 2) + "\n");
+}
+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/annotations/targets.mjs b/umtool/lib/annotations/targets.mjs
@@ -15,6 +15,7 @@ import { readdir, readFile, realpath, stat } from "node:fs/promises";
import path from "node:path";
import { NOTES_FILENAME, REPORTS_ROOT, SEGMENT_RE, SITES_DIR, corpusNotesFile, inside, isCorpusNotesFile } from "../paths.mjs";
import { projectRefs, resolveProject } from "../projects/core.mjs";
+import { kindTakesNotes } from "../projects/kinds.mjs";
import { sourceFor, tildify } from "../articles/sources.mjs";
import { readNotes, writeOp } from "./store.mjs";
@@ -76,7 +77,7 @@ export async function projectTarget(spec, { reportsRoot = REPORTS_ROOT } = {}) {
* @param {{ reportsRoot?: string }} [opts]
*/
export async function projectTargetFor(p, { reportsRoot = REPORTS_ROOT } = {}) {
- if (p.kind !== "report-video") throw new TargetError(`${p.id} is a ${p.kind} project; notes are for report videos`);
+ if (!kindTakesNotes(p.kind)) throw new TargetError(`${p.id} is a ${p.kind} project; notes are for report videos`);
const [realRoot, realDir] = await Promise.all([
realpath(/* turbopackIgnore: true */ reportsRoot).catch(() => null),
realpath(/* turbopackIgnore: true */ p.dir).catch(() => null),
@@ -160,7 +161,7 @@ export async function listNotesFiles({ sitesDir = SITES_DIR, reportsRoot = REPOR
}
}
for (const p of await projectRefs(reportsRoot)) {
- if (p.kind !== "report-video") continue;
+ if (!kindTakesNotes(p.kind)) continue;
const file = path.join(/* turbopackIgnore: true */ p.dir, NOTES_FILENAME);
if (!(await exists(file))) continue;
out.push({ kind: "video-project", id: p.id, file, ...(await readNotes(file)) });
diff --git a/umtool/lib/projects/kinds.mjs b/umtool/lib/projects/kinds.mjs
@@ -104,6 +104,9 @@ export const PROJECT_KINDS = [
// `brand` offers report-to-video's presets (render.brand); none is the
// default and writes the manifest it always did.
scaffold: { fields: ["from", "siteOrigin", "seed", "brand"], brands: BRAND_CHOICES },
+ // Takes a notes.json beside its manifest (lib/annotations/targets.mjs):
+ // timed notes on its cuts, row notes, take notes, edit notes.
+ notes: true,
},
{
id: "song",
@@ -182,6 +185,9 @@ if (process.env.E2E_UMTOOL_EXTRA_KINDS) {
export const kindById = (id) => PROJECT_KINDS.find((k) => k.id === id) ?? null;
+/** Does a project of this kind keep a notes.json (lib/annotations)? Declared on the kind, never branched on its id. */
+export const kindTakesNotes = (id) => kindById(id)?.notes === true;
+
/** What a client component needs, with none of what it must not have. */
export const kindMeta = (k) => ({
id: k.id,
diff --git a/umtool/lib/report/edit-notes.mjs b/umtool/lib/report/edit-notes.mjs
@@ -0,0 +1,174 @@
+// An edit made in umtool to a GENERATED manifest, written down for the agent
+// that generates it.
+//
+// A manifest with `generatedBy` (polemics/video/make-videos.py, …) is rebuilt
+// from the generator's inputs, and the rebuild overwrites whatever was edited
+// here. So edits are still allowed -- the operator is watching the cut and the
+// fix belongs there -- and each one becomes an `edit` note in the project's
+// notes.json (lib/annotations/): which entry, which field, from what, to what.
+// The agent ports it into the generator's inputs (BEATS, drafts) and resolves
+// the note, and the next rebuild keeps it.
+//
+// ONE wrapper does this for every manifest writer (lib/report/guard.ts), by
+// diffing the manifest before and after the write -- so a writer added later
+// is covered without knowing this exists.
+//
+// Repeated saves of one field COALESCE: an open edit note on the same entry and
+// field keeps its original `from` and takes the new `to`, and an edit that
+// returns the field to its `from` deletes the note. Dragging a window five
+// times is one note, and dragging it back is none.
+import { readNotes } from "../annotations/store.mjs";
+import { writeNote } from "../annotations/targets.mjs";
+
+const MAX_VALUE = 3000;
+
+/** A value small enough to keep in a note; a large one is summarised. */
+function keep(v) {
+ if (v === undefined) return null;
+ const s = JSON.stringify(v);
+ if (s.length <= MAX_VALUE) return v;
+ return `(${Array.isArray(v) ? `${v.length} items` : "object"}, ${s.length} characters)`;
+}
+
+const ID_LISTS = ["posts", "ledger"];
+
+const same = (a, b) => JSON.stringify(a) === JSON.stringify(b);
+
+/** Key the timeline by `id` (and its variant, since twins share an id). */
+function entryKey(e) {
+ return e?.variant ? `${e.id}@${e.variant}` : String(e?.id);
+}
+
+/**
+ * Every change between two manifests, as `{ entry?, field, from, to }`.
+ *
+ * timeline entry field { entry: id, field: "<key>" }
+ * an entry added { entry: id, field: "timeline+", from: null, to: <entry> }
+ * an entry removed { entry: id, field: "timeline-", from: <entry>, to: null }
+ * the order { field: "timeline.order", from: [ids], to: [ids] } (survivors only)
+ * a post, a ledger claim { entry: id, field: "posts.<key>" | "posts+" | "posts-" } (ledger alike)
+ * anything else { field: "<top>.<key>" } one level down (render.chrome, provenance.siteOrigin)
+ *
+ * @param {Record<string, any>} before
+ * @param {Record<string, any>} after
+ */
+export function editsBetween(before, after) {
+ const out = [];
+ const a = (before?.timeline ?? []).filter((e) => e && e.id != null);
+ const b = (after?.timeline ?? []).filter((e) => e && e.id != null);
+ const byA = new Map(a.map((e) => [entryKey(e), e]));
+ const byB = new Map(b.map((e) => [entryKey(e), e]));
+ for (const [k, e] of byA) if (!byB.has(k)) out.push({ entry: String(e.id), field: "timeline-", from: keep(e), to: null });
+ for (const [k, e] of byB) if (!byA.has(k)) out.push({ entry: String(e.id), field: "timeline+", from: null, to: keep(e) });
+ const sa = a.map(entryKey).filter((k) => byB.has(k));
+ const sb = b.map(entryKey).filter((k) => byA.has(k));
+ if (!same(sa, sb)) out.push({ field: "timeline.order", from: keep(sa), to: keep(sb) });
+ for (const [k, x] of byA) {
+ const y = byB.get(k);
+ if (!y) continue;
+ for (const f of new Set([...Object.keys(x), ...Object.keys(y)])) {
+ // sectionEnter follows the order; the order change already says it.
+ if (f === "sectionEnter" || same(x[f], y[f])) continue;
+ out.push({ entry: String(x.id), field: f, from: keep(x[f]), to: keep(y[f]) });
+ }
+ }
+
+ // Lists of things with ids -- the posts, the ledger's claims -- by id.
+ for (const list of ID_LISTS) {
+ const pa = new Map((Array.isArray(before?.[list]) ? before[list] : []).map((p) => [String(p?.id), p]));
+ const pb = new Map((Array.isArray(after?.[list]) ? after[list] : []).map((p) => [String(p?.id), p]));
+ for (const [id, p] of pa) if (!pb.has(id)) out.push({ entry: id, field: `${list}-`, from: keep(p), to: null });
+ for (const [id, p] of pb) {
+ const q = pa.get(id);
+ if (!q) {
+ out.push({ entry: id, field: `${list}+`, from: null, to: keep(p) });
+ continue;
+ }
+ for (const f of new Set([...Object.keys(q), ...Object.keys(p)])) {
+ if (!same(q[f], p[f])) out.push({ entry: id, field: `${list}.${f}`, from: keep(q[f]), to: keep(p[f]) });
+ }
+ }
+ }
+
+ for (const top of new Set([...Object.keys(before ?? {}), ...Object.keys(after ?? {})])) {
+ if (top === "timeline" || ID_LISTS.includes(top)) continue;
+ const x = before?.[top];
+ const y = after?.[top];
+ if (same(x, y)) continue;
+ const isObj = (v) => v && typeof v === "object" && !Array.isArray(v);
+ if (isObj(x) && isObj(y)) {
+ for (const f of new Set([...Object.keys(x), ...Object.keys(y)])) {
+ if (!same(x[f], y[f])) out.push({ field: `${top}.${f}`, from: keep(x[f]), to: keep(y[f]) });
+ }
+ } else {
+ out.push({ field: top, from: keep(x), to: keep(y) });
+ }
+ }
+ return out;
+}
+
+/** The sentence an edit note carries; the anchor carries the values. */
+export function editText(edit, generatedBy) {
+ const where = edit.entry ? `${edit.entry} ` : "";
+ const what =
+ edit.field === "timeline+"
+ ? "added to the timeline"
+ : edit.field === "timeline-"
+ ? "removed from the timeline"
+ : edit.field === "timeline.order"
+ ? "the timeline was re-ordered"
+ : edit.field.endsWith("+")
+ ? `${edit.field.slice(0, -1)} entry added`
+ : edit.field.endsWith("-")
+ ? `${edit.field.slice(0, -1)} entry removed`
+ : `${edit.field} changed`;
+ return `${where}${what} in umtool. Port it into the inputs of ${generatedBy}; a rebuild of manifests overwrites it.`;
+}
+
+/**
+ * Write `edits` into a project's notes as `edit` notes, coalescing with open
+ * ones on the same entry and field (see the top of this file). Returns how many
+ * notes were added, updated and deleted. Errors are returned, not thrown: the
+ * manifest write already happened, and a notes file that will not take a note
+ * must not turn a saved edit into a reported failure.
+ *
+ * @param {{ file: string, subject: Record<string, string>, source: () => Promise<any> }} target
+ * @param {Array<{ entry?: string, field: string, from: unknown, to: unknown }>} edits
+ * @param {string} generatedBy
+ */
+export async function recordEdits(target, edits, generatedBy) {
+ const counts = { added: 0, updated: 0, deleted: 0, errors: /** @type {string[]} */ ([]) };
+ for (const edit of edits) {
+ try {
+ const { doc } = await readNotes(target.file);
+ const open = (doc?.notes ?? []).find(
+ (n) =>
+ n.status === "open" &&
+ n.author === "operator" &&
+ n.anchor.kind === "edit" &&
+ n.anchor.field === edit.field &&
+ (n.anchor.entry ?? null) === (edit.entry ?? null),
+ );
+ if (open) {
+ const from = /** @type {any} */ (open.anchor).from;
+ // Put back as it was: the note says nothing -- unless somebody has
+ // already replied to it, and then it stays for them to resolve.
+ if (same(from, edit.to) && !open.replies.length) {
+ await writeNote(target, { op: "delete", id: open.id }, { by: "operator" });
+ counts.deleted += 1;
+ } else {
+ const anchor = { kind: "edit", field: edit.field, from, to: edit.to, ...(edit.entry ? { entry: edit.entry } : {}) };
+ await writeNote(target, { op: "edit", id: open.id, anchor }, { by: "operator" });
+ counts.updated += 1;
+ }
+ continue;
+ }
+ const anchor = { kind: "edit", field: edit.field, from: edit.from, to: edit.to, ...(edit.entry ? { entry: edit.entry } : {}) };
+ await writeNote(target, { op: "add", text: editText(edit, generatedBy), anchor }, { by: "operator" });
+ counts.added += 1;
+ } catch (e) {
+ counts.errors.push(e instanceof Error ? e.message : String(e));
+ }
+ }
+ return counts;
+}
diff --git a/umtool/lib/report/edit-notes.test.mjs b/umtool/lib/report/edit-notes.test.mjs
@@ -0,0 +1,67 @@
+// Edits to a generated manifest, as notes: the diff, and the coalescing.
+//
+// Run with: pnpm test:scripts
+import assert from "node:assert/strict";
+import { mkdtemp, rm } from "node:fs/promises";
+import { tmpdir } from "node:os";
+import path from "node:path";
+import test from "node:test";
+import { readNotes } from "../annotations/store.mjs";
+import { editsBetween, editText, recordEdits } from "./edit-notes.mjs";
+
+const m = () => ({
+ generatedBy: "polemics/video/make-videos.py",
+ render: { fps: 30, chrome: { engine: "hyperframes" } },
+ timeline: [
+ { type: "clip", id: "a", start: 1, end: 5, quote: "q" },
+ { type: "clip", id: "b", start: 10, end: 15 },
+ { type: "card", id: "k" },
+ ],
+ posts: [{ id: "p1", text: "t", attachTo: "a" }],
+ ledger: [{ id: "c1", scope: "x" }],
+});
+
+test("editsBetween names each change by entry and field", () => {
+ const before = m();
+ const after = m();
+ after.timeline[0].quote = "new";
+ after.timeline[0].start = 2;
+ after.timeline = [after.timeline[1], after.timeline[0], { type: "card", id: "k2" }];
+ after.posts[0].attachTo = "b";
+ after.ledger[0].scope = "y";
+ after.render.chrome.layout = "deck";
+ const e = editsBetween(before, after);
+ const keyOf = (x) => `${x.entry ?? ""}|${x.field}`;
+ assert.deepEqual(
+ e.map(keyOf).sort(),
+ ["a|quote", "a|start", "c1|ledger.scope", "k2|timeline+", "k|timeline-", "p1|posts.attachTo", "|render.chrome", "|timeline.order"].sort(),
+ );
+ const quote = e.find((x) => x.field === "quote");
+ assert.deepEqual([quote.from, quote.to], ["q", "new"]);
+ assert.deepEqual(editsBetween(before, m()), []);
+ assert.match(editText({ entry: "a", field: "quote" }, "gen.py"), /^a quote changed in umtool\. Port it into the inputs of gen\.py/);
+});
+
+test("recordEdits adds, coalesces, and deletes a note an edit put back", async () => {
+ const dir = await mkdtemp(path.join(tmpdir(), "umtool-editnotes-"));
+ const target = {
+ file: path.join(dir, "notes.json"),
+ subject: { kind: "video-project", project: "x/y" },
+ source: async () => ({ manifest: "~/x/y/video.manifest.json" }),
+ };
+ try {
+ let c = await recordEdits(target, [{ entry: "a", field: "start", from: 1, to: 2 }], "gen.py");
+ assert.deepEqual([c.added, c.updated, c.deleted], [1, 0, 0]);
+ c = await recordEdits(target, [{ entry: "a", field: "start", from: 2, to: 3 }], "gen.py");
+ assert.deepEqual([c.added, c.updated], [0, 1]);
+ let doc = (await readNotes(target.file)).doc;
+ assert.equal(doc.notes.length, 1);
+ assert.deepEqual([doc.notes[0].anchor.from, doc.notes[0].anchor.to], [1, 3]);
+ assert.equal(doc.source.manifest, "~/x/y/video.manifest.json");
+ c = await recordEdits(target, [{ entry: "a", field: "start", from: 3, to: 1 }], "gen.py");
+ assert.equal(c.deleted, 1);
+ assert.equal((await readNotes(target.file)).doc, null);
+ } finally {
+ await rm(dir, { recursive: true });
+ }
+});
diff --git a/umtool/lib/report/guard.ts b/umtool/lib/report/guard.ts
@@ -0,0 +1,38 @@
+import { projectTargetFor } from "@/lib/annotations/targets.mjs";
+import { readManifest } from "@/lib/projects/report.mjs";
+import { editsBetween, recordEdits } from "./edit-notes.mjs";
+
+// THE one wrapper every manifest writer's route goes through.
+//
+// A manifest with `generatedBy` is rebuilt by its generator, and the rebuild
+// overwrites edits made here (the banner on the project page, the bench and
+// the On-screen section says so). The edit is still made; what this adds is a
+// record of it: the manifest is read before and after the write, and every
+// change becomes an `edit` note in the project's notes.json for the agent to
+// port into the generator's inputs (lib/report/edit-notes.mjs). A hand-edited
+// manifest (no `generatedBy`) is written exactly as before.
+//
+// The notes are written AFTER the manifest and never fail the request: the
+// edit is saved either way, and `editNotes.errors` says when its note is not.
+
+export type EditNotes = { generatedBy: string; added: number; updated: number; deleted: number; errors: string[] };
+
+export async function withEditNotes<T>(
+ project: { id: string; dir: string; kind: string },
+ write: () => Promise<T>,
+): Promise<{ result: T; editNotes: EditNotes | null }> {
+ const before = await readManifest(project.dir);
+ const result = await write();
+ const generatedBy = typeof before?.generatedBy === "string" ? before.generatedBy.trim() : "";
+ if (!generatedBy) return { result, editNotes: null };
+ const after = await readManifest(project.dir);
+ const edits = editsBetween(before, after);
+ if (!edits.length) return { result, editNotes: { generatedBy, added: 0, updated: 0, deleted: 0, errors: [] } };
+ try {
+ const target = await projectTargetFor(project);
+ const counts = await recordEdits(target, edits, generatedBy);
+ return { result, editNotes: { generatedBy, ...counts } };
+ } catch (e) {
+ return { result, editNotes: { generatedBy, added: 0, updated: 0, deleted: 0, errors: [e instanceof Error ? e.message : String(e)] } };
+ }
+}
diff --git a/umtool/lib/report/manifest.mjs b/umtool/lib/report/manifest.mjs
@@ -30,10 +30,12 @@ import {
rolesGaps,
} from "umtool-report-to-video/ledger-totals";
import { isCalendarDate } from "umtool-report-to-video/attribution";
-import { normalizeOnscreen, validateChrome, validatePosts } from "umtool-report-to-video/deck";
+import { normalizeOnscreen, validateChrome, validatePosts, validateTeaser, validateTeasers } from "umtool-report-to-video/deck";
+import { applySectionEnter } from "./sections.mjs";
import { normalizeClaim, validateClaims } from "umtool-report-to-video/factcheck";
import { parseMuteFrom } from "./playback.mjs";
import { DELIVERABLES_MODES } from "./storage.mjs";
+import { createSnapshot, listSnapshots } from "./snapshots.mjs";
// Its own write queue, not lib/state.ts's.
//
@@ -787,3 +789,381 @@ export async function updateStorage(dir, { deliverables } = {}, { token = null }
return { storage: manifest.storage, token: nextToken, changed: true };
});
}
+
+
+// ---------------------------------------------------------------------------
+// STRUCTURE: re-ordering the cut, and the edits that add or remove a thing.
+//
+// Every writer above changes the fields of something already in the manifest.
+// These change WHAT IS IN IT -- the order of the timeline, which entries it
+// holds, a teaser's lines, the posts, the fact-check's labels -- so they are
+// the edits a person wants to take back. Each one snapshots the manifest into
+// revisions/ first (`auto-before-<op>`, at most one per op every two minutes,
+// so a burst of drags is one step back), and `undoStructural` restores the
+// newest of those.
+//
+// Same four rules as the rest of the file: the mtime token, tmp + rename under
+// the lock, 2 dp, and validation by the BUILD's own checks (deck.mjs,
+// factcheck.mjs) before anything is written, so a manifest these accept is one
+// the build accepts.
+//
+// An entry is named by its id. A timeline may repeat an id across variants
+// (`variant: "sourced"` / `"full"` twins); then the caller passes `at`, the
+// index it means, and a stale `at` (the id is not there any more) refuses.
+// ---------------------------------------------------------------------------
+
+export const AUTO_SNAPSHOT_PREFIX = "auto-before-";
+const AUTO_SNAPSHOT_EVERY_MS = 2 * 60 * 1000;
+const ENTRY_ID_RE = /^[A-Za-z0-9_-]{1,64}$/;
+
+/**
+ * Copy the manifest into revisions/ as `auto-before-<op>`, unless one for the
+ * same op was taken in the last two minutes. Never fatal: an undo point that
+ * cannot be taken is reported, and the edit still lands.
+ */
+export async function autoSnapshot(dir, op, { now = Date.now() } = {}) {
+ const label = `${AUTO_SNAPSHOT_PREFIX}${op}`;
+ try {
+ const recent = (await listSnapshots(dir)).find((s) => !s.legacy && s.label === label);
+ if (recent && now - recent.mtimeMs < AUTO_SNAPSHOT_EVERY_MS) return { skipped: true, rel: recent.rel };
+ return { skipped: false, ...(await createSnapshot(dir, { label })) };
+ } catch (e) {
+ return { skipped: true, error: e instanceof Error ? e.message : String(e) };
+ }
+}
+
+/** The index of entry `id`: `at` when it names it, else the only entry with that id. */
+export function entryIndex(timeline, id, at = null) {
+ if (at !== null && at !== undefined) {
+ const i = Number(at);
+ if (!Number.isInteger(i) || timeline[i]?.id !== id) {
+ throw new Error(`timeline[${at}] is not ${id} any more — reload`);
+ }
+ return i;
+ }
+ const hits = [];
+ timeline.forEach((e, i) => {
+ if (e?.id === id) hits.push(i);
+ });
+ if (!hits.length) throw new Error(`no timeline entry with id ${id}`);
+ if (hits.length > 1) throw new Error(`${id} is in the timeline ${hits.length} times — say which (at)`);
+ return hits[0];
+}
+
+/** An id not yet in the timeline: `base`, else `base-2`, `base-3`, … */
+export function freshEntryId(timeline, base) {
+ const clean = String(base).replace(/[^A-Za-z0-9_-]+/g, "-").replace(/^-+|-+$/g, "").slice(0, 56) || "entry";
+ const ids = new Set(timeline.map((e) => e?.id));
+ if (!ids.has(clean)) return clean;
+ for (let n = 2; ; n += 1) if (!ids.has(`${clean}-${n}`)) return `${clean}-${n}`;
+}
+
+/** Every reason the build would refuse the manifest's structure, after an edit. */
+function structureErrors(manifest) {
+ return [
+ ...validatePosts(manifest.posts, manifest.timeline ?? [], manifest.render),
+ ...validateClaims(manifest),
+ ...validateTeasers(manifest),
+ ];
+}
+
+/** The build's sentences, thrown whole so a route can return each one. */
+export class StructureRefused extends Error {
+ /** @param {string[]} errors */
+ constructor(errors) {
+ super(errors.join("; "));
+ this.name = "StructureRefused";
+ this.errors = errors;
+ }
+}
+
+/**
+ * One structural write: token, read, `mutate` (which throws to refuse), the
+ * build's checks, the auto snapshot of the file as it still is, then the write.
+ */
+async function structural(dir, op, token, mutate) {
+ return withManifestLock(async () => {
+ const current = await manifestToken(dir);
+ if (token !== null && current !== token) throw new StaleToken(token, current);
+ const manifest = JSON.parse(await readFile(manifestFile(dir), "utf8"));
+ if (!Array.isArray(manifest.timeline)) manifest.timeline = [];
+ // Only what THIS edit breaks refuses it: a manifest that already carries a
+ // problem the build would name (somebody's hand edit) can still be
+ // re-ordered, and the problem is still the build's to report.
+ const already = new Set(structureErrors(manifest));
+ const result = mutate(manifest);
+ const errors = structureErrors(manifest).filter((e) => !already.has(e));
+ if (errors.length) throw new StructureRefused(errors);
+ applySectionEnter(manifest.timeline);
+ const snapshot = await autoSnapshot(dir, op);
+ const nextToken = await writeManifestAtomic(dir, manifest);
+ return { ...result, snapshot, token: nextToken };
+ });
+}
+
+/**
+ * Move one entry to `toIndex` (its index in the timeline AFTER the move).
+ * `sectionEnter` is recomputed by report-to-video's own rule (sections.mjs),
+ * and only on a manifest that already uses it.
+ *
+ * @param {string} dir
+ * @param {string} id
+ * @param {number} toIndex
+ * @param {{ token?: string | null, at?: number | null }} [opts]
+ */
+export async function moveEntry(dir, id, toIndex, { token = null, at = null } = {}) {
+ return structural(dir, "move", token, (m) => {
+ const from = entryIndex(m.timeline, id, at);
+ const to = Number(toIndex);
+ if (!Number.isInteger(to) || to < 0 || to >= m.timeline.length) {
+ throw new Error(`toIndex must be 0–${m.timeline.length - 1}`);
+ }
+ if (to === from) throw new Error(`${id} is already at ${to}`);
+ const [e] = m.timeline.splice(from, 1);
+ m.timeline.splice(to, 0, e);
+ return { id, from, to };
+ });
+}
+
+/**
+ * Remove one entry. Refused when something still points at it -- a post
+ * attached to a clip, a claim -- in the build's own words.
+ *
+ * @param {string} dir
+ * @param {string} id
+ * @param {{ token?: string | null, at?: number | null }} [opts]
+ */
+export async function removeEntry(dir, id, { token = null, at = null } = {}) {
+ return structural(dir, "remove", token, (m) => {
+ const i = entryIndex(m.timeline, id, at);
+ const [removed] = m.timeline.splice(i, 1);
+ return { id, at: i, removed };
+ });
+}
+
+/** Copy one entry to just after itself, under a fresh id. A copied claim is dropped (one claim, one entry). *
+ * @param {string} dir
+ * @param {string} id
+ * @param {{ token?: string | null, at?: number | null }} [opts]
+ */
+export async function duplicateEntry(dir, id, { token = null, at = null } = {}) {
+ return structural(dir, "duplicate", token, (m) => {
+ const i = entryIndex(m.timeline, id, at);
+ const copy = JSON.parse(JSON.stringify(m.timeline[i]));
+ copy.id = freshEntryId(m.timeline, `${id}-copy`);
+ delete copy.claim;
+ m.timeline.splice(i + 1, 0, copy);
+ return { id: copy.id, at: i + 1, entry: copy };
+ });
+}
+
+/** `<channel>/<video>@<start>-<end>`, in seconds. */
+const CLIP_SPEC_RE = /^([A-Za-z0-9._-]+)\/([^@\s/]+)@(\d+(?:\.\d+)?)-(\d+(?:\.\d+)?)$/;
+
+/**
+ * What `insertEntry` will put in the timeline, checked: a clip spec string
+ * (`<channel>/<video>@<start>-<end>`) becomes a bare clip; an object is an
+ * entry as written, given a fresh id when it has none (or one already taken).
+ *
+ * @param {Array<Record<string, unknown>>} timeline
+ * @param {unknown} spec
+ */
+export function entryFromSpec(timeline, spec) {
+ if (typeof spec === "string") {
+ const mm = spec.trim().match(CLIP_SPEC_RE);
+ if (!mm) throw new Error("a clip is <channel>/<video>@<start>-<end> in seconds");
+ const [, channel, video, s, e] = mm;
+ return clipEntry(timeline, { type: "clip", channel, video, start: Number(s), end: Number(e) });
+ }
+ if (!spec || typeof spec !== "object" || Array.isArray(spec)) throw new Error("an entry is an object, or a clip spec string");
+ const entry = JSON.parse(JSON.stringify(spec));
+ if (JSON.stringify(entry).length > 20000) throw new Error("that entry is too large");
+ if (typeof entry.type !== "string" || !entry.type) throw new Error("an entry needs a type");
+ if (entry.type === "clip") return clipEntry(timeline, entry);
+ const base = typeof entry.id === "string" && ENTRY_ID_RE.test(entry.id) ? entry.id : entry.type;
+ entry.id = freshEntryId(timeline, base);
+ return entry;
+}
+
+function clipEntry(timeline, e) {
+ if (typeof e.video !== "string" || !e.video.trim()) throw new Error("a clip needs its video id");
+ for (const k of ["start", "end"]) {
+ const v = Number(e[k]);
+ if (!Number.isFinite(v) || v < 0) throw new Error(`a clip's ${k} must be a number ≥ 0`);
+ e[k] = round2(v);
+ }
+ if (e.end - e.start < 0.5) throw new Error(`a clip must be at least half a second (${e.start}–${e.end})`);
+ if (e.channel !== undefined && (typeof e.channel !== "string" || !e.channel)) delete e.channel;
+ const base = typeof e.id === "string" && ENTRY_ID_RE.test(e.id) ? e.id : `${e.video}-${Math.floor(e.start)}`;
+ // `type` and `id` first: the manifests are read by humans.
+ const { type: _t, id: _i, ...rest } = e;
+ return { type: "clip", id: freshEntryId(timeline, base), ...rest };
+}
+
+/**
+ * Insert an entry after `afterId` (null or "": at the start). `spec` is a clip
+ * spec string or an entry object (entryFromSpec).
+ *
+ * @param {string} dir
+ * @param {string | null} afterId
+ * @param {unknown} spec
+ * @param {{ token?: string | null, at?: number | null }} [opts] `at` is afterId's index
+ */
+export async function insertEntry(dir, afterId, spec, { token = null, at = null } = {}) {
+ return structural(dir, "insert", token, (m) => {
+ const i = afterId ? entryIndex(m.timeline, afterId, at) + 1 : 0;
+ const entry = entryFromSpec(m.timeline, spec);
+ m.timeline.splice(i, 0, entry);
+ return { id: entry.id, at: i, entry };
+ });
+}
+
+const TEASER_PATCH_KEYS = ["lines", "beat", "dip", "tail", "tailWait"];
+
+/**
+ * Patch one teaser: its lines, beat, dip, tail and tail wait. A key given as
+ * null (or "") is removed; a key not given is kept. Checked with the build's
+ * validateTeaser (which checks the dip too) against the patched entry.
+ *
+ * @param {string} dir
+ * @param {string} id
+ * @param {Record<string, unknown>} patch
+ * @param {{ token?: string | null, at?: number | null }} [opts]
+ */
+export async function updateTeaser(dir, id, patch, { token = null, at = null } = {}) {
+ if (!patch || typeof patch !== "object" || Array.isArray(patch)) throw new Error("a teaser patch is an object");
+ const keys = Object.keys(patch);
+ const bad = keys.filter((k) => !TEASER_PATCH_KEYS.includes(k));
+ if (bad.length) throw new Error(`${bad.join(", ")}: not something this writer changes (${TEASER_PATCH_KEYS.join(", ")})`);
+ if (!keys.length) throw new Error("nothing to change");
+ return structural(dir, "teaser", token, (m) => {
+ const e = m.timeline[entryIndex(m.timeline, id, at)];
+ if (e.type !== "teaser") throw new Error(`${id} is a ${e.type ?? "non-teaser"} entry, not a teaser`);
+ for (const k of keys) {
+ const v = patch[k];
+ if (v === null || v === "" || v === undefined) delete e[k];
+ else if (k === "beat" || k === "tailWait") e[k] = round2(Number(v));
+ else if (k === "dip") {
+ e.dip = typeof v === "object" && v ? { fade: round2(Number(v.fade)), black: round2(Number(v.black)) } : v;
+ } else if (k === "tail") e.tail = String(v);
+ else e[k] = v;
+ }
+ const errors = validateTeaser(e);
+ if (errors.length) throw new StructureRefused(errors);
+ return { id, entry: e };
+ });
+}
+
+const POST_TEXT_KEYS = ["platform", "author", "handle", "date", "text", "url", "shot", "flag", "accent", "logo", "siteChannel", "siteUrl", "postId", "variant"];
+
+/**
+ * Add a post, or replace the one with its id. The value is the post as it
+ * should be stored: an empty optional string is dropped rather than written.
+ * `attachTo` and `hide` are kept from the stored post unless the value names
+ * them. Checked with the build's validatePosts.
+ *
+ * @param {string} dir
+ * @param {Record<string, any>} post
+ * @param {{ token?: string | null }} [opts]
+ */
+export async function upsertPost(dir, post, { token = null } = {}) {
+ if (!post || typeof post !== "object" || Array.isArray(post)) throw new Error("a post is an object");
+ if (typeof post.id !== "string" || !ENTRY_ID_RE.test(post.id)) throw new Error("a post needs an id: letters, digits, dashes, underscores");
+ return structural(dir, "post", token, (m) => {
+ if (!Array.isArray(m.posts)) m.posts = [];
+ const i = m.posts.findIndex((p) => p?.id === post.id);
+ const prev = i >= 0 ? m.posts[i] : {};
+ const next = { id: post.id };
+ for (const k of POST_TEXT_KEYS) {
+ const v = k in post ? post[k] : prev[k];
+ if (v === undefined || v === null || (typeof v === "string" && !v.trim())) continue;
+ next[k] = typeof v === "string" ? v.trim() : v;
+ }
+ for (const k of ["attachTo", "hide"]) {
+ const v = k in post ? post[k] : prev[k];
+ if (v === undefined || v === null || v === "" || v === false) continue;
+ next[k] = v;
+ }
+ if (i >= 0) m.posts[i] = next;
+ else m.posts.push(next);
+ return { post: next, created: i < 0 };
+ });
+}
+
+/** Remove one post by id. *
+ * @param {string} dir
+ * @param {string} id
+ * @param {{ token?: string | null }} [opts]
+ */
+export async function removePost(dir, id, { token = null } = {}) {
+ return structural(dir, "post", token, (m) => {
+ const i = (m.posts ?? []).findIndex((p) => p?.id === id);
+ if (i < 0) throw new Error(`no post with id ${id}`);
+ const [removed] = m.posts.splice(i, 1);
+ if (!m.posts.length) delete m.posts;
+ return { removed };
+ });
+}
+
+/**
+ * Set or remove `render.chrome.factcheck`: the stamp, the tally, and each
+ * verdict's label and colour. Only on a manifest whose deck is on (the
+ * fact-check is drawn by the deck); null removes it. Checked by the build's
+ * validateChrome against the rest of the render block.
+ *
+ * @param {string} dir
+ * @param {Record<string, unknown> | null} factcheck
+ * @param {{ token?: string | null }} [opts]
+ */
+export async function updateFactcheck(dir, factcheck, { token = null } = {}) {
+ if (factcheck === undefined) throw new Error("factcheck must be an object, or null to remove it");
+ return structural(dir, "factcheck", token, (m) => {
+ const render = m.render ?? {};
+ if (!render.chrome || typeof render.chrome !== "object") {
+ throw new Error("the fact-check is drawn by the on-screen deck, and this manifest has none — turn the deck on first");
+ }
+ const chrome = { ...render.chrome };
+ if (factcheck === null) delete chrome.factcheck;
+ else chrome.factcheck = factcheck;
+ const { chrome: _old, ...rest } = render;
+ const already = new Set(validateChrome(render.chrome, rest));
+ const errors = validateChrome(chrome, rest).filter((e) => !already.has(e));
+ if (errors.length) throw new StructureRefused(errors);
+ m.render = { ...render, chrome };
+ return { factcheck: chrome.factcheck ?? null };
+ });
+}
+
+/**
+ * Take back the newest structural edit: restore the newest `auto-before-<op>`
+ * snapshot, byte for byte. The manifest as it is now is kept first
+ * (`undo-saved`), and the restored snapshot is renamed `undone-<op>` so the
+ * next undo goes one step further back rather than round in a circle.
+ *
+ * @param {string} dir
+ * @param {{ token?: string | null }} [opts]
+ */
+export async function undoStructural(dir, { token = null } = {}) {
+ return withManifestLock(async () => {
+ const current = await manifestToken(dir);
+ if (token !== null && current !== token) throw new StaleToken(token, current);
+ const target = (await listSnapshots(dir)).find((s) => !s.legacy && s.label?.startsWith(AUTO_SNAPSHOT_PREFIX));
+ if (!target) throw new Error("nothing to undo: no automatic snapshot in revisions/");
+ const op = target.label.slice(AUTO_SNAPSHOT_PREFIX.length);
+ const text = await readFile(path.join(dir, target.rel), "utf8");
+ JSON.parse(text); // a snapshot that does not parse is not restored over a manifest that does
+ let saved = null;
+ try {
+ saved = (await createSnapshot(dir, { label: "undo-saved" })).rel;
+ } catch {
+ // within the same second as another snapshot: the state is already kept
+ }
+ const file = manifestFile(dir);
+ const tmp = `${file}.tmp-${process.pid}-${Math.random().toString(36).slice(2, 8)}`;
+ await writeFile(tmp, text, "utf8");
+ await rename(tmp, file);
+ const undone = target.rel.replace(`-${target.label}.manifest.json`, `-undone-${op}.manifest.json`);
+ await rename(path.join(dir, target.rel), path.join(dir, undone)).catch(() => {});
+ return { restored: target.rel, op, saved, token: await manifestToken(dir) };
+ });
+}
diff --git a/umtool/lib/report/moments.mjs b/umtool/lib/report/moments.mjs
@@ -0,0 +1,155 @@
+// A moment on a rendered cut -> the entry playing there.
+//
+// A timed note is a second on a FILE (`out/sourced/<slug>.mp4`, a take's
+// `takes/<id>/preview.mp4`). What makes it worth an agent's time is what that
+// second resolves to: the entry on screen, its onscreen title and quote, and
+// the source second with a link into the archive. That join is the build's
+// `schedule.json` (build-video.mjs writeChromeSchedule: `out/<variant>/`, or a
+// take's own `takes/<id>/out/<variant>/`), which says where each entry starts
+// in the cut.
+//
+// THE SCHEDULE MATCHES THE BUILD'S OUTPUT, and a take's preview.mp4 is that
+// output copied: measured on every take of candace/polemic-israel, the
+// preview's duration equals the schedule's `total` to the millisecond. So a
+// mark is exact when the file it was made on is as long as the schedule says,
+// and APPROXIMATE (`approx: true`) when it is not -- a different preset, a
+// trimmed preview -- or when the schedule is an estimate, or the second falls
+// in a held frame past the clip's own source.
+//
+// The resolution is written INTO the note at write time (`anchor.resolved`):
+// a later rebuild moves entries around, and the agent reading the note must
+// see what was on screen when the operator pressed the key.
+import { readdir, readFile, stat } from "node:fs/promises";
+import path from "node:path";
+import { DEFAULT_VARIANT } from "umtool-report-to-video/build-video";
+import { channelFor } from "../projects/report.mjs";
+
+const TAKE_RE = /^[a-z0-9][a-z0-9-]{0,63}$/;
+const APPROX_TOLERANCE = 0.25;
+
+/**
+ * Where a moment's file sits: in a take (`takes/<id>/…`) and/or a variant's
+ * output dir (`…out/<variant>/…`). Pure.
+ *
+ * @param {string} rel project-relative, `/`-separated
+ */
+export function momentFileInfo(rel) {
+ const parts = String(rel ?? "").split("/");
+ let take = null;
+ let i = 0;
+ if (parts[0] === "takes" && TAKE_RE.test(parts[1] ?? "")) {
+ take = parts[1];
+ i = 2;
+ }
+ const variant = parts[i] === "out" && parts.length > i + 2 && TAKE_RE.test(parts[i + 1]) ? parts[i + 1] : null;
+ return { take, variant };
+}
+
+async function readJson(file) {
+ try {
+ return JSON.parse(await readFile(/* turbopackIgnore: true */ file, "utf8"));
+ } catch {
+ return null;
+ }
+}
+
+/**
+ * The schedule and manifest a moment on `rel` resolves against, or null when
+ * there is no schedule. A take's own manifest wins over the project's.
+ *
+ * @param {string} projectDir
+ * @param {string} rel
+ */
+export async function scheduleForFile(projectDir, rel) {
+ const { take, variant } = momentFileInfo(rel);
+ const base = take ? path.join(/* turbopackIgnore: true */ projectDir, "takes", take) : projectDir;
+ let variants = [];
+ if (variant) variants = [variant];
+ 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);
+ // 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");
+ const schedule = await readJson(file);
+ if (!schedule || !Array.isArray(schedule.segments)) continue;
+ const manifest =
+ (take ? await readJson(path.join(/* turbopackIgnore: true */ base, "video.manifest.json")) : null) ??
+ (await readJson(path.join(/* turbopackIgnore: true */ projectDir, "video.manifest.json")));
+ const st = await stat(/* turbopackIgnore: true */ file).catch(() => null);
+ return {
+ schedule,
+ manifest,
+ variant: v,
+ take,
+ scheduleRel: path.relative(/* turbopackIgnore: true */ projectDir, file).split(path.sep).join("/"),
+ scheduleMtimeMs: st ? Math.round(st.mtimeMs) : null,
+ };
+ }
+ return null;
+}
+
+/**
+ * The entry playing at `t` seconds of a cut. Pure.
+ *
+ * During a crossfade both segments are on screen; the incoming one is taken
+ * from half-way through it. `duration` is the file's own (the player knows it):
+ * when it differs from the schedule's total the result is `approx`.
+ *
+ * @param {{ schedule: any, manifest: any, variant?: string | null, t: number, duration?: number | null }} args
+ * @returns {{ entry: string | null, title?: string, quote?: string, channel?: string, video?: string, sourceT?: number, url?: string, approx?: boolean }}
+ */
+export function resolveMoment({ schedule, manifest, variant = null, t, duration = null }) {
+ const segs = Array.isArray(schedule?.segments) ? schedule.segments : [];
+ if (!segs.length || !Number.isFinite(t)) return { entry: null };
+ const D = Number(schedule.transition) || 0;
+ let i = 0;
+ for (let k = 0; k < segs.length; k += 1) if (Number(segs[k].start) <= t - D / 2) i = k;
+ const seg = segs[i];
+ const out = { entry: String(seg.id) };
+ let approx = schedule.estimated === true;
+ if (Number.isFinite(duration) && Number.isFinite(Number(schedule.total)) && Math.abs(duration - Number(schedule.total)) > APPROX_TOLERANCE) {
+ approx = true;
+ }
+ const e = (manifest?.timeline ?? []).find((x) => x?.id === seg.id && (!x.variant || !variant || x.variant === variant)) ?? null;
+ const title = seg.title ?? e?.onscreen?.title ?? e?.title ?? e?.heading ?? null;
+ if (title) out.title = String(title);
+ if (e?.quote) out.quote = String(e.quote);
+ if (e?.type === "clip") {
+ const from = Number(e.cutStart ?? e.start);
+ const to = Number(e.cutEnd ?? e.end);
+ const into = Math.max(0, t - Number(seg.start));
+ if (Number.isFinite(from) && Number.isFinite(to)) {
+ if (from + into > to + 0.05) approx = true; // a held frame past the clip's own source
+ out.sourceT = Number(Math.min(to, from + into).toFixed(2));
+ const channel = channelFor(manifest, e);
+ if (channel) out.channel = channel;
+ out.video = String(e.video);
+ const origin = manifest?.provenance?.siteOrigin;
+ if (origin && channel) {
+ out.url = `${origin}/?v=${encodeURIComponent(`${channel}/${e.video}`)}&t=${Math.floor(out.sourceT)}`;
+ }
+ }
+ }
+ if (approx) out.approx = true;
+ return out;
+}
+
+/**
+ * What a moment on `rel` at `t` resolves to, or `{ entry: null }` with no schedule.
+ *
+ * @param {string} projectDir
+ * @param {string} rel
+ * @param {number} t
+ * @param {number | null} [duration]
+ */
+export async function resolveMomentOnFile(projectDir, rel, t, duration = null) {
+ const s = await scheduleForFile(projectDir, rel);
+ if (!s) return { entry: null, schedule: null };
+ return { ...resolveMoment({ ...s, t, duration }), schedule: s.scheduleRel };
+}
diff --git a/umtool/lib/report/moments.test.mjs b/umtool/lib/report/moments.test.mjs
@@ -0,0 +1,81 @@
+// A second on a rendered cut -> the entry, title, quote and source second.
+//
+// Run with: pnpm test:scripts
+import assert from "node:assert/strict";
+import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
+import { tmpdir } from "node:os";
+import path from "node:path";
+import test from "node:test";
+import { momentFileInfo, resolveMoment, resolveMomentOnFile } from "./moments.mjs";
+
+const manifest = {
+ provenance: { siteOrigin: "https://arch.test", channelSlug: "chan" },
+ timeline: [
+ { type: "teaser", id: "tz", lines: ["X"] },
+ { type: "clip", id: "c1", video: "v1", start: 100, end: 110, cutStart: 102, cutEnd: 108, quote: "the words", onscreen: { title: "Own title" } },
+ { type: "clip", id: "c2", video: "v2", channel: "other", start: 50, end: 60 },
+ ],
+};
+const schedule = {
+ transition: 0.5,
+ total: 20.5,
+ segments: [
+ { id: "tz", start: 0, end: 4.5, title: "Teaser" },
+ { id: "c1", start: 4, end: 10.5 },
+ { id: "c2", start: 10, end: 20.5, title: "Schedule title" },
+ ],
+};
+
+test("momentFileInfo reads the take and the variant from the path", () => {
+ assert.deepEqual(momentFileInfo("takes/deck/preview.mp4"), { take: "deck", variant: null });
+ assert.deepEqual(momentFileInfo("takes/deck/out/full/x.mp4"), { take: "deck", variant: "full" });
+ assert.deepEqual(momentFileInfo("out/sourced/slug.mp4"), { take: null, variant: "sourced" });
+ assert.deepEqual(momentFileInfo("video.mp4"), { take: null, variant: null });
+});
+
+test("resolveMoment: the entry on screen, the incoming one past half a crossfade", () => {
+ assert.equal(resolveMoment({ schedule, manifest, t: 1 }).entry, "tz");
+ assert.equal(resolveMoment({ schedule, manifest, t: 4.1 }).entry, "tz", "still the outgoing one");
+ const c1 = resolveMoment({ schedule, manifest, t: 6, duration: 20.5 });
+ assert.deepEqual(c1, {
+ entry: "c1",
+ title: "Own title",
+ quote: "the words",
+ sourceT: 104,
+ channel: "chan",
+ video: "v1",
+ url: "https://arch.test/?v=chan%2Fv1&t=104",
+ });
+ const c2 = resolveMoment({ schedule, manifest, t: 12 });
+ assert.equal(c2.title, "Schedule title");
+ assert.equal(c2.channel, "other");
+ assert.equal(c2.sourceT, 52);
+});
+
+test("approx: a file of another length, an estimated schedule, a held frame", () => {
+ assert.equal(resolveMoment({ schedule, manifest, t: 6, duration: 30 }).approx, true);
+ assert.equal(resolveMoment({ schedule: { ...schedule, estimated: true }, manifest, t: 6 }).approx, true);
+ // c1's cut is 6 s; 7.5 s in is a held frame
+ const held = resolveMoment({ schedule, manifest, t: 4 + 7.5 - 0.01 + 0, duration: 20.5 });
+ assert.equal(held.entry, "c2");
+ const c1held = resolveMoment({ schedule: { ...schedule, segments: [schedule.segments[0], { id: "c1", start: 4 }] }, manifest, t: 12, duration: 20.5 });
+ assert.equal(c1held.sourceT, 108);
+ assert.equal(c1held.approx, true);
+});
+
+test("resolveMomentOnFile finds a take's schedule, preferring the default variant", async () => {
+ const dir = await mkdtemp(path.join(tmpdir(), "umtool-moments-"));
+ try {
+ await writeFile(path.join(dir, "video.manifest.json"), JSON.stringify(manifest));
+ await mkdir(path.join(dir, "takes", "t1", "out", "full"), { recursive: true });
+ await mkdir(path.join(dir, "takes", "t1", "out", "sourced"), { recursive: true });
+ await writeFile(path.join(dir, "takes", "t1", "out", "sourced", "schedule.json"), JSON.stringify(schedule));
+ await writeFile(path.join(dir, "takes", "t1", "out", "full", "schedule.json"), JSON.stringify({ ...schedule, segments: [{ id: "c2", start: 0 }] }));
+ const r = await resolveMomentOnFile(dir, "takes/t1/preview.mp4", 6, 20.5);
+ assert.equal(r.entry, "c1");
+ assert.equal(r.schedule, "takes/t1/out/sourced/schedule.json");
+ assert.deepEqual(await resolveMomentOnFile(dir, "takes/none/preview.mp4", 6), { entry: null, schedule: null });
+ } finally {
+ await rm(dir, { recursive: true });
+ }
+});
diff --git a/umtool/lib/report/sections.mjs b/umtool/lib/report/sections.mjs
@@ -0,0 +1,64 @@
+// `sectionEnter`: the flag on the first clip of each section, which is what
+// makes the legacy footer's marker slide from one node to the next
+// (report-to-video/build-video.mjs reads it; README "The marker slides").
+//
+// report-to-video only ever READS it; the generators author it. This writes
+// the rule down for umtool's one writer that re-orders a timeline
+// (lib/report/manifest.mjs moveEntry), derived from what the builder reads and
+// checked against every real manifest: in the one that carries the flag
+// (quartering-employee-count, seven of nineteen clips) a clip carries it
+// exactly when its `section` differs from the previous CLIP's -- the first
+// clip counts, a card between two clips does not break a section, and a clip
+// with no `section` never enters one. Re-applying it to every real manifest
+// under ~/reports changes nothing (lib/report/sections.test.mjs has the shape).
+//
+// `section` itself is the author's: it says which chapter an entry belongs
+// to, and a move does not change that. Only the flag follows the order.
+//
+// A manifest that carries no `sectionEnter` key at all (every deck-era cut:
+// the deck draws no footer) is left exactly as it is: `applySectionEnter`
+// changes nothing unless some entry already carries the key.
+
+/**
+ * The flag each entry should carry, by index: true on a clip whose `section`
+ * is set and differs from the previous clip's; false everywhere else.
+ *
+ * @param {Array<Record<string, unknown>>} timeline
+ * @returns {boolean[]}
+ */
+export function sectionEnterFlags(timeline) {
+ let prev;
+ return (timeline ?? []).map((e) => {
+ if (e?.type !== "clip") return false;
+ const s = e.section;
+ const enters = s !== undefined && s !== null && s !== prev;
+ prev = s;
+ return enters;
+ });
+}
+
+/** Does this timeline use the flag at all? */
+export const usesSectionEnter = (timeline) => (timeline ?? []).some((e) => e && Object.hasOwn(e, "sectionEnter"));
+
+/**
+ * Recompute `sectionEnter` in place after a re-order. Only on a timeline that
+ * already uses it; written as `true` or removed (an absent key reads as false,
+ * and `"sectionEnter": false` is noise a human reads as a decision). Returns
+ * the ids whose flag changed.
+ *
+ * @param {Array<Record<string, unknown>>} timeline
+ * @returns {string[]}
+ */
+export function applySectionEnter(timeline) {
+ if (!usesSectionEnter(timeline)) return [];
+ const flags = sectionEnterFlags(timeline);
+ const changed = [];
+ timeline.forEach((e, i) => {
+ const was = e.sectionEnter === true;
+ if (flags[i] === was) return;
+ if (flags[i]) e.sectionEnter = true;
+ else delete e.sectionEnter;
+ changed.push(String(e.id));
+ });
+ return changed;
+}
diff --git a/umtool/lib/report/sections.test.mjs b/umtool/lib/report/sections.test.mjs
@@ -0,0 +1,42 @@
+// The sectionEnter rule, against the shape of the one manifest that uses it.
+import assert from "node:assert/strict";
+import test from "node:test";
+import { applySectionEnter, sectionEnterFlags, usesSectionEnter } from "./sections.mjs";
+
+const clip = (id, section, extra = {}) => ({ type: "clip", id, section, ...extra });
+
+test("a clip enters a section when its section differs from the previous clip's", () => {
+ const tl = [
+ { type: "card", id: "t0" },
+ clip("a", 1),
+ clip("b", 1),
+ { type: "card", id: "mid" },
+ clip("c", 1),
+ clip("d", 2),
+ clip("e"),
+ clip("f", 2),
+ { type: "scroll", id: "s" },
+ ];
+ assert.deepEqual(sectionEnterFlags(tl), [false, true, false, false, false, true, false, true, false]);
+});
+
+test("applySectionEnter leaves a timeline that never used the flag alone", () => {
+ const tl = [clip("a", 1), clip("b", 2)];
+ assert.deepEqual(applySectionEnter(tl), []);
+ assert.equal(usesSectionEnter(tl), false);
+ assert.equal("sectionEnter" in tl[0], false);
+});
+
+test("applySectionEnter is the identity on a timeline already in order, and fixes a move", () => {
+ const tl = [clip("a", 1, { sectionEnter: true }), clip("b", 1), clip("c", 2, { sectionEnter: true }), clip("d", 2)];
+ assert.deepEqual(applySectionEnter(tl), []);
+ // d moved to the front: d enters 2, a enters 1, c no longer enters (b was 1, c is 2 -> still enters)
+ const moved = [tl[3], tl[0], tl[1], tl[2]];
+ assert.deepEqual(applySectionEnter(moved).sort(), ["d"]);
+ assert.equal(moved[0].sectionEnter, true);
+ assert.equal(moved[3].sectionEnter, true);
+ // and moving it back removes the flag again (deleted, never written false)
+ const back = [moved[1], moved[2], moved[3], moved[0]];
+ assert.deepEqual(applySectionEnter(back), ["d"]);
+ assert.equal("sectionEnter" in back[3], false);
+});
diff --git a/umtool/lib/report/structure.test.mjs b/umtool/lib/report/structure.test.mjs
@@ -0,0 +1,235 @@
+// The structural writers: move, remove, duplicate, insert, the teaser, the
+// posts, the fact-check, and undo. Each goes through the token, the lock, the
+// build's own checks and an automatic snapshot, and each refusal leaves the
+// file byte-for-byte as it was.
+//
+// Run with: pnpm test:scripts
+import assert from "node:assert/strict";
+import { mkdtemp, readFile, readdir, rm, utimes, writeFile } from "node:fs/promises";
+import { tmpdir } from "node:os";
+import path from "node:path";
+import test from "node:test";
+
+import {
+ MANIFEST_NAME,
+ StaleToken,
+ StructureRefused,
+ duplicateEntry,
+ entryFromSpec,
+ insertEntry,
+ manifestToken,
+ moveEntry,
+ removeEntry,
+ removePost,
+ undoStructural,
+ updateFactcheck,
+ updateTeaser,
+ upsertPost,
+} from "./manifest.mjs";
+
+const base = () => ({
+ slug: "t",
+ provenance: { siteOrigin: "https://example.test", channelSlug: "chan" },
+ render: { width: 1920, height: 1080, fps: 30, transition: 0.5 },
+ timeline: [
+ { type: "teaser", id: "tz", lines: ["THE PROMISE"] },
+ { type: "clip", id: "c01", video: "v1", start: 10, end: 20, section: 1, sectionEnter: true },
+ { type: "clip", id: "c02", video: "v2", start: 30, end: 41, section: 1 },
+ { type: "clip", id: "c03", video: "v3", start: 50, end: 55, section: 2, sectionEnter: true },
+ ],
+ posts: [
+ { id: "p1", platform: "x", date: "2024-01-02", text: "hello", url: "https://x.com/a/status/1", attachTo: "c02" },
+ ],
+});
+
+async function project(manifest = base()) {
+ const dir = await mkdtemp(path.join(tmpdir(), "umtool-structure-"));
+ await writeFile(path.join(dir, MANIFEST_NAME), JSON.stringify(manifest, null, 2) + "\n");
+ return dir;
+}
+const readRaw = (dir) => readFile(path.join(dir, MANIFEST_NAME), "utf8");
+const read = async (dir) => JSON.parse(await readRaw(dir));
+const ids = (m) => m.timeline.map((e) => e.id);
+const revisions = async (dir) => (await readdir(path.join(dir, "revisions")).catch(() => [])).sort();
+
+test("moveEntry: re-orders, recomputes sectionEnter, snapshots once per burst", async () => {
+ const dir = await project();
+ try {
+ const r = await moveEntry(dir, "c03", 1, { token: await manifestToken(dir) });
+ assert.deepEqual([r.from, r.to], [3, 1]);
+ const m = await read(dir);
+ assert.deepEqual(ids(m), ["tz", "c03", "c01", "c02"]);
+ // c03 (section 2) now first: it enters; c01 (section 1, after a 2) enters; c02 does not
+ assert.equal(m.timeline[1].sectionEnter, true);
+ assert.equal(m.timeline[2].sectionEnter, true);
+ assert.equal("sectionEnter" in m.timeline[3], false);
+ // The file is still the CLI's formatting.
+ assert.equal(await readRaw(dir), JSON.stringify(m, null, 2) + "\n");
+ assert.equal((await revisions(dir)).filter((n) => n.includes("auto-before-move")).length, 1);
+ await moveEntry(dir, "c03", 3);
+ assert.equal((await revisions(dir)).filter((n) => n.includes("auto-before-move")).length, 1, "throttled");
+ await assert.rejects(moveEntry(dir, "c03", 3), /already at 3/);
+ await assert.rejects(moveEntry(dir, "c03", 9), /toIndex/);
+ await assert.rejects(moveEntry(dir, "nope", 0), /no timeline entry/);
+ } finally {
+ await rm(dir, { recursive: true });
+ }
+});
+
+test("a stale token refuses every structural write and leaves the file alone", async () => {
+ const dir = await project();
+ try {
+ const before = await readRaw(dir);
+ for (const call of [
+ () => moveEntry(dir, "c03", 1, { token: "1" }),
+ () => removeEntry(dir, "c03", { token: "1" }),
+ () => duplicateEntry(dir, "c03", { token: "1" }),
+ () => insertEntry(dir, "c03", "chan/vx@1-5", { token: "1" }),
+ () => updateTeaser(dir, "tz", { beat: 1 }, { token: "1" }),
+ () => upsertPost(dir, { id: "p2" }, { token: "1" }),
+ () => removePost(dir, "p1", { token: "1" }),
+ () => undoStructural(dir, { token: "1" }),
+ ]) {
+ await assert.rejects(call(), StaleToken);
+ }
+ assert.equal(await readRaw(dir), before);
+ } finally {
+ await rm(dir, { recursive: true });
+ }
+});
+
+test("removeEntry: refused while a post rides on the clip; fine once it does not", async () => {
+ const dir = await project();
+ try {
+ const before = await readRaw(dir);
+ await assert.rejects(removeEntry(dir, "c02"), (e) => e instanceof StructureRefused && /attachTo/.test(e.message));
+ assert.equal(await readRaw(dir), before);
+ const r = await removeEntry(dir, "c03");
+ assert.equal(r.removed.id, "c03");
+ assert.deepEqual(ids(await read(dir)), ["tz", "c01", "c02"]);
+ } finally {
+ await rm(dir, { recursive: true });
+ }
+});
+
+test("duplicateEntry and insertEntry: fresh ids, the clip spec, 2 dp, at the right place", async () => {
+ const dir = await project();
+ try {
+ const d = await duplicateEntry(dir, "c01");
+ assert.equal(d.id, "c01-copy");
+ const again = await duplicateEntry(dir, "c01");
+ assert.equal(again.id, "c01-copy-2");
+ const ins = await insertEntry(dir, "c02", "mychan/abc123@12.3456-20.1");
+ assert.deepEqual(ins.entry, { type: "clip", id: "abc123-12", channel: "mychan", video: "abc123", start: 12.35, end: 20.1 });
+ const first = await insertEntry(dir, null, { type: "card", heading: "Start" });
+ assert.equal(first.at, 0);
+ assert.equal(first.id, "card");
+ const m = await read(dir);
+ assert.deepEqual(ids(m), ["card", "tz", "c01", "c01-copy-2", "c01-copy", "c02", "abc123-12", "c03"]);
+ await assert.rejects(insertEntry(dir, "c02", "not a spec"), /channel/);
+ await assert.rejects(insertEntry(dir, "c02", "chan/v@5-5.2"), /half a second/);
+ // A teaser that the build would refuse is refused here.
+ await assert.rejects(insertEntry(dir, "c02", { type: "teaser", lines: [] }), StructureRefused);
+ } finally {
+ await rm(dir, { recursive: true });
+ }
+});
+
+test("entryFromSpec never reuses an id", () => {
+ const tl = [{ id: "a" }, { id: "v-1" }];
+ assert.equal(entryFromSpec(tl, { type: "card", id: "a" }).id, "a-2");
+ assert.equal(entryFromSpec(tl, "c/v@1-3").id, "v-1-2");
+});
+
+test("updateTeaser: lines, beat, tail, dip — validated by the build's own check", async () => {
+ const dir = await project();
+ try {
+ const r = await updateTeaser(dir, "tz", { lines: ["THE PROMISE", "AND WHAT HAPPENED"], beat: 1.2345, tail: "?", tailWait: 1 });
+ assert.equal(r.entry.beat, 1.23);
+ await assert.rejects(updateTeaser(dir, "tz", { lines: [] }), StructureRefused);
+ await assert.rejects(updateTeaser(dir, "tz", { tail: null }), /tailWait/);
+ await updateTeaser(dir, "tz", { tail: null, tailWait: null });
+ const m = await read(dir);
+ assert.equal("tail" in m.timeline[0], false);
+ await assert.rejects(updateTeaser(dir, "c01", { beat: 1 }), /not a teaser/);
+ await assert.rejects(updateTeaser(dir, "tz", { seconds: 4 }), /not something/);
+ } finally {
+ await rm(dir, { recursive: true });
+ }
+});
+
+test("upsertPost / removePost: add, replace keeping attachTo, refuse what validatePosts refuses", async () => {
+ const dir = await project();
+ try {
+ const add = await upsertPost(dir, { id: "p2", platform: "bluesky", date: "2024-03-04", text: " words ", url: "https://bsky.app/x", author: "" });
+ assert.equal(add.created, true);
+ assert.deepEqual(add.post, { id: "p2", platform: "bluesky", date: "2024-03-04", text: "words", url: "https://bsky.app/x" });
+ const rep = await upsertPost(dir, { id: "p1", platform: "x", date: "2024-01-02", text: "edited", url: "https://x.com/a/status/1" });
+ assert.equal(rep.post.attachTo, "c02");
+ await assert.rejects(upsertPost(dir, { id: "p3", platform: "myspace", date: "2024", text: "x", url: "http://no" }), StructureRefused);
+ await removePost(dir, "p2");
+ await removePost(dir, "p1");
+ assert.equal("posts" in (await read(dir)), false);
+ await assert.rejects(removePost(dir, "p1"), /no post/);
+ } finally {
+ await rm(dir, { recursive: true });
+ }
+});
+
+test("updateFactcheck: needs the deck; labels and colours checked by validateChrome", async () => {
+ const dir = await project();
+ try {
+ await assert.rejects(updateFactcheck(dir, { stamp: { seconds: 3 } }), /deck on first/);
+ const m = await read(dir);
+ m.render.chrome = { engine: "hyperframes", layout: "deck" };
+ await writeFile(path.join(dir, MANIFEST_NAME), JSON.stringify(m, null, 2) + "\n");
+ const r = await updateFactcheck(dir, { verdicts: { CONTRADICTED: { label: "NOPE", color: "#ff0000" } }, stamp: { seconds: 4 } });
+ assert.equal(r.factcheck.stamp.seconds, 4);
+ await assert.rejects(updateFactcheck(dir, { stamp: { seconds: 99 } }), StructureRefused);
+ await updateFactcheck(dir, null);
+ assert.equal("factcheck" in (await read(dir)).render.chrome, false);
+ } finally {
+ await rm(dir, { recursive: true });
+ }
+});
+
+test("undoStructural restores the newest automatic snapshot, keeps the current state, and walks back", async () => {
+ const dir = await project();
+ try {
+ const original = await readRaw(dir);
+ await assert.rejects(undoStructural(dir), /nothing to undo/);
+ await moveEntry(dir, "c03", 1);
+ // Age the move's snapshot so the next op is not throttled into it.
+ const rev = path.join(dir, "revisions");
+ for (const n of await readdir(rev)) await utimes(path.join(rev, n), new Date(Date.now() - 600_000), new Date(Date.now() - 600_000));
+ await new Promise((r) => setTimeout(r, 1100));
+ await removeEntry(dir, "c01");
+ const afterRemove = ids(await read(dir));
+ assert.deepEqual(afterRemove, ["tz", "c03", "c02"]);
+
+ const u1 = await undoStructural(dir);
+ assert.equal(u1.op, "remove");
+ assert.deepEqual(ids(await read(dir)), ["tz", "c03", "c01", "c02"]);
+ await new Promise((r) => setTimeout(r, 1100));
+ const u2 = await undoStructural(dir);
+ assert.equal(u2.op, "move");
+ assert.equal(await readRaw(dir), original, "byte for byte");
+ const names = await revisions(dir);
+ assert.ok(names.some((n) => n.includes("undone-move")));
+ assert.ok(names.some((n) => n.includes("undo-saved")));
+ } finally {
+ await rm(dir, { recursive: true });
+ }
+});
+
+test("a manifest that already has a build problem can still be re-ordered", async () => {
+ const m = base();
+ m.posts[0].attachTo = "gone"; // already broken before the edit
+ const dir = await project(m);
+ try {
+ await moveEntry(dir, "c03", 1);
+ assert.deepEqual(ids(await read(dir)), ["tz", "c03", "c01", "c02"]);
+ } finally {
+ await rm(dir, { recursive: true });
+ }
+});
diff --git a/umtool/lib/report/takes.mjs b/umtool/lib/report/takes.mjs
@@ -290,3 +290,33 @@ export async function setTakeVerdict(projectDir, takeId, patch) {
return { entry: map[takeId] ?? null, verdicts: map };
});
}
+
+/**
+ * Every take with its verdict, for an agent reading the conversation back
+ * (`umtool notes`, the notes digest): label, group, summary and changes from
+ * take.json, and the operator's verdict and note from verdicts.json. A verdict
+ * on a take that no longer exists is kept, with `missing: true`, rather than
+ * dropped -- the agent may have deleted the take it was about.
+ *
+ * @param {string} projectDir
+ * @returns {Promise<Array<{ id: string, group: string | null, label: string | null, summary: string, changes: string[], verdict: "like" | "maybe" | "no" | null, note: string, at: string, missing?: true }>>}
+ */
+export async function takesWithVerdicts(projectDir) {
+ const [listing, verdicts] = await Promise.all([listTakes(projectDir), readVerdicts(projectDir)]);
+ const out = listing.takes.map((t) => ({
+ id: t.id,
+ group: t.group,
+ label: t.label,
+ summary: t.summary,
+ changes: t.changes,
+ verdict: verdicts[t.id]?.verdict ?? null,
+ note: verdicts[t.id]?.note ?? "",
+ at: verdicts[t.id]?.at ?? "",
+ }));
+ const known = new Set(out.map((t) => t.id));
+ for (const [id, v] of Object.entries(verdicts)) {
+ if (known.has(id)) continue;
+ out.push({ id, group: null, label: null, summary: "", changes: [], verdict: v.verdict, note: v.note, at: v.at, missing: true });
+ }
+ return out;
+}
diff --git a/umtool/lib/report/takes.test.mjs b/umtool/lib/report/takes.test.mjs
@@ -237,3 +237,26 @@ test("concurrent verdicts on different takes are all kept", async () => {
await rm(dir, { recursive: true, force: true });
}
});
+
+test("takesWithVerdicts joins take.json and verdicts.json, keeping a verdict on a vanished take", async () => {
+ const { takesWithVerdicts } = await import("./takes.mjs");
+ const dir = await mkdtemp(path.join(tmpdir(), "umtool-takes-digest-"));
+ try {
+ await mkdir(path.join(dir, "takes", "a"), { recursive: true });
+ await writeFile(
+ path.join(dir, "takes", "a", "take.json"),
+ JSON.stringify({ id: "a", group: "g", order: 1, label: "A", kind: "reference", preview: "p.mp4", summary: "s" }),
+ );
+ await writeFile(
+ path.join(dir, "takes", "verdicts.json"),
+ JSON.stringify({ a: { verdict: "like", note: "yes", at: "x" }, gone: { verdict: "no", note: "", at: "y" } }),
+ );
+ const rows = await takesWithVerdicts(dir);
+ assert.deepEqual(rows.map((r) => [r.id, r.label, r.verdict, r.note, r.missing ?? false]), [
+ ["a", "A", "like", "yes", false],
+ ["gone", null, "no", "", true],
+ ]);
+ } finally {
+ await rm(dir, { recursive: true });
+ }
+});