Archilyzer · Source

archilyzer

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

commit ad8d17d231c61e80c74aaaaedb7308655b8534d3
parent 5f571b84df3de9e6651a31b261017de78a9ebc23
Author: I Mean I'm Just Saying <imeanimjustsaying@kiwifarms.st>
Date:   Sat, 19 Sep 2026 03:16:55 -0400

extent vs cut: the window is what was reviewed, the cut is what plays

The operator's bench edits widen a clip to its USEFUL EXTENT -- "oh I see how
useful this clip could be" -- which is a judgement about the recording, not
about what one paragraph of one report needs. So the manifest now carries both:
`start`/`end` is that extent, and `cutStart`/`cutEnd` is the tight cut derived
from where the `quote` actually is inside it. Reviewing once and re-cutting
later, for a different report, stops requiring anybody to watch anything again.

One rule, enforced in one place: both fields or neither, at least half a
second, and inside the extent. updateClip refuses the rest, `umtool check`
reports `clip-cut-outside` as BLOCKING -- a cut outside its extent renders
seconds nobody reviewed, and half a pair renders the whole extent while the
manifest reads as though it were trimmed.

build-video cuts to [cutStart − render.leadIn, cutEnd] when both are there
(0.4 s, clamped into the extent, because starting exactly on the first syllable
sounds like a dropped frame) and to the extent when they are not, so every
manifest written before this behaves as it did. FETCHING stays keyed on the
extent: a cut is always inside it, and a re-cut must never need another
download. `cite` is kept rather than moved when it falls outside the cut, with
a note -- a citation silently re-pointed is worse than one flagged.

In the bench: the cut reads out under the window with its match score, draws
as an inner pair of markers on the rail, and "cut to quote" runs the SAME
matcher through a per-clip route rather than the CLI's whole-manifest pass --
which would write cuts for nineteen clips nobody has looked at. Dragging the
extent inward past the cut clears it in the same save and says so. `lockCut`
joins the other locks; `umtool window --cut-start/--cut-end/--lock-cut` and
`umtool show` speak both numbers.

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

Diffstat:
Mumtool/app/api/report/clip/route.ts | 3+++
Aumtool/app/api/report/cut/route.ts | 85+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mumtool/app/api/report/window/route.ts | 4++++
Mumtool/bin/umtool.mjs | 29+++++++++++++++++++++++++++--
Mumtool/components/projects/ClipBench.tsx | 149++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-
Mumtool/components/projects/ClipBenchPage.tsx | 3+++
Mumtool/docs/clip-bench.md | 18++++++++++++++++++
Mumtool/docs/report-video.md | 43++++++++++++++++++++++++++++++++++++++++++-
Mumtool/lib/projects/report.mjs | 38++++++++++++++++++++++++++++++++++++++
Mumtool/lib/report/manifest.mjs | 48+++++++++++++++++++++++++++++++++++++++++++++++-
Mumtool/report-to-video/build-video.mjs | 40++++++++++++++++++++++++++++++++++++++--
11 files changed, 453 insertions(+), 7 deletions(-)

diff --git a/umtool/app/api/report/clip/route.ts b/umtool/app/api/report/clip/route.ts @@ -56,6 +56,9 @@ export async function GET(request: Request) { lock: !!clip.lock, lockStart: !!clip.lockStart, lockEnd: !!clip.lockEnd, + cutStart: clip.cutStart ?? null, + cutEnd: clip.cutEnd ?? null, + lockCut: !!clip.lockCut, }, view, windows: windows.map((w: { name: string; from: number; to: number }) => ({ diff --git a/umtool/app/api/report/cut/route.ts b/umtool/app/api/report/cut/route.ts @@ -0,0 +1,85 @@ +import { StaleToken, updateClip } from "@/lib/report/manifest.mjs"; +import { cuesInWindow } from "@/lib/projects/report.mjs"; +import { resolveClip } from "@/lib/report/serve.mjs"; +import { cutToQuote } from "umtool-report-to-video/resolve-windows"; + +export const dynamic = "force-dynamic"; + +// "Cut to quote", for ONE clip. +// +// The CLI pass (`resolve-windows --cut-to-quote --write`) does the whole +// manifest, which is the right shape for a first pass over a fresh cut and the +// wrong one for somebody sitting with a single clip: it would write cuts for +// nineteen other clips they have not looked at. So the matcher is imported -- +// the same function, never a second implementation -- and run here over this +// clip's cues, and the result goes through the one writer with the same token, +// so a stale tab cannot overwrite a judgement. +// +// The cues are read a little wider than the extent because the matcher snaps +// outward to sentence edges before clamping back inside it. +const LOOK = 30; + +export async function POST(request: Request) { + const body = (await request.json().catch(() => ({}))) as Record<string, unknown>; + const projectId = String(body.project ?? ""); + const clipId = String(body.clip ?? ""); + + const r = await resolveClip(projectId, clipId); + if ("error" in r) return Response.json({ error: r.error }, { status: r.status }); + const { project, clip } = r; + + if (!String(clip.quote ?? "").trim()) { + return Response.json( + { error: "this clip has no quote to cut to — write one first", ok: false }, + { status: 400 }, + ); + } + + const doc = await cuesInWindow(project.dir, clipId, clip.start - LOOK, clip.end + LOOK); + if (!doc?.cues?.length) { + return Response.json( + { error: "no cue file for this source, so there is nothing to match against", ok: false }, + { status: 400 }, + ); + } + + const minMatch = Number.isFinite(Number(body.minMatch)) ? Number(body.minMatch) : 0.6; + const hit = cutToQuote(doc.cues, clip.quote, { start: clip.start, end: clip.end }, { minMatch }) as + | { ok: true; cutStart: number; cutEnd: number; score: number; matched: string } + | { ok: false; score: number; why: string; matched: string }; + + if (!hit.ok) { + // NOT an error to recover from: a quote the transcript does not contain is + // a fact about the report, and guessing a cut would hide it. + return Response.json( + { + ok: false, + score: hit.score, + matched: hit.matched, + error: `no cut written — ${hit.why} (match ${hit.score.toFixed(2)})`, + }, + { status: 200, headers: { "cache-control": "no-store" } }, + ); + } + + try { + const res = await 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 }, + { headers: { "cache-control": "no-store" } }, + ); + } catch (e) { + if (e instanceof StaleToken) { + return Response.json( + { error: e.message, expected: e.expected, got: e.got, stale: true }, + { status: 409 }, + ); + } + 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 @@ -43,6 +43,10 @@ export async function PUT(request: Request) { "citeUrl", "quote", "correction", + // The cut inside the extent, and the pin that stops the resolver moving it. + "cutStart", + "cutEnd", + "lockCut", // Whether the walk has looked at this clip: "confirmed", or empty to clear // it. A non-empty `correction` is the other answer and needs no value. "verdict", diff --git a/umtool/bin/umtool.mjs b/umtool/bin/umtool.mjs @@ -20,6 +20,7 @@ // 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] [--verdict confirmed|incorrect|''] +// [--cut-start S] [--cut-end E] [--lock-cut] the cut inside the extent // 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] @@ -216,10 +217,18 @@ async function cmdShow() { e.endsSentence === false && !e.lockEnd && !e.lock ? "ends mid-sentence" : "", e.noPunctuation ? "source unpunctuated" : "", e.proposed ? `widen -> ${e.proposed.start}–${e.proposed.end}` : "", + e.lockCut ? "cut pinned" : "", ].filter(Boolean); + // EXTENT then CUT. The extent is what was reviewed; the cut is what + // plays, and a line that showed only one of them would be the same + // confusion the two fields exist to end. + const cut = + e.cutStart != null && e.cutEnd != null + ? ` cut ${Number(e.cutStart).toFixed(2)}–${Number(e.cutEnd).toFixed(2)} (${(e.cutEnd - e.cutStart).toFixed(1)}s)` + : ""; console.log( ` ${e.id.padEnd(5)} ${String(e.video).padEnd(14)} ` + - `${e.start.toFixed(2)}–${e.end.toFixed(2)} (${(e.end - e.start).toFixed(1)}s)` + + `${e.start.toFixed(2)}–${e.end.toFixed(2)} (${(e.end - e.start).toFixed(1)}s)${cut}` + `${marks.length ? ` [${marks.join(" · ")}]` : ""}`, ); } @@ -445,6 +454,7 @@ function usage() { " attribution too: --title, --date YYYY-MM-DD, --cite, --cite-url, --quote", " --correction TEXT what the REPORT got wrong here (never rendered)", " --verdict confirmed|incorrect what the walk said; incorrect needs --correction", + " --cut-start S --cut-end E --lock-cut the cut the video renders", " an empty value (--title '') deletes the field", " umtool corrections <project> what the report got wrong + the walk's coverage", " umtool build <project> [--preset preview|fast|final] [--only ID]", @@ -505,12 +515,19 @@ async function cmdWindow() { }; if (num("--start") !== undefined) patch.start = num("--start"); if (num("--end") !== undefined) patch.end = num("--end"); + // The cut the video renders, inside the reviewed extent. An empty value + // clears it, like every other field here. + for (const [flag, key] of [["--cut-start", "cutStart"], ["--cut-end", "cutEnd"]]) { + if (val(flag) !== undefined) patch[key] = val(flag) === "" ? "" : Number(val(flag)); + } // A flag and its negation, because `false` REMOVES the key -- the manifests // are read by humans and `"lockEnd": false` reads like a decision. for (const [flag, key] of [ ["--lock", "lock"], ["--lock-start", "lockStart"], ["--lock-end", "lockEnd"], + // The cut, pinned against the resolver. `lock` is about the EXTENT. + ["--lock-cut", "lockCut"], ]) { if (has(flag)) patch[key] = true; if (has(`--no-${flag.slice(2)}`)) patch[key] = false; @@ -543,7 +560,15 @@ async function cmdWindow() { console.log( `${clipId}: ${res.before.start}–${res.before.end} -> ${res.entry.start}–${res.entry.end}`, ); - const marks = ["lock", "lockStart", "lockEnd"].filter((k) => res.entry[k]); + if (res.entry.cutStart != null) { + console.log( + ` cut: ${res.entry.cutStart}–${res.entry.cutEnd} ` + + `(${(res.entry.cutEnd - res.entry.cutStart).toFixed(2)}s inside the extent)`, + ); + } else if (patch.cutStart !== undefined || patch.cutEnd !== undefined) { + console.log(" cut cleared — the build will use the whole extent"); + } + const marks = ["lock", "lockStart", "lockEnd", "lockCut"].filter((k) => res.entry[k]); if (marks.length) console.log(` ${marks.join(", ")}`); // The line the renderer will burn in, printed so an attribution edit is // checkable without building anything. diff --git a/umtool/components/projects/ClipBench.tsx b/umtool/components/projects/ClipBench.tsx @@ -78,6 +78,18 @@ type Clip = { lock: boolean; lockStart: boolean; lockEnd: boolean; + /** + * The CUT: what the video actually plays, inside the reviewed extent. + * + * `start`/`end` is how much of this recording is worth having -- a judgement + * about the material. The cut is a different question: the sentences the + * quote is made of. Absent means "the whole extent plays", which is what + * every manifest did before the two were separated. + */ + cutStart: number | null; + cutEnd: number | null; + /** The cut is deliberate; `resolve-windows --cut-to-quote` leaves it alone. */ + lockCut: boolean; /** "confirmed" / "incorrect", or null for "nobody has looked at this yet". */ verdict: "confirmed" | "incorrect" | null; }; @@ -148,6 +160,9 @@ const fromEntry = (prev: Clip, e: Record<string, unknown>): Clip => ({ lock: !!e.lock, lockStart: !!e.lockStart, lockEnd: !!e.lockEnd, + cutStart: e.cutStart == null ? null : Number(e.cutStart), + cutEnd: e.cutEnd == null ? null : Number(e.cutEnd), + lockCut: !!e.lockCut, verdict: e.verdict === "confirmed" || e.verdict === "incorrect" ? e.verdict : null, }); @@ -264,6 +279,9 @@ export default function ClipBench({ data }: { data: ClipBenchData }) { // The cues AROUND the cached window: what is coming, read before paying for // the media. One request, widened only when somebody asks. const [peek, setPeek] = useState<Cue[]>([]); + // The score the resolver last reported for this clip. Not stored in the + // manifest: it is a fact about a matching run, not about the cut. + const [cutScore, setCutScore] = useState<number | null>(null); const [peekPad, setPeekPad] = useState(PEEK_STEP); const [playhead, setPlayhead] = useState<number | null>(null); const [note, setNote] = useState<string | null>(null); @@ -827,6 +845,65 @@ export default function ClipBench({ data }: { data: ClipBenchData }) { setNote("the render is still going; reload to pick it up"); }, [data.project, clip.id, refresh]); + // ---- cutting to the quote ------------------------------------------------- + // + // The same matcher the CLI pass runs, over this clip only. A quote the + // transcript does not contain comes back as a REFUSAL with its best partial + // rather than a guessed cut: that mismatch is a fact about the report, and a + // cut invented to hide it would be the worst of both. + const cutToQuote = useCallback(async () => { + setBusy("matching the quote…"); + setNote(null); + const r = await fetch("/api/report/cut", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ project: data.project, clip: clip.id, token: token.current }), + }); + const j = (await r.json()) as { + ok?: boolean; + entry?: Record<string, unknown>; + token?: string; + score?: number; + matched?: string; + error?: string; + stale?: boolean; + }; + setBusy(null); + if (!r.ok || j.ok === false) { + if (j.score != null) setCutScore(j.score); + setNote( + j.stale + ? "the manifest changed since you opened this — reload before saving" + : (j.error ?? "could not match the quote"), + ); + return; + } + setClip((prev) => fromEntry(prev, j.entry ?? {})); + token.current = String(j.token ?? ""); + setCutScore(j.score ?? null); + setNote(`cut to the quote (match ${(j.score ?? 0).toFixed(2)})`); + }, [data.project, clip.id]); + + /** Save the window, and drop a cut the new extent no longer contains. */ + const saveWindow = useCallback(() => { + const start = round2(sel.from); + const end = round2(sel.to); + const cutOutside = + clip.cutStart != null && + clip.cutEnd != null && + (clip.cutStart < start - 0.02 || clip.cutEnd > end + 0.02); + if (cutOutside) { + // The extent is the judgement being made right now; the cut was derived + // from a wider one and is no longer inside it. Clearing it in the SAME + // patch is what keeps the writer's rule and the screen agreeing. + void save({ start, end, cutStart: "", cutEnd: "" }).then((ok) => { + if (ok) setNote("saved — the cut no longer fitted this window and was cleared"); + }); + return; + } + void save({ start, end }); + }, [sel.from, sel.to, clip.cutStart, clip.cutEnd, save]); + // ---- the warnings -------------------------------------------------------- const endCue = cues.find((c) => sel.to >= c.start - 0.02 && sel.to <= c.end + 0.02) ?? null; const endsSentence = data.noPunctuation ? null : endCue ? endCue.endsSentence : null; @@ -1033,7 +1110,7 @@ export default function ClipBench({ data }: { data: ClipBenchData }) { type="button" className={buttonVariants({ variant: "primary", size: "sm" })} disabled={!dirty || !!busy} - onClick={() => void save({ start: round2(sel.from), end: round2(sel.to) })} + onClick={saveWindow} > save window </button> @@ -1102,6 +1179,52 @@ export default function ClipBench({ data }: { data: ClipBenchData }) { auto-audition {playback.auto ? "on" : "off"} </button> </span> + {/* ---- EXTENT above, CUT here ---- + The window row says how much of the recording is worth having. + This says what will actually play, and offers to derive it + from the quote -- which is the whole reason the two are + different numbers. */} + <span data-cut={clip.cutStart != null ? `${clip.cutStart}-${clip.cutEnd}` : ""} className="flex flex-wrap items-center gap-1.5"> + {clip.cutStart != null && clip.cutEnd != null ? ( + <span className="num text-[var(--color-good)]"> + cut {hms(clip.cutStart)}–{hms(clip.cutEnd)}{" "} + <span className="text-[var(--color-dim)]"> + ({(clip.cutEnd - clip.cutStart).toFixed(2)}s + {cutScore != null ? `, quote match ${cutScore.toFixed(2)}` : ""} + {clip.lockCut ? ", pinned" : ""}) + </span> + </span> + ) : ( + <span className="text-[var(--color-dim)]"> + no cut — the whole window plays + </span> + )} + <button + type="button" + data-cut-to-quote="" + className={buttonVariants({ size: "sm" })} + disabled={!!busy || !draft.quote.trim()} + title={ + draft.quote.trim() + ? "find the quote in the transcript and cut to it, inside this window" + : "there is no quote to match against" + } + onClick={() => void cutToQuote()} + > + cut to quote + </button> + {clip.cutStart != null && ( + <button + type="button" + data-cut-clear="" + className={buttonVariants({ size: "sm" })} + disabled={!!busy} + onClick={() => void save({ cutStart: "", cutEnd: "", lockCut: false })} + > + clear cut + </button> + )} + </span> <span className="micro"> <kbd>[</kbd> <kbd>]</kbd> start · <kbd>,</kbd> <kbd>.</kbd> end — each plays the edge it moved · <kbd>space</kbd> the whole selection · <kbd>R</kbd> reset · <kbd>-</kbd>{" "} @@ -1233,6 +1356,29 @@ export default function ClipBench({ data }: { data: ClipBenchData }) { </div> ); })} + {/* The CUT, as an inner pair of markers: what plays, inside what + was reviewed. */} + {clip.cutStart != null && clip.cutEnd != null && ( + <> + <div + data-cut-span="" + className="absolute top-0 h-full bg-[color-mix(in_srgb,var(--color-good)_10%,transparent)]" + style={{ + left: rpct(clip.cutStart), + width: `calc(${rpct(clip.cutEnd)} - ${rpct(clip.cutStart)})`, + }} + /> + {[clip.cutStart, clip.cutEnd].map((t) => ( + <div + key={`cut-${t}`} + data-cut-edge={t} + title={`the cut ${hms(clip.cutStart!)}–${hms(clip.cutEnd!)}`} + className="absolute top-0 h-full border-l-2 border-[var(--color-good)]" + style={{ left: rpct(t) }} + /> + ))} + </> + )} {railCues.map((c) => { const inSel = c.end > sel.from && c.start < sel.to; const owner = siblingAt(c); @@ -1419,6 +1565,7 @@ export default function ClipBench({ data }: { data: ClipBenchData }) { ["lock", "leave this clip alone entirely — the window is deliberate"], ["lockStart", "pin the start exactly where it is"], ["lockEnd", "pin the end exactly where it is"], + ["lockCut", "keep the cut as it is — the resolver may not re-derive it"], ] as const ).map(([k, why]) => ( <label key={k} className="flex items-start gap-2"> diff --git a/umtool/components/projects/ClipBenchPage.tsx b/umtool/components/projects/ClipBenchPage.tsx @@ -67,6 +67,9 @@ export default async function ClipBenchPage({ lock: !!entry.lock, lockStart: !!entry.lockStart, lockEnd: !!entry.lockEnd, + cutStart: entry.cutStart ?? null, + cutEnd: entry.cutEnd ?? null, + lockCut: !!entry.lockCut, verdict: entry.verdict === "confirmed" || entry.verdict === "incorrect" ? entry.verdict : null, }, diff --git a/umtool/docs/clip-bench.md b/umtool/docs/clip-bench.md @@ -199,6 +199,24 @@ reviewed. See [report-video.md](report-video.md#verdict--what-the-walk-said) for what is stored and what the writer refuses. +## The extent you reviewed, and the cut that plays + +The window you drag is the clip's **extent** — how much of this recording is +worth having. What the video plays is the **cut**, derived from where the quote +actually is inside it (see +[extent vs cut](report-video.md#extent-vs-cut--startend-and-cutstartcutend)). + +Under the window readout the bench says which is which: *"cut 0:14.20–0:19.80 +(5.60s, quote match 0.87)"*, or *"no cut — the whole window plays"*. **Cut to +quote** runs the same matcher the CLI pass runs, over this clip only, and +writes through the one writer with the same token; a quote the transcript does +not contain comes back as a refusal carrying its best partial rather than a +guessed cut. The rail draws the cut as an inner pair of markers inside the +window, so the two are visible at once. **Clear cut** goes back to playing the +whole extent, and dragging the extent inward past the cut clears it in the same +save and says so — the extent is the judgement being made right now, and the +cut was derived from a wider one. + ## What else the cut takes from this recording "Should this clip be wider, or is what it is missing already in the cut?" is diff --git a/umtool/docs/report-video.md b/umtool/docs/report-video.md @@ -39,7 +39,9 @@ travel. umtool never reorders as a side effect of a window edit. { "type": "clip", "id": "c04", "video": "uyz1_FIqIEk", "channel": "hasanabi-vods3", // optional: which archived channel's cues "channelTitle": "HasanAbi", // optional: what to CALL that channel on screen - "start": 32980.24, "end": 32994.19, // absolute source seconds, 2 dp + "start": 32980.24, "end": 32994.19, // the reviewed EXTENT, absolute source seconds, 2 dp + "cutStart": 32986.1, "cutEnd": 32992.4, // the CUT that plays, inside the extent + "lockCut": true, // the cut is deliberate; the resolver leaves it "cite": 32989, // the second shown in the attribution line "citeUrl": "https://…", // optional: overrides the derived QR target "quote": "…", // the words this clip exists for @@ -121,6 +123,45 @@ 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. +### Extent vs cut — `start`/`end` and `cutStart`/`cutEnd` + +**They answer different questions, and conflating them is why a reviewed clip +used to have to be re-reviewed.** `start`/`end` is the **extent**: how much of +this recording is worth having, decided once by somebody watching it — *"oh, I +see how useful this clip could be"* — and not by any one paragraph's needs. +`cutStart`/`cutEnd` is the **cut**: the sentences the quote is actually made +of, which is what the video plays. + +The cut is **derived**, by `resolve-windows --cut-to-quote`, so the same +reviewed extent can be re-cut for a different report without watching anything +again. It must lie **inside** the extent; both fields or neither; at least half +a second. The writer refuses anything else and `umtool check` reports it as +`clip-cut-outside`, **blocking** — a cut outside its extent renders seconds +nobody reviewed, and half a pair renders the whole extent while the manifest +reads as though it were trimmed. + +- **The build** cuts to `[cutStart − render.leadIn, cutEnd]` (lead-in 0.4 s by + default, clamped into the extent) when both are present, and to the extent + when they are not — so a manifest written before any of this behaves exactly + as it did. +- **Fetching and the cache stay keyed on the EXTENT.** A cut is always inside + it, so re-cutting never needs another download. +- **`cite` is kept, never moved.** If it falls outside the cut the build says + so and leaves it: a citation silently re-pointed is worse than one flagged. +- **`lock` is about the extent; `lockCut` is about the cut.** The resolver + honours both, and never overwrites existing cut fields without `--force-cut`. + +The matcher is deliberately loose, because a quote is prose a human wrote about +speech a machine transcribed: case and punctuation are dropped, `[bracketed]` +editorial words and `[Speaker]` markers are dropped, and an **ellipsis splits +the quote into fragments** located independently — `"A … B"` is two places in +the recording and the cut spans from the first to the last. A fragment counts +as located at half its words; the whole quote must reach `--min-match` (0.6) or +the clip is reported **UNMATCHED** with its best partial and nothing is +written for it. That report is the useful output: a quote the transcript does +not contain is a fact about the *report*, and a cut invented to hide it would +be the worst of both. + ### `verdict` — what the walk said `"confirmed"`, `"incorrect"`, or **absent**. Never rendered, like `correction`, diff --git a/umtool/lib/projects/report.mjs b/umtool/lib/projects/report.mjs @@ -633,6 +633,11 @@ export const REPORT_DECISION_KINDS = [ // fix (a recovered transcript, or a shorter window) is editorial. The // reducer reads cue files already; it does not measure media. "clip-cue-gap", + // A `cutStart`/`cutEnd` that is not inside its clip's own window. BLOCKING: + // the build would cut somewhere nobody reviewed, or -- with the pair half + // written -- silently ignore it and render the whole extent instead. Both + // are the render disagreeing with the manifest about what the clip IS. + "clip-cut-outside", // A ledger entry nobody has ruled on. BLOCKING, which is earned here: both // the stated and the implied total lie if you act on an unadjudicated ledger, // and they lie quietly, in a chart, with his name on it. @@ -824,6 +829,39 @@ export async function reportDecisions(ctx, summary) { } } + // THE CUT MUST BE INSIDE THE EXTENT. `start`/`end` is the reviewed extent and + // `cutStart`/`cutEnd` is what actually plays; a cut outside it renders + // seconds nobody looked at, and half a pair renders the whole extent while + // the manifest reads as though it were trimmed. + for (const e of clips) { + const hasStart = Number.isFinite(Number(e.cutStart)); + const hasEnd = Number.isFinite(Number(e.cutEnd)); + if (!hasStart && !hasEnd) continue; + if (hasStart !== hasEnd) { + add( + "clip-cut-outside", + e.id, + `has ${hasStart ? "cutStart" : "cutEnd"} and not the other — the build honours the pair or neither`, + "blocking", + { href: clipHref(e.id) }, + ); + continue; + } + if (Number(e.cutEnd) - Number(e.cutStart) < 0.5) { + add("clip-cut-outside", e.id, `cut ${e.cutStart}–${e.cutEnd} is under half a second`, "blocking", { href: clipHref(e.id) }); + continue; + } + if (Number(e.cutStart) < Number(e.start) - 0.02 || Number(e.cutEnd) > Number(e.end) + 0.02) { + add( + "clip-cut-outside", + e.id, + `cut ${e.cutStart}–${e.cutEnd} lies outside its window ${e.start}–${e.end} — the build would cut seconds nobody reviewed`, + "blocking", + { href: clipHref(e.id) }, + ); + } + } + // A clip with no numeric window cannot be built, widened or benched. The // scaffold's `--seed chapters` writes one when the last chapter has no // duration to end at, and says so; this is where it stays visible. diff --git a/umtool/lib/report/manifest.mjs b/umtool/lib/report/manifest.mjs @@ -112,7 +112,10 @@ export class StaleToken extends Error { } const WINDOW_FIELDS = ["start", "end"]; -const FLAG_FIELDS = ["lock", "lockStart", "lockEnd"]; +const FLAG_FIELDS = ["lock", "lockStart", "lockEnd", "lockCut"]; + +/** Same tolerance the resolver and the build use for a stored 2 dp edge. */ +const WIN_EPS = 0.02; /** * Patch ONE clip. The window, the locks, and what the header says. @@ -234,6 +237,49 @@ export async function updateClip(dir, clipId, patch, { token = null } = {}) { } } + // ---- the CUT, inside the extent ------------------------------------------ + // + // `start`/`end` is the reviewed EXTENT -- how much of this recording is + // worth having -- and `cutStart`/`cutEnd` is the tight cut the video + // renders, derived from where the quote actually is. Two numbers, one + // rule: a cut that is not inside its extent is not a cut, it is a second + // window nobody reviewed. + // + // Both or neither. A lone `cutStart` reads like a decision and builds like + // nothing, because the build only honours the pair. + for (const k of ["cutStart", "cutEnd"]) { + if (patch[k] === undefined) continue; + const raw = patch[k]; + if (raw === null || raw === "") { + delete entry[k]; + continue; + } + const v = Number(raw); + if (!Number.isFinite(v) || v < 0) throw new Error(`${k} must be a number ≥ 0, or empty`); + entry[k] = round2(v); + } + if (patch.cutStart !== undefined || patch.cutEnd !== undefined || patch.start !== undefined || patch.end !== undefined) { + const hasStart = entry.cutStart != null; + const hasEnd = entry.cutEnd != null; + if (hasStart !== hasEnd) { + throw new Error("a cut needs both cutStart and cutEnd, or neither"); + } + if (hasStart) { + if (entry.cutEnd - entry.cutStart < 0.5) { + throw new Error(`a cut must be at least half a second (${entry.cutStart}–${entry.cutEnd})`); + } + if ( + entry.cutStart < entry.start - WIN_EPS || + entry.cutEnd > entry.end + WIN_EPS + ) { + throw new Error( + `the cut ${entry.cutStart}–${entry.cutEnd} must lie inside the window ` + + `${entry.start}–${entry.end} — widen the window, or clear the cut`, + ); + } + } + } + // ---- the walk's verdict ------------------------------------------------- // // Whether somebody has LOOKED at this clip and said the description is what diff --git a/umtool/report-to-video/build-video.mjs b/umtool/report-to-video/build-video.mjs @@ -519,9 +519,45 @@ async function buildClipSegment(entry, meta, render, dirs, opts, chrome, nodes, const pal = render.palette; const { width, height } = render; + // EXTENT vs CUT. + // + // `start`/`end` is the reviewed extent -- how much of this recording is worth + // having, decided once by somebody watching it. `cutStart`/`cutEnd`, when + // they are there, is the tight cut derived from where the quote actually is + // (resolve-windows --cut-to-quote), and it is what plays. The extent still + // decides what gets FETCHED, because a cut is always inside it and a + // re-cut must never need another download. + // + // The lead-in is a breath before the first word, clamped into the extent: + // starting exactly on the quote's first syllable sounds like a dropped + // frame. + const lead = render.leadIn ?? 0.4; + const hasCut = Number.isFinite(entry.cutStart) && Number.isFinite(entry.cutEnd); + const playFrom = hasCut ? Math.max(entry.start, entry.cutStart - lead) : entry.start; + const playTo = hasCut ? Math.min(entry.end, entry.cutEnd) : entry.end; + if (hasCut) { + EMIT("cut", { + id: entry.id, + extent: [entry.start, entry.end], + cut: [playFrom, playTo], + seconds: Number((playTo - playFrom).toFixed(2)), + }); + // The header prints a second, and it has to be a second you can hear in + // the clip that plays. Kept rather than moved: a cite is a citation, and + // silently re-pointing one is worse than saying it is off. + const at = entry.cite ?? entry.start; + if (at < playFrom - 0.05 || at > playTo + 0.05) { + EMIT("note", { + message: + `${entry.id}: cite ${at} is outside the cut ${playFrom.toFixed(2)}–${playTo.toFixed(2)} ` + + `— kept as written; move it or widen the cut`, + }); + } + } + // Desired cut points, expressed relative to the over-fetched file. - const wantA = entry.start - fetchStart; - const wantB = entry.end - fetchStart; + const wantA = playFrom - fetchStart; + const wantB = playTo - fetchStart; const win = render.snapWindow ?? 1.6; const sil = await detectSilence(raw, render);