Archilyzer · Source

archilyzer

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

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:
Mumtool/app/api/report/chrome/route.ts | 10+++++++---
Mumtool/app/api/report/claim/route.ts | 14++++++++++----
Mumtool/app/api/report/cut/route.ts | 15+++++++++------
Aumtool/app/api/report/moment/route.ts | 26++++++++++++++++++++++++++
Mumtool/app/api/report/onscreen/route.ts | 13++++++++-----
Mumtool/app/api/report/posts/route.ts | 13++++++++-----
Aumtool/app/api/report/timeline/route.ts | 134+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mumtool/app/api/report/window/route.ts | 11+++++++----
Aumtool/components/notes/AnchoredNotes.tsx | 74++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Aumtool/components/notes/GeneratedBanner.tsx | 15+++++++++++++++
Aumtool/components/notes/NoteThread.tsx | 136+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Aumtool/components/notes/NotesProvider.tsx | 56++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Aumtool/components/notes/TimedNotes.tsx | 234+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mumtool/components/projects/ClipBench.tsx | 18++++++++++++++++--
Mumtool/components/projects/ClipBenchPage.tsx | 1+
Mumtool/components/projects/OnscreenSection.tsx | 56+++++++++++++++++++++++++++++++++++++++++++++++++++-----
Mumtool/components/projects/ReportProject.tsx | 31++++++++++++++++++++++++-------
Aumtool/components/projects/StructureEditors.tsx | 286+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mumtool/components/projects/TakesBench.tsx | 30++++++++++++++++++++++++++++--
Aumtool/components/projects/TimelineEditor.tsx | 205+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Aumtool/components/projects/timelineApi.ts | 39+++++++++++++++++++++++++++++++++++++++
Mumtool/e2e/fixtures/make-fixture.mjs | 68++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Aumtool/e2e/timeline-edit.spec.ts | 136+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Aumtool/e2e/video-notes.spec.ts | 151++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mumtool/lib/annotations/targets.mjs | 5+++--
Mumtool/lib/projects/kinds.mjs | 6++++++
Aumtool/lib/report/edit-notes.mjs | 174+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Aumtool/lib/report/edit-notes.test.mjs | 67+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Aumtool/lib/report/guard.ts | 38++++++++++++++++++++++++++++++++++++++
Mumtool/lib/report/manifest.mjs | 382++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-
Aumtool/lib/report/moments.mjs | 155+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Aumtool/lib/report/moments.test.mjs | 81+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Aumtool/lib/report/sections.mjs | 64++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Aumtool/lib/report/sections.test.mjs | 42++++++++++++++++++++++++++++++++++++++++++
Aumtool/lib/report/structure.test.mjs | 235+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mumtool/lib/report/takes.mjs | 30++++++++++++++++++++++++++++++
Mumtool/lib/report/takes.test.mjs | 23+++++++++++++++++++++++
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&rsquo;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 }); + } +});