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:
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;