Archilyzer · Source

archilyzer

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

commit 2cd9a2aeb67abe6db1eb41a96224a83ea0155600
parent 998a627492e5220c76534291c839afb0341276a5
Author: I Mean I'm Just Saying <imeanimjustsaying@kiwifarms.st>
Date:   Fri, 18 Sep 2026 17:37:33 -0400

umtool: a clip can record what the report got wrong

`correction` is free text written while watching the clip and read by nobody
downstream — not the renderer, not the QR, not the header. It says the REPORT
has a defect: wrong speaker, wrong addressee, wrong date. destinys-child's c07
is the case, where the report attributed to Destiny a line Dan says to her.

Deliberately not a decision in the inbox: nothing here can close it, because the
fix belongs to the next sweep rather than to this manifest. So it is collected
instead — `correctionsOf()` is the one definition, the project page lists it, and
`umtool corrections <project>` prints the same list as markdown with each clip's
own QR moment link per bullet, which is the form it actually gets used in.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

Diffstat:
Mumtool/app/api/report/clip/route.ts | 1+
Mumtool/app/api/report/window/route.ts | 1+
Mumtool/bin/umtool.mjs | 33++++++++++++++++++++++++++++++++-
Mumtool/components/projects/ClipBench.tsx | 6+++++-
Mumtool/components/projects/ClipBenchPage.tsx | 1+
Mumtool/components/projects/ReportProject.tsx | 56+++++++++++++++++++++++++++++++++++++++++++++++++++++++-
Mumtool/docs/clip-bench.md | 75+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++--
Mumtool/docs/e2e.md | 2+-
Mumtool/docs/report-video.md | 43+++++++++++++++++++++++++++++++++++++++++++
Mumtool/lib/projects/report.mjs | 22++++++++++++++++++++++
Mumtool/lib/report/manifest.mjs | 7++++++-
11 files changed, 240 insertions(+), 7 deletions(-)

diff --git a/umtool/app/api/report/clip/route.ts b/umtool/app/api/report/clip/route.ts @@ -49,6 +49,7 @@ export async function GET(request: Request) { cite: clip.cite ?? null, quote: clip.quote ?? null, note: clip.note ?? null, + correction: clip.correction ?? null, title: clip.title ?? null, date: clip.date ?? null, citeUrl: clip.citeUrl ?? null, diff --git a/umtool/app/api/report/window/route.ts b/umtool/app/api/report/window/route.ts @@ -42,6 +42,7 @@ export async function PUT(request: Request) { "cite", "citeUrl", "quote", + "correction", ]) { if (body[k] !== undefined) patch[k] = body[k]; } diff --git a/umtool/bin/umtool.mjs b/umtool/bin/umtool.mjs @@ -19,6 +19,8 @@ // umtool kinds [--json] // umtool window <project> <clip> [--start S] [--end E] [--lock] [--lock-end] // [--title T] [--date YYYY-MM-DD] [--cite S] [--cite-url U] [--quote Q] +// [--correction TEXT] +// umtool corrections <project> what the report got wrong, as markdown for the next pass // umtool build <project> [--preset preview|fast|final] [--only ID] [--dry] // umtool index [--rebuild] [--prune] [--since MS] [--json] // umtool new <slug> [--kind report-video] [--from <report.md>|<share URL>|<channel>/<id>] @@ -39,7 +41,7 @@ import { resolveProject, summarise, } from "../lib/projects/core.mjs"; -import { readClipDetail, readManifest, sourcesOf } from "../lib/projects/report.mjs"; +import { correctionsOf, readClipDetail, readManifest, sourcesOf } from "../lib/projects/report.mjs"; import { createSnapshot, listSnapshots, readSnapshot } from "../lib/report/snapshots.mjs"; import { diffManifests, formatChange } from "../lib/report/manifest-diff.mjs"; import { EXPORT_FORMATS, exportProject } from "../lib/report/export.mjs"; @@ -434,7 +436,9 @@ function usage() { " umtool kinds [--json]", " umtool window <project> <clip> [--start S] [--end E] [--lock|--lock-end|…]", " attribution too: --title, --date YYYY-MM-DD, --cite, --cite-url, --quote", + " --correction TEXT what the REPORT got wrong here (never rendered)", " an empty value (--title '') deletes the field", + " umtool corrections <project> what the report got wrong, as markdown", " umtool build <project> [--preset preview|fast|final] [--only ID]", " umtool index [--rebuild] [--prune] [--since MS] [--json]", " umtool new <slug> [--from <report.md>|<share URL>|<channel>/<id>] [--site-origin URL] [--seed chapters]", @@ -455,6 +459,7 @@ function usage() { const COMMANDS = { ls: cmdLs, window: cmdWindow, + corrections: cmdCorrections, build: cmdBuild, index: cmdIndex, new: cmdNew, @@ -512,6 +517,7 @@ async function cmdWindow() { ["--cite", "cite"], ["--cite-url", "citeUrl"], ["--quote", "quote"], + ["--correction", "correction"], ]) { if (val(flag) !== undefined) patch[key] = val(flag); } @@ -537,6 +543,9 @@ async function cmdWindow() { ); if (res.entry.citeUrl) console.log(` QR: ${res.entry.citeUrl}`); } + if (patch.correction !== undefined) { + console.log(res.entry.correction ? ` correction: ${res.entry.correction}` : " correction cleared"); + } console.log(`\nRun resolve-windows to see whether the widener agrees:`); console.log(` node umtool/report-to-video/resolve-windows.mjs ${p.dir}/video.manifest.json`); } catch (e) { @@ -544,6 +553,28 @@ async function cmdWindow() { } } +// What the REPORT got wrong, collected for the next pass. +// +// Markdown, and the moment link is the QR's own target -- so a bullet can be +// pasted straight into a prompt or a sweep and the reader can open the exact +// second the correction is about. That is the whole point: a correction that +// cannot be checked is an assertion. +async function cmdCorrections() { + const p = await pick(positional[0]); + const manifest = await readManifest(p.dir); + if (!manifest) die("no manifest"); + const rows = correctionsOf(manifest); + + if (json) return out({ project: p.id, corrections: rows }); + if (!rows.length) return console.log("no corrections"); + + console.log(`# Corrections for the next pass — ${manifest.title ?? p.id}\n`); + for (const c of rows) { + console.log(`- **${c.id}** · ${c.channel}/${c.video} @ ${hms(c.at)} · <${c.href}>`); + console.log(` ${c.text}`); + } +} + async function cmdBuild() { const p = await pick(positional[0]); const preset = val("--preset") ?? "fast"; diff --git a/umtool/components/projects/ClipBench.tsx b/umtool/components/projects/ClipBench.tsx @@ -47,6 +47,8 @@ type Clip = { cite: number | null; quote: string | null; note: string | null; + /** What the REPORT got wrong about this clip. Never rendered. */ + correction: string | null; /** The attribution overrides. Absent means "use the archived record's own". */ title: string | null; date: string | null; @@ -63,6 +65,7 @@ const ATTRIB = [ ["cite", "cite", "The second the header prints, in absolute source seconds. Empty uses the clip's start."], ["citeUrl", "citeUrl", "Where the QR points. Empty derives it from the archive. Set it when the clip is cut from a mirror that reads better."], ["quote", "quote", "The words this clip exists for. Not drawn on screen — it is what the cut is checked against."], + ["correction", "correction", "What the report got wrong here — wrong speaker, wrong addressee, wrong date. Read by the next report pass, never rendered."], ] as const; type AttribKey = (typeof ATTRIB)[number][0]; @@ -88,6 +91,7 @@ const fromEntry = (prev: Clip, e: Record<string, unknown>): Clip => ({ cite: e.cite == null ? null : Number(e.cite), quote: (e.quote as string) ?? null, note: (e.note as string) ?? null, + correction: (e.correction as string) ?? null, title: (e.title as string) ?? null, date: (e.date as string) ?? null, citeUrl: (e.citeUrl as string) ?? null, @@ -638,7 +642,7 @@ export default function ClipBench({ data }: { data: ClipBenchData }) { </span> )} <span className="block text-[11px] text-[var(--color-dim)]">{why}</span> - {k === "quote" ? ( + {k === "quote" || k === "correction" ? ( <textarea data-attrib-field={k} name={k} diff --git a/umtool/components/projects/ClipBenchPage.tsx b/umtool/components/projects/ClipBenchPage.tsx @@ -53,6 +53,7 @@ export default async function ClipBenchPage({ cite: entry.cite ?? null, quote: entry.quote ?? null, note: entry.note ?? null, + correction: entry.correction ?? null, title: entry.title ?? null, date: entry.date ?? null, citeUrl: entry.citeUrl ?? null, diff --git a/umtool/components/projects/ReportProject.tsx b/umtool/components/projects/ReportProject.tsx @@ -6,7 +6,7 @@ import CopyButton from "@/components/CopyButton"; import CheckSourcesButton from "@/components/dashboard/CheckSourcesButton"; import { fmtAgo, fmtBytes } from "@/lib/format"; import { Markdown } from "@/lib/markdown"; -import { readClipDetail, sourcesOf } from "@/lib/projects/report.mjs"; +import { correctionsOf, readClipDetail, sourcesOf } from "@/lib/projects/report.mjs"; import { EXPORT_FORMATS, exportableVariants } from "@/lib/report/export.mjs"; import { diffManifests, formatChange } from "@/lib/report/manifest-diff.mjs"; import { listSnapshots, readSnapshot } from "@/lib/report/snapshots.mjs"; @@ -71,6 +71,14 @@ export default async function ReportProject({ } const { manifest: m, build, entries, channelsDir, shadowExists } = detail; + const corrections = correctionsOf(m) as { + id: string; + channel: string | null; + video: string; + at: number; + href: string; + text: string; + }[]; const clips = entries.filter((e) => e.kind === "clip"); const nonClips = entries.filter((e) => e.kind !== "clip"); const runtime = clips.reduce((n, e) => n + Math.max(0, e.end - e.start), 0); @@ -463,6 +471,52 @@ export default async function ReportProject({ </div> </section> + {/* --- corrections ------------------------------------------------ */} + {/* + Not a decision and not a defect in the CUT: a correction says the + REPORT got something wrong -- wrong speaker, wrong addressee, wrong + date -- and the fix belongs to the next sweep, not to this manifest. + So it is collected here rather than filed in the inbox, in the same + shape `umtool corrections` prints, because the list's real destination + is somebody's next prompt. + */} + {corrections.length > 0 && ( + <section + data-corrections="" + className="rounded border border-[var(--color-line)] bg-[var(--color-panel)] px-3 py-2" + > + <h2 className="micro mb-1.5">corrections for the next pass — {corrections.length}</h2> + <ul className="space-y-1.5"> + {corrections.map((c) => ( + <li key={c.id} data-correction={c.id} className="text-[12px] leading-snug"> + <Link + href={`/browse/${project.id}/clip/${c.id}`} + className="font-mono text-[11px] text-[var(--color-sel)] hover:underline" + > + {c.id} + </Link>{" "} + <span className="text-[11px] text-[var(--color-dim)]"> + {c.channel}/{c.video} @ {hms(c.at)} + </span>{" "} + <a + href={c.href} + target="_blank" + rel="noreferrer" + className="text-[11px] text-[var(--color-sel)] hover:underline" + > + the moment + </a> + <div className="text-[var(--color-dim)]">{c.text}</div> + </li> + ))} + </ul> + <p className="mt-2 text-[11px] text-[var(--color-dim)]"> + <code className="font-mono">umtool corrections {project.id}</code> prints the same + list as markdown, with the moment links, ready to paste into the next sweep. + </p> + </section> + )} + {/* --- revisions ------------------------------------------------- */} <section data-revisions="" className="rounded border border-[var(--color-line)] bg-[var(--color-panel)] px-3 py-2"> <div className="mb-1 flex flex-wrap items-baseline gap-3"> diff --git a/umtool/docs/clip-bench.md b/umtool/docs/clip-bench.md @@ -55,6 +55,64 @@ means the next `--write` reverts it, so the bench offers to set the matching loc That is why five of six real manifests are 100% locked. "Run the widener and see" stops being a leap of faith. +## The attribution, beside the edges + +Watching a clip is when you find out *both* that it starts too late *and* that +the header calls it by the archive's title and the archive's upload date — which +for a VOD mirror is years after the stream. So the five fields that decide the +burned-in line — `title`, `date`, `cite`, `citeUrl`, `quote` — are in the same +panel as the handles, each saving on blur or <kbd>Enter</kbd>, each showing what +it was when it differs from what is saved. + +Above them is a **live preview of the exact line ffmpeg will draw**, built by +calling the renderer's own `attributionLine()` from +`report-to-video/attribution.mjs`. Not a reimplementation: a preview that +disagrees with the header by one character is worth less than no preview, +because the only way to discover it is a twenty-minute build. + +Beside the preview is the archive's own answer — *"the archive says `<title>` · +`<uploadDate>`"* — because "the date is wrong" is only visible when you can see +which date it would otherwise use. An empty field goes back to it. + +`date` is refused unless it is a real calendar day, and `citeUrl` unless it is +`http(s)`; the message comes back from the writer and lands under the controls, +with what you typed left in the box to fix. + +## `correction` — a message to the next report pass + +The sixth field in that panel is not about this video at all. It records what the +**report** got wrong about this clip — wrong speaker, wrong addressee, wrong date +— and nothing renders it. The project page collects every one under *corrections +for the next pass*, and `umtool corrections <project>` prints the same list as +markdown with each clip's own moment link, ready to paste into the next sweep. + +It is not filed as a decision because nothing here can close it: the fix belongs +to the next sweep, not to this manifest. + +## Re-render this one clip, and watch it + +The `preview` preset is one clip and nothing else, and a segment is +content-addressed by its clip id — so the button overwrites exactly the file you +are looking at and touches no deliverable. The job is polled the way the wider +fetch is, and when it finishes the built segment plays in a second player. + +That is the only place the attribution can be *checked* rather than believed: the +header is drawn by ffmpeg from a text file, and no amount of HTML can promise it +wrapped the same way. + +`GET /api/report/segment` serves it — names not paths, byte ranges, the same +root resolution `/raw` uses. A re-render writes the **same path**, so the file's +mtime is in the URL: a request whose `v` matches is immutable for an hour, and one +whose `v` does not is served `no-store` rather than handing back the cut from +before the edit. + +## Walking the cut + +`p` and `n` (and the links either side of the header) move to the previous and +next clip, computed server-side from the timeline's own order. Reviewing a cut is +watching nineteen clips in a row, and going back to the project page between each +one is nineteen round trips to re-find your place. + ## The cue rail Every cue in view, positioned by time. Inside the selection in full contrast, @@ -73,8 +131,16 @@ a sound restarting on every `pointermove` is unusable. ## Saving -`PUT /api/report/window` with `{project, clip, start, end, lock…, token}`. It -never sends a path and it cannot ask for an entry to move. +`PUT /api/report/window` with `{project, clip, start, end, lock…, title, date, +cite, citeUrl, quote, correction, token}`. It never sends a path and it cannot ask +for an entry to move. The whitelist is mirrored from `lib/report/manifest.mjs`, +which is where the values are actually checked. + +The window and the attribution go through **one** function and **one** token, +because they are one edit: somebody watching a clip fixes its edges and its +header in the same sitting, and two writers would be two chances to lose the +other's write. In the component this is why a window save no longer resets the +attribution drafts and an attribution save no longer discards nudged edges. See [report-video.md](report-video.md) for the four rules the writer keeps (2 dp, the CLI's formatting, tmp+rename under a lock, an mtime token). A stale token is a @@ -88,6 +154,11 @@ show` pilled four ferret-rescue clips "ends mid-sentence" while the decisions inbox stayed silent about them, because the inbox had a punctuation gate and the detail did not. The inbox was right. +**A deleted key has to disappear from the form.** Merging a saved entry with +`{...clip, ...entry}` leaves the old `title` on screen after an empty save +deletes it — the write succeeded and the page said otherwise. The entry is +mapped field by field instead. + **The bench's prediction is testable, and is tested.** It says 3.00–6.00 widens to 3.00–9.00; saving 3.00–9.00 makes `resolve-windows` a no-op on that clip. That round trip is the whole argument for the bench. diff --git a/umtool/docs/e2e.md b/umtool/docs/e2e.md @@ -31,7 +31,7 @@ rendered over a deliverable would be indistinguishable from a person doing it. | | | |---|---| | `report-fixture` | read-only. 4 clips: c01 ends mid-sentence, c04 does too but sets `lockEnd`, c03's source has no punctuation | -| `bench-fixture` | the clip bench **writes** | +| `bench-fixture` | the clip bench **writes** — windows, locks, attribution and corrections | | `build-fixture` | the build **writes** | | `gone-fixture` | its source is gone — the preflight must block it | | `no-origin-fixture` / `localhost-fixture` | the two defects that shipped | diff --git a/umtool/docs/report-video.md b/umtool/docs/report-video.md @@ -42,12 +42,55 @@ travel. umtool never reorders as a side effect of a window edit. "cite": 32989, // the second shown in the attribution line "citeUrl": "https://…", // optional: overrides the derived QR target "quote": "…", // the words this clip exists for + "title": "Monday Mail #11", // optional: overrides the record's title in the header + "date": "2016-09-12", // optional: overrides the record's upload date "note": "…", // why it is in the cut (editorial, for humans) + "correction": "…", // what the REPORT got wrong here; never rendered "chapter": "…", // chapter title; falls back to date + title "section": 2, "sectionEnter": true, "lock": true, "lockStart": true, "lockEnd": true } ``` +### The attribution: `title`, `date`, `cite`, `citeUrl`, `quote` + +The header line is +`${title ?? cleanTitle(record.title)} · ${date ?? record.uploadDate} @ ${hms(cite ?? start)}`, +built by `report-to-video/attribution.mjs` — one module, imported by the +renderer, by the clip bench's live preview and by the writer's validation, so a +preview cannot promise a line the renderer would not draw. + +**`title` and `date` exist because the archive's answer is often the wrong one.** +A VOD mirror's `uploadDate` is the date the *copy* was posted, routinely years +after the stream; `destinys-child` overrides both on all 19 clips. Both absent +reproduces the old line byte for byte, and `chapterTitle()` honours the same two +so the mp4's chapter list and its burned-in header cannot disagree. + +All five are **writable** — from the clip bench, from `PUT /api/report/window`, +and from `umtool window --title/--date/--cite/--cite-url/--quote` — under the +four rules below. An **empty value deletes the key**, the way the locks do: a +manifest is read by humans and `"title": ""` is noise that reads like a decision. +Two are checked rather than trusted: + +- **`date`** must be `YYYY-MM-DD` *and a day that exists*. The regex alone + accepts `2025-02-31`, which reads as a date right up until somebody tries to + check the clip against the stream it claims to come from. +- **`citeUrl`** must be `http(s)`. A QR encoding anything else is the same class + of defect as the 19 codes that shipped reading `undefined/?v=…`. + +### `correction` — what the REPORT got wrong + +Free text, written while watching the clip, **never rendered and never read by +the renderer**. It records a defect in the *report*, not in the cut: wrong +speaker, wrong addressee, wrong date. `destinys-child`'s `c07` is the case it +was built for — the report attributed a line to Destiny that Dan says, to +Destiny, on a call. + +It is deliberately not a decision in the inbox: the fix belongs to the next +sweep, not to this manifest, and nothing here can close it. `correctionsOf()` is +the one definition; the project page lists it and `umtool corrections <project>` +prints the same list as markdown with the QR's own moment link per bullet, ready +to paste into the next prompt. + A card entry is `{"type":"card", "id", "style", "seconds", …}` — see the pipeline README for the styles. Cards have no window and no source. diff --git a/umtool/lib/projects/report.mjs b/umtool/lib/projects/report.mjs @@ -106,6 +106,28 @@ export const derivedCiteUrl = (m, e) => `${m?.provenance?.siteOrigin ?? ""}/?v=${encodeURIComponent(`${channelFor(m, e)}/${e.video}`)}&t=${Math.floor(e.start ?? 0)}`; export const citeUrlFor = (m, e) => e.citeUrl ?? derivedCiteUrl(m, e); +/** + * The clips whose `correction` says the REPORT got something wrong. + * + * Not a render input and not a decision: a correction is a message to the next + * report pass -- wrong speaker, wrong addressee, wrong date -- written while + * somebody was watching the clip and could see it. The page and `umtool + * corrections` read it from here so a list that is copied into a prompt and one + * that is read on screen cannot drift apart. + */ +export function correctionsOf(m) { + return clipsOf(m) + .filter((e) => String(e.correction ?? "").trim()) + .map((e) => ({ + id: e.id, + channel: channelFor(m, e), + video: e.video, + at: e.cite ?? e.start ?? 0, + href: citeUrlFor(m, e), + text: String(e.correction).trim(), + })); +} + /** The recorded preflight, and when it ran. Never a live probe. */ export async function readAvailability(dir) { const file = path.join(dir, "out", "availability.json"); diff --git a/umtool/lib/report/manifest.mjs b/umtool/lib/report/manifest.mjs @@ -178,7 +178,12 @@ export async function updateClip(dir, clipId, patch, { token = null } = {}) { } // ---- what the header will say ------------------------------------------- - for (const k of ["title", "quote"]) { + // + // `correction` is the odd one out and belongs here anyway: it is written in + // the same sitting, by the same person, looking at the same clip. It says + // what the REPORT got wrong -- wrong speaker, wrong addressee, wrong date -- + // and nothing renders it. It is a message to the next report pass. + for (const k of ["title", "quote", "correction"]) { if (patch[k] === undefined) continue; const v = String(patch[k] ?? "").trim(); if (v) entry[k] = v;