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