Archilyzer · Source

archilyzer

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

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:
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 | 111+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mumtool/app/api/report/window/route.ts | 11+++++++----
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 | 2+-
Aumtool/lib/report/moments.mjs | 144+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Aumtool/lib/report/moments.test.mjs | 81+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Aumtool/lib/report/sections.mjs | 64++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Rumtool/report-to-video/sections.test.mjs -> umtool/lib/report/sections.test.mjs | 0
Mumtool/report-to-video/package.json | 1-
Dumtool/report-to-video/sections.mjs | 59-----------------------------------------------------------
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; -}