commit 282802734c9e1cdb64b2a3e4600ff3b8bf7ab7d6
parent 3890e1d4540d5a7fbf6175e88c866811d437151b
Author: I Mean I'm Just Saying <imeanimjustsaying@kiwifarms.st>
Date: Thu, 8 Oct 2026 22:40:23 -0400
umtool: edit notes on generated manifests, the moment resolver, the timeline route
- lib/report/guard.ts withEditNotes: every manifest writer route (window, cut,
onscreen, posts, chrome, claim, timeline) diffs the manifest around the write
and, when it has generatedBy, leaves coalesced edit notes for the agent
- lib/report/moments.mjs + /api/report/moment: t on a cut -> entry, title,
quote, source second, archive link; approx when the file is not the build's
- /api/report/timeline: move/remove/duplicate/insert/teaser/post/factcheck/undo
- sectionEnter rule lives in lib/report/sections.mjs (report-to-video untouched)
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Diffstat:
18 files changed, 755 insertions(+), 88 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,111 @@
+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";
+
+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, and the timeline's ids in order
+// 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 timeline = ((r.manifest.timeline ?? []) as { id: string; type?: string }[]).map((e) => ({ id: e.id, type: e.type ?? "entry" }));
+ return Response.json(
+ { timeline, 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;
+ const token = body.token === undefined ? null : String(body.token);
+ 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/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
@@ -31,7 +31,7 @@ import {
} from "umtool-report-to-video/ledger-totals";
import { isCalendarDate } from "umtool-report-to-video/attribution";
import { normalizeOnscreen, validateChrome, validatePosts, validateTeaser, validateTeasers } from "umtool-report-to-video/deck";
-import { applySectionEnter } from "umtool-report-to-video/sections";
+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";
diff --git a/umtool/lib/report/moments.mjs b/umtool/lib/report/moments.mjs
@@ -0,0 +1,144 @@
+// 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);
+ variants.sort((a, b) => (a === DEFAULT_VARIANT ? -1 : b === DEFAULT_VARIANT ? 1 : 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. */
+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/report-to-video/sections.test.mjs b/umtool/lib/report/sections.test.mjs
diff --git a/umtool/report-to-video/package.json b/umtool/report-to-video/package.json
@@ -32,7 +32,6 @@
"./post-links": "./post-links.mjs",
"./render-cards": "./render-cards.mjs",
"./resolve-windows": "./resolve-windows.mjs",
- "./sections": "./sections.mjs",
"./shoot-page": "./shoot-page.mjs",
"./sources": "./sources.mjs",
"./verify-build": "./verify-build.mjs"
diff --git a/umtool/report-to-video/sections.mjs b/umtool/report-to-video/sections.mjs
@@ -1,59 +0,0 @@
-// `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
-// (build-video.mjs reads it; README "The marker slides").
-//
-// The build has only ever READ it. The rule lived in whoever wrote the
-// manifest: in the one real manifest that carries it
-// (quartering-employee-count, seven of nineteen clips), a clip carries the flag
-// 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. This is that rule, written once, for a writer
-// that re-orders the timeline (umtool's lib/report/manifest.mjs moveEntry).
-//
-// 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;
-}