commit 2f6210a883f53b3c1a3ede75bb91ecfc726379c0
parent 1bedff04c0f1becca0c1d76d2b045cc196667dbb
Author: I Mean I'm Just Saying <imeanimjustsaying@kiwifarms.st>
Date: Sat, 19 Sep 2026 01:40:12 -0400
clip bench: one screen, and the description it is checking
Walking a cut is sixty clips in a row. The bench stacked two 16:9 players and
three paragraphs of prose, so the attribution fields -- the point of the walk --
were below the fold on every one of them, and "did I check that" became a
scroll position.
From lg up the bench is now exactly the space the page has left: a header line
carrying where you are, the neighbours, and the renderer's own line live; a
two-column grid under it; and nothing outside a column that scrolls. The
player takes whatever height is left (object-contain, not an aspect ratio), the
window readout and its buttons are one row under it, and the second player --
looked at once per clip, after a render -- folds into a <details> strip with
the resolve-windows locks. The warnings stay unfolded and one line each,
because a warning behind a summary is a warning nobody reads. Below lg it is a
single scrolling column: a phone cannot hold this and pretending otherwise
would cost the desk case something.
The right column is what the clip IS. A clip is a claim -- the report said
somebody said this, here -- so the description (its quote, and why it is in the
cut) heads the column, and under it the one question the walk exists to answer:
"Is this accurate?", yes or a note saying what is wrong. `y` confirms and
ADVANCES, because the next clip is what you want in the yes case; `x` puts the
cursor in the correction box, because the note IS the no answer. The six fields
follow, their explanations moved to tooltips and one fold.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Diffstat:
2 files changed, 506 insertions(+), 326 deletions(-)
diff --git a/umtool/components/projects/ClipBench.tsx b/umtool/components/projects/ClipBench.tsx
@@ -56,8 +56,26 @@ type Clip = {
lock: boolean;
lockStart: boolean;
lockEnd: boolean;
+ /** "confirmed", or null for "nobody has looked at this one yet". */
+ verdict: "confirmed" | null;
};
+/**
+ * Has anybody looked at this clip, and did they agree with the description?
+ *
+ * The same three answers lib/projects/report.mjs's clipVerdict() reads out of
+ * the manifest, from the copy this component holds. A `correction` IS the "no"
+ * answer, so it wins on its own -- the writer keeps the two exclusive.
+ */
+const verdictOf = (c: Clip): "corrected" | "confirmed" | "unreviewed" =>
+ (c.correction ?? "").trim() ? "corrected" : c.verdict === "confirmed" ? "confirmed" : "unreviewed";
+
+const VERDICT_LABEL = {
+ corrected: "corrected",
+ confirmed: "confirmed",
+ unreviewed: "not yet reviewed",
+} as const;
+
/** The five fields that decide the burned-in header and the QR. */
const ATTRIB = [
["title", "title", "What the header calls this stream. Empty uses the archived record's own title, cleaned."],
@@ -98,6 +116,7 @@ const fromEntry = (prev: Clip, e: Record<string, unknown>): Clip => ({
lock: !!e.lock,
lockStart: !!e.lockStart,
lockEnd: !!e.lockEnd,
+ verdict: e.verdict === "confirmed" ? "confirmed" : null,
});
export type ClipBenchData = {
@@ -120,6 +139,15 @@ export type ClipBenchData = {
/** Neighbours in the cut, computed server-side. Null at either end. */
prev: string | null;
next: string | null;
+ /** Where this clip sits in the cut, 1-based, and how long the cut is. */
+ index: number;
+ total: number;
+ /**
+ * How many OTHER clips have been reviewed. The count this clip contributes is
+ * computed here, so pressing `y` moves the progress reading without a reload
+ * -- and without this component having to guess at the rest of the cut.
+ */
+ reviewedOthers: number;
cues: Cue[];
token: string | null;
fetchPad: number;
@@ -164,6 +192,8 @@ export default function ClipBench({ data }: { data: ClipBenchData }) {
// And they must not interleave: same read-modify-write, one clip.
const saving = useRef<Promise<boolean>>(Promise.resolve(true));
const stopAt = useRef<number | null>(null);
+ // `x` answers "no" by putting the cursor in the note, which is the answer.
+ const correctionBox = useRef<HTMLTextAreaElement | null>(null);
// The widest cached file is the one the bench draws from: it is how much room
// there is to drag before anything has to be fetched.
@@ -237,49 +267,6 @@ export default function ClipBench({ data }: { data: ClipBenchData }) {
const atEndEdge =
sel.to >= cachedTo - 0.05 && (data.sourceDuration == null || cachedTo < data.sourceDuration - 0.05);
- // ---- keyboard -----------------------------------------------------------
- useEffect(() => {
- const nudge = (which: "from" | "to", by: number) =>
- onSel(which === "from" ? { ...sel, from: sel.from + by } : { ...sel, to: sel.to + by }, false);
-
- const onKey = (e: KeyboardEvent) => {
- const el = e.target as HTMLElement | null;
- if (el && /^(INPUT|TEXTAREA|SELECT)$/.test(el.tagName)) return;
- const step = e.shiftKey ? 0.5 : 0.05;
- switch (e.key) {
- case "[": nudge("from", -step); break;
- case "]": nudge("from", step); break;
- case ",": nudge("to", -step); break;
- case ".": nudge("to", step); break;
- case " ":
- e.preventDefault();
- play(sel.from, sel.to);
- break;
- case "r":
- case "R":
- setSel({ from: clip.start, to: clip.end });
- setDirty(false);
- break;
- // Walking the cut. Reviewing a whole video is nineteen clips in a row,
- // and going back to the project page between each one is nineteen round
- // trips to re-find where you were.
- case "n":
- case "N":
- if (data.next) router.push(`/browse/${data.project}/clip/${data.next}`);
- break;
- case "p":
- case "P":
- if (data.prev) router.push(`/browse/${data.project}/clip/${data.prev}`);
- break;
- default:
- return;
- }
- e.preventDefault();
- };
- window.addEventListener("keydown", onKey);
- return () => window.removeEventListener("keydown", onKey);
- }, [sel, clip.start, clip.end, onSel, play, router, data.project, data.prev, data.next]);
-
// ---- saving -------------------------------------------------------------
const doSave = useCallback(
async (patch: Record<string, unknown>) => {
@@ -355,6 +342,95 @@ export default function ClipBench({ data }: { data: ClipBenchData }) {
[draft, clip, save],
);
+ // ---- the walk's verdict ---------------------------------------------------
+ //
+ // "Is this clip what the report says it is" is the question the walk exists
+ // to answer, and it has two answers: yes, or a note saying what is wrong.
+ // Confirming ADVANCES, because the next clip is what you want in the yes
+ // case; saying no stays put, because the note has to be typed.
+ const confirmClip = useCallback(async () => {
+ if ((clip.correction ?? "").trim()) {
+ // A clip cannot be both. Refused here as well as in the writer, so the
+ // answer arrives before the round trip rather than as a 400.
+ setNote("this clip has a correction; clear it first if it is actually fine");
+ return;
+ }
+ const ok = await save({ verdict: "confirmed" });
+ if (ok && data.next) router.push(`/browse/${data.project}/clip/${data.next}`);
+ }, [clip.correction, save, router, data.project, data.next]);
+
+ const rejectClip = useCallback(() => {
+ const el = correctionBox.current;
+ if (!el) return;
+ el.focus();
+ el.setSelectionRange(el.value.length, el.value.length);
+ }, []);
+
+ // ---- keyboard -----------------------------------------------------------
+ useEffect(() => {
+ const nudge = (which: "from" | "to", by: number) =>
+ onSel(which === "from" ? { ...sel, from: sel.from + by } : { ...sel, to: sel.to + by }, false);
+
+ const onKey = (e: KeyboardEvent) => {
+ const el = e.target as HTMLElement | null;
+ if (el && /^(INPUT|TEXTAREA|SELECT)$/.test(el.tagName)) return;
+ const step = e.shiftKey ? 0.5 : 0.05;
+ switch (e.key) {
+ case "[": nudge("from", -step); break;
+ case "]": nudge("from", step); break;
+ case ",": nudge("to", -step); break;
+ case ".": nudge("to", step); break;
+ case " ":
+ e.preventDefault();
+ play(sel.from, sel.to);
+ break;
+ case "r":
+ case "R":
+ setSel({ from: clip.start, to: clip.end });
+ setDirty(false);
+ break;
+ // Walking the cut. Reviewing a whole video is nineteen clips in a row,
+ // and going back to the project page between each one is nineteen round
+ // trips to re-find where you were.
+ case "n":
+ case "N":
+ if (data.next) router.push(`/browse/${data.project}/clip/${data.next}`);
+ break;
+ case "p":
+ case "P":
+ if (data.prev) router.push(`/browse/${data.project}/clip/${data.prev}`);
+ break;
+ // The two answers to "is this accurate". `y` also advances, so a cut
+ // that is mostly right is walked with one finger.
+ case "y":
+ case "Y":
+ void confirmClip();
+ break;
+ case "x":
+ case "X":
+ rejectClip();
+ break;
+ default:
+ return;
+ }
+ e.preventDefault();
+ };
+ window.addEventListener("keydown", onKey);
+ return () => window.removeEventListener("keydown", onKey);
+ }, [
+ sel,
+ clip.start,
+ clip.end,
+ onSel,
+ play,
+ router,
+ data.project,
+ data.prev,
+ data.next,
+ confirmClip,
+ rejectClip,
+ ]);
+
const refresh = useCallback(async () => {
const r = await fetch(
`/api/report/clip?project=${encodeURIComponent(data.project)}&clip=${encodeURIComponent(clip.id)}`,
@@ -483,10 +559,34 @@ export default function ClipBench({ data }: { data: ClipBenchData }) {
? `/api/report/segment?project=${encodeURIComponent(data.project)}&clip=${encodeURIComponent(clip.id)}&v=${segmentMtime ?? 0}`
: null;
+ const verdict = verdictOf(clip);
+ const reviewed = data.reviewedOthers + (verdict === "unreviewed" ? 0 : 1);
+
return (
- <div className="space-y-3" data-bench={clip.id}>
- {/* ---- walking the cut ---- */}
- <div className="flex items-center gap-3 text-[11px]">
+ // ONE SCREEN, at a desk.
+ //
+ // Walking a cut is sixty clips in a row. A bench that scrolls puts the
+ // attribution fields -- the point of the walk -- below the fold on every
+ // one of them, and the answer to "did I check that" becomes a scroll
+ // position. So from lg up the bench owns exactly the space the page has
+ // left: the grid is the viewport minus the chrome, nothing outside a column
+ // scrolls, and what does not earn its height is folded rather than cut.
+ // Below lg it is a single scrolling column -- a phone cannot hold this and
+ // pretending otherwise would cost the desk case something.
+ <div
+ data-bench={clip.id}
+ data-verdict={verdict}
+ className="flex flex-col gap-2 lg:h-full lg:min-h-0 lg:overflow-hidden"
+ >
+ {/* ---- where you are, where you can go, and what will be burned in ---- */}
+ <div className="flex flex-wrap items-center gap-x-3 gap-y-1 text-[11px]">
+ <span className="num font-mono text-[var(--color-text)]">
+ clip {data.index} of {data.total} ·{" "}
+ <span className="text-[var(--color-sel)]">{clip.id}</span>
+ </span>
+ <span className="micro" data-review-progress="">
+ {reviewed} reviewed
+ </span>
{data.prev ? (
<Link
data-clip-nav="prev"
@@ -496,190 +596,407 @@ export default function ClipBench({ data }: { data: ClipBenchData }) {
← {data.prev}
</Link>
) : (
- <span className="text-[var(--color-dim)]">← first clip</span>
+ <span className="text-[var(--color-dim)]">← first</span>
)}
- <span className="micro">
- reviewing a cut is watching every clip in order — <kbd>p</kbd> and <kbd>n</kbd> move
- </span>
{data.next ? (
<Link
data-clip-nav="next"
href={`/browse/${data.project}/clip/${data.next}`}
- className="ml-auto text-[var(--color-sel)] hover:underline"
+ className="text-[var(--color-sel)] hover:underline"
>
{data.next} →
</Link>
) : (
- <span className="ml-auto text-[var(--color-dim)]">last clip →</span>
+ <span className="text-[var(--color-dim)]">last →</span>
)}
+ <span className="micro">
+ <kbd>p</kbd> <kbd>n</kbd> walk
+ </span>
+ {/* The renderer's own line, live. It used to sit four hundred pixels
+ down the page; it is the one string this bench exists to get right,
+ so it heads the screen. */}
+ <span
+ data-attrib-preview=""
+ title={previewLine}
+ className="num ml-auto min-w-0 max-w-full truncate rounded bg-[var(--color-panel-2)] px-2 py-0.5 text-[12px] text-[var(--color-meter)]"
+ >
+ {previewLine}
+ </span>
</div>
- {/* ---- the picture ---- */}
- <div className="grid gap-3 lg:grid-cols-[minmax(0,1fr)_320px]">
- <div>
+ <div className="grid min-h-0 flex-1 gap-3 lg:grid-cols-[minmax(0,1fr)_minmax(22rem,26rem)]">
+ {/* ================= the picture, and its edges ================= */}
+ <div className="flex min-h-0 flex-col gap-2">
{cached ? (
<video
ref={video}
data-testid="clip-video"
src={`/api/report/raw?project=${encodeURIComponent(data.project)}&clip=${encodeURIComponent(clip.id)}&file=${encodeURIComponent(cached.name)}`}
- className="aspect-video w-full rounded border border-[var(--color-line)] bg-black"
+ // object-contain, because the height is now whatever is left
+ // rather than whatever 16:9 asks for.
+ className="aspect-video w-full rounded border border-[var(--color-line)] bg-black object-contain lg:aspect-auto lg:min-h-0 lg:flex-1"
preload="metadata"
controls
/>
) : (
- <div className="flex aspect-video w-full items-center justify-center rounded border border-dashed border-[var(--color-line)] text-[12px] text-[var(--color-dim)]">
+ <div className="flex aspect-video w-full items-center justify-center rounded border border-dashed border-[var(--color-line)] text-[12px] text-[var(--color-dim)] lg:aspect-auto lg:min-h-0 lg:flex-1">
nothing fetched for this clip yet
</div>
)}
- </div>
- <div className="space-y-2 text-[12px]">
- <div className="num">
- <div className="micro">window (source seconds)</div>
- <div className="text-[var(--color-meter)]">
+ {/* ---- the window, and everything that moves it, in one row ---- */}
+ <div className="flex flex-wrap items-center gap-x-3 gap-y-1.5 text-[12px]">
+ <span className="num text-[var(--color-meter)]">
{hms(sel.from)} – {hms(sel.to)}{" "}
<span className="text-[var(--color-dim)]">({(sel.to - sel.from).toFixed(2)}s)</span>
- </div>
+ </span>
{dirty && (
- <div className="text-[var(--color-dirty)]">
+ <span className="num text-[var(--color-dirty)]">
unsaved — was {hms(clip.start)} – {hms(clip.end)}
- </div>
+ </span>
+ )}
+ <span className="flex flex-wrap items-center gap-1.5">
+ <button
+ type="button"
+ className={buttonVariants({ variant: "primary", size: "sm" })}
+ disabled={!dirty || !!busy}
+ onClick={() => void save({ start: round2(sel.from), end: round2(sel.to) })}
+ >
+ save window
+ </button>
+ <button
+ type="button"
+ className={buttonVariants({ size: "sm" })}
+ onClick={() => play(sel.from, sel.to)}
+ >
+ play selection
+ </button>
+ <button
+ type="button"
+ className={buttonVariants({ size: "sm" })}
+ disabled={!dirty}
+ onClick={() => {
+ setSel({ from: clip.start, to: clip.end });
+ setDirty(false);
+ }}
+ >
+ reset
+ </button>
+ {data.mixHref && (
+ // Built SERVER-SIDE, because a mix link carries an absolute path
+ // and this component has no business constructing one. It also
+ // carries the SAVED window rather than the current selection: a
+ // mix of an unsaved edit is a mix of something not in the cut.
+ <Link
+ href={data.mixHref}
+ data-mix-link={clip.id}
+ className={buttonVariants({ size: "sm" })}
+ >
+ mix
+ </Link>
+ )}
+ </span>
+ <span className="micro">
+ <kbd>[</kbd> <kbd>]</kbd> start · <kbd>,</kbd> <kbd>.</kbd> end · <kbd>space</kbd>{" "}
+ audition · <kbd>R</kbd> reset — shift for 0.5s
+ </span>
+ {busy && <span className="text-[var(--color-meter)]">{busy}</span>}
+ {note && (
+ <span data-bench-note="" className="text-[var(--color-dirty)]">
+ {note}
+ </span>
)}
</div>
- <div className="flex flex-wrap gap-1.5">
+ {/* ---- the instrument ---- */}
+ <Waveform
+ view={view}
+ sel={sel}
+ cand={{ from: clip.start, to: clip.end }}
+ words={cues.map((c) => ({ start: c.start, end: c.end, w: c.text.slice(0, 24) }))}
+ peaks={peaks}
+ playhead={playhead}
+ height={104}
+ onSel={onSel}
+ onReachEdge={() => {
+ /* widening is a FETCH here, not a redraw -- see the button below */
+ }}
+ />
+
+ {(atStartEdge || atEndEdge) && (
+ <div className="flex items-center gap-2 text-[12px]">
+ <span className="text-[var(--color-dirty)]">
+ that is the edge of what is cached{cached ? ` (${cached.name})` : ""}
+ </span>
+ <button
+ type="button"
+ className={buttonVariants({ variant: "primary", size: "sm" })}
+ disabled={!!busy}
+ onClick={() => void fetchMore()}
+ >
+ fetch 20s more
+ </button>
+ </div>
+ )}
+
+ {/* ---- the cue rail ---- */}
+ <div className="relative h-14 w-full shrink-0 overflow-hidden rounded border border-[var(--color-line)] bg-[var(--color-panel-2)]">
+ {cues.map((c) => {
+ const inSel = c.end > sel.from && c.start < sel.to;
+ return (
+ <button
+ type="button"
+ key={`${c.start}-${c.end}`}
+ data-cue={c.start}
+ data-ends-sentence={c.endsSentence ? "1" : "0"}
+ title={c.text}
+ onClick={() => {
+ // Snap the NEARER edge to this cue's nearer boundary.
+ const dStart = Math.abs(sel.from - c.start);
+ const dEnd = Math.abs(sel.to - c.end);
+ onSel(dStart <= dEnd ? { ...sel, from: c.start } : { ...sel, to: c.end }, false);
+ }}
+ className={`absolute top-0 h-full overflow-hidden border-l px-1 text-left text-[10px] leading-tight ${
+ inSel
+ ? "border-[var(--color-sel)] text-[var(--color-text)]"
+ : "border-[var(--color-line)] text-[var(--color-dim)] opacity-60"
+ }`}
+ style={{ left: pct(c.start), width: `calc(${pct(c.end)} - ${pct(c.start)})` }}
+ >
+ {c.endsSentence && (
+ <span className="mr-1 text-[var(--color-meter)]" aria-label="closes a sentence">
+ ¶
+ </span>
+ )}
+ {c.text}
+ </button>
+ );
+ })}
+ </div>
+
+ {/* ---- the warnings ----
+ Still unfolded, and deliberately: a warning behind a summary is a
+ warning nobody reads. They are one line each instead. */}
+ {midSentence && (
+ <p
+ data-warn="mid-sentence"
+ className="shrink-0 rounded border border-[var(--color-dirty)] bg-[color-mix(in_srgb,var(--color-dirty)_10%,transparent)] px-2 py-1 text-[11px] leading-snug text-[var(--color-dirty)]"
+ >
+ Ends mid-sentence — the cut lands inside “…
+ {endCue?.text.trim().slice(-56)}”. Run it to the end of the sentence, or set{" "}
+ <code className="font-mono">lockEnd</code> to say you meant to cut here.
+ </p>
+ )}
+ {data.noPunctuation && (
+ <p
+ data-warn="no-punctuation"
+ className="shrink-0 rounded border border-[var(--color-line)] px-2 py-1 text-[11px] leading-snug text-[var(--color-dim)]"
+ >
+ This upload’s ASR carries no punctuation, so there are no sentence boundaries to
+ find and widening cannot help. Set these edges by ear and lock them.
+ </p>
+ )}
+ {wouldRevert && (
+ <p
+ data-warn="would-revert"
+ className="flex flex-wrap items-center gap-2 rounded border border-[var(--color-dirty)] px-2 py-1 text-[11px] leading-snug text-[var(--color-dirty)]"
+ >
+ <span>
+ <code className="font-mono">resolve-windows --write</code> would make this{" "}
+ {hms(proposed!.start)} – {hms(proposed!.end)}, reverting your edit.
+ </span>
+ <button
+ type="button"
+ className={buttonVariants({ variant: "primary", size: "sm" })}
+ onClick={() =>
+ void save({
+ start: round2(sel.from),
+ end: round2(sel.to),
+ lockStart: Math.abs(proposed!.start - round2(sel.from)) > 0.05,
+ lockEnd: Math.abs(proposed!.end - round2(sel.to)) > 0.05,
+ })
+ }
+ >
+ save and lock
+ </button>
+ </p>
+ )}
+
+ {/* ---- the second 16:9 box, folded ----
+ It was always on screen and it is the thing you look at LAST, once
+ per clip, after a render. Two stacked players is what made this
+ bench taller than a laptop. */}
+ <details
+ data-rendered-strip=""
+ className="shrink-0 rounded border border-[var(--color-line)] px-2 py-1 text-[12px] open:max-h-[55vh] open:overflow-y-auto"
+ >
+ <summary className="cursor-pointer text-[11px] text-[var(--color-dim)]">
+ this clip as rendered{" "}
+ <span className={segSrc ? "text-[var(--color-meter)]" : ""}>
+ {segSrc ? "· built" : "· nothing built yet"}
+ </span>{" "}
+ · what resolve-windows may touch
+ {windows.length > 1 && <span> · {windows.length} cached windows</span>}
+ </summary>
+ <div className="mt-2 grid gap-3 sm:grid-cols-[minmax(0,1fr)_minmax(0,16rem)]">
+ <div className="space-y-2">
+ {segSrc ? (
+ <video
+ data-testid="segment-video"
+ src={segSrc}
+ className="aspect-video w-full rounded border border-[var(--color-line)] bg-black"
+ preload="metadata"
+ controls
+ />
+ ) : (
+ <div
+ data-testid="no-segment"
+ className="flex aspect-video w-full items-center justify-center rounded border border-dashed border-[var(--color-line)] px-2 text-center text-[11px] text-[var(--color-dim)]"
+ >
+ nothing rendered for this clip yet
+ </div>
+ )}
+ <button
+ type="button"
+ data-rerender=""
+ className={buttonVariants({ variant: "primary", size: "sm" })}
+ disabled={!!busy}
+ onClick={() => void rerender()}
+ >
+ re-render this clip
+ </button>
+ <p className="text-[11px] leading-snug text-[var(--color-dim)]">
+ One clip, no crossfade, no chapters — it overwrites this clip’s segment and
+ nothing else. The header is drawn by ffmpeg from a text file, so this is the only
+ place the line above can be checked rather than believed.
+ </p>
+ </div>
+
+ {/* ---- lock, explained rather than labelled ---- */}
+ <div className="space-y-1">
+ <div className="micro">what resolve-windows may touch</div>
+ {(
+ [
+ ["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"],
+ ] as const
+ ).map(([k, why]) => (
+ <label key={k} className="flex items-start gap-2">
+ <input
+ type="checkbox"
+ name={k}
+ checked={!!clip[k]}
+ disabled={!!busy}
+ onChange={(e) => void save({ [k]: e.target.checked } as Partial<Clip>)}
+ />
+ <span>
+ <span className="font-mono text-[11px] text-[var(--color-text)]">{k}</span>
+ <span className="block text-[11px] text-[var(--color-dim)]">{why}</span>
+ </span>
+ </label>
+ ))}
+ </div>
+ </div>
+ </details>
+ </div>
+
+ {/* ============ what this clip IS, and what the header says ============ */}
+ <div className="flex min-h-0 flex-col gap-2 overflow-y-auto text-[12px]">
+ {/* ---- the description, and the one question ----
+ A clip is a claim: the report said somebody said this, here. The
+ walk is somebody checking that claim against the audio, and the
+ only two answers are "yes" and a note saying what is wrong. */}
+ <section
+ data-clip-description=""
+ className="space-y-1 rounded border border-[var(--color-line)] bg-[var(--color-panel)] px-2 py-1.5"
+ >
+ <div className="micro">this clip is supposed to be</div>
+ <p data-clip-quote="" className="leading-snug text-[var(--color-text)]">
+ {draft.quote.trim() ? `“${draft.quote.trim()}”` : "— no quote on this entry —"}
+ </p>
+ <div className="micro">why it is in the cut</div>
+ <p data-clip-note="" className="text-[11px] leading-snug text-[var(--color-dim)]">
+ {clip.note?.trim() || "no note"}
+ </p>
+ </section>
+
+ <div className="flex flex-wrap items-center gap-2">
+ <span className="text-[var(--color-text)]">Is this accurate?</span>
<button
type="button"
+ data-verdict-yes=""
className={buttonVariants({ variant: "primary", size: "sm" })}
- disabled={!dirty || !!busy}
- onClick={() => void save({ start: round2(sel.from), end: round2(sel.to) })}
+ disabled={!!busy}
+ onClick={() => void confirmClip()}
>
- save window
+ Yes, confirmed <kbd>y</kbd>
</button>
<button
type="button"
+ data-verdict-no=""
className={buttonVariants({ size: "sm" })}
- onClick={() => play(sel.from, sel.to)}
+ onClick={rejectClip}
>
- play selection
+ No, note what is wrong <kbd>x</kbd>
</button>
- <button
- type="button"
- className={buttonVariants({ size: "sm" })}
- disabled={!dirty}
- onClick={() => {
- setSel({ from: clip.start, to: clip.end });
- setDirty(false);
- }}
+ <span
+ data-verdict-state=""
+ className={
+ verdict === "confirmed"
+ ? "text-[var(--color-good)]"
+ : verdict === "corrected"
+ ? "text-[var(--color-dirty)]"
+ : "text-[var(--color-dim)]"
+ }
>
- reset
- </button>
- {data.mixHref && (
- // Built SERVER-SIDE, because a mix link carries an absolute path
- // and this component has no business constructing one. It also
- // carries the SAVED window rather than the current selection: a mix
- // of an unsaved edit is a mix of something not in the cut.
- <Link
- href={data.mixHref}
- data-mix-link={clip.id}
- className={buttonVariants({ size: "sm" })}
- >
- mix
- </Link>
- )}
- </div>
-
- <div className="micro">
- <kbd>[</kbd> <kbd>]</kbd> start · <kbd>,</kbd> <kbd>.</kbd> end · <kbd>space</kbd>{" "}
- audition · <kbd>R</kbd> reset · <kbd>p</kbd> <kbd>n</kbd> walk the cut — hold shift for
- 0.5s
+ {VERDICT_LABEL[verdict]}
+ </span>
</div>
- {/* ---- lock, explained rather than labelled ---- */}
- <div className="space-y-1 rounded border border-[var(--color-line)] p-2">
- <div className="micro">what resolve-windows may touch</div>
- {(
- [
- ["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"],
- ] as const
- ).map(([k, why]) => (
- <label key={k} className="flex items-start gap-2">
- <input
- type="checkbox"
- name={k}
- checked={!!clip[k]}
- disabled={!!busy}
- onChange={(e) => void save({ [k]: e.target.checked } as Partial<Clip>)}
- />
- <span>
- <span className="font-mono text-[11px] text-[var(--color-text)]">{k}</span>
- <span className="block text-[11px] text-[var(--color-dim)]">{why}</span>
- </span>
- </label>
- ))}
- </div>
-
- {busy && <div className="text-[var(--color-meter)]">{busy}</div>}
- {note && (
- <div data-bench-note="" className="text-[var(--color-dirty)]">
- {note}
- </div>
- )}
- </div>
- </div>
-
- {/* ---- what the header will say ---- */}
- <div className="grid gap-3 rounded border border-[var(--color-line)] p-3 lg:grid-cols-[minmax(0,1fr)_320px]">
- <div className="space-y-2">
- <div className="micro">
- the line burned into the header, exactly as the renderer will draw it
- </div>
- <div
- data-attrib-preview=""
- className="num overflow-x-auto whitespace-nowrap rounded bg-[var(--color-panel-2)] px-2 py-1.5 text-[13px] text-[var(--color-meter)]"
- >
- {previewLine}
- </div>
{data.sourceTitle == null && (
- <div className="text-[11px] text-[var(--color-dirty)]">
+ <div className="text-[11px] leading-snug text-[var(--color-dirty)]">
No cue file was found for this source, so the archived record’s own title and
upload date cannot be read here — set <code className="font-mono">title</code> and{" "}
<code className="font-mono">date</code> by hand, or the build will fail before it
- draws this.
+ draws the header.
</div>
)}
- <div className="text-[11px] text-[var(--color-dim)]">
- The archive says{" "}
- <span className="text-[var(--color-text)]">{data.sourceTitle || "—"}</span> ·{" "}
- <span className="text-[var(--color-text)]">{data.uploadDate || "—"}</span>. That is the
- date the <em>copy</em> was uploaded; a VOD mirror is often years after the stream, which
- is what these fields are for. Each saves when it loses focus or on{" "}
- <kbd>Enter</kbd>; an empty one goes back to the archive’s answer.
+
+ <div className="micro">
+ archive: <span className="text-[var(--color-text)]">{data.sourceTitle || "—"}</span> ·{" "}
+ <span className="text-[var(--color-text)]">{data.uploadDate || "—"}</span>
</div>
- <div className="space-y-2">
+ {/* ---- the six fields that decide the header ----
+ Each field's reason is a tooltip now rather than a paragraph: the
+ explanations are read once and the boxes are used every clip. */}
+ <div className="space-y-1.5">
{ATTRIB.map(([k, label, why]) => {
const changed = draft[k] !== attribValue(clip, k);
return (
- <label key={k} className="block">
+ <label key={k} className="block" title={why}>
<span className="font-mono text-[11px] text-[var(--color-text)]">{label}</span>
{changed && (
<span className="ml-2 text-[11px] text-[var(--color-dirty)]">
unsaved — was {attribValue(clip, k) || "(empty)"}
</span>
)}
- <span className="block text-[11px] text-[var(--color-dim)]">{why}</span>
{k === "quote" || k === "correction" ? (
<textarea
data-attrib-field={k}
name={k}
- rows={2}
+ ref={k === "correction" ? correctionBox : undefined}
+ rows={3}
value={draft[k]}
+ placeholder={
+ k === "correction"
+ ? "what the report got wrong: speaker, addressee, date, wording…"
+ : ""
+ }
onChange={(e) => setDraft((d) => ({ ...d, [k]: e.target.value }))}
onBlur={() => commit(k)}
- className="mt-1 w-full rounded border border-[var(--color-line)] bg-[var(--color-panel-2)] px-2 py-1 text-[12px]"
+ className="mt-0.5 w-full rounded border border-[var(--color-line)] bg-[var(--color-panel-2)] px-2 py-1 text-[12px]"
/>
) : (
<input
@@ -706,178 +1023,31 @@ export default function ClipBench({ data }: { data: ClipBenchData }) {
commit(k);
}
}}
- className="mt-1 w-full rounded border border-[var(--color-line)] bg-[var(--color-panel-2)] px-2 py-1 font-mono text-[12px]"
+ className="mt-0.5 w-full rounded border border-[var(--color-line)] bg-[var(--color-panel-2)] px-2 py-1 font-mono text-[12px]"
/>
)}
</label>
);
})}
</div>
- </div>
- {/* ---- and what it actually looks like ---- */}
- <div className="space-y-2 text-[12px]">
- <div className="micro">this clip as rendered</div>
- {segSrc ? (
- <video
- data-testid="segment-video"
- src={segSrc}
- className="aspect-video w-full rounded border border-[var(--color-line)] bg-black"
- preload="metadata"
- controls
- />
- ) : (
- <div
- data-testid="no-segment"
- className="flex aspect-video w-full items-center justify-center rounded border border-dashed border-[var(--color-line)] px-2 text-center text-[11px] text-[var(--color-dim)]"
- >
- nothing rendered for this clip yet
+ <details className="rounded border border-[var(--color-line)] px-2 py-1 text-[11px] text-[var(--color-dim)]">
+ <summary className="cursor-pointer">what these fields are for</summary>
+ <div className="mt-1 space-y-1 leading-snug">
+ <p>
+ Each saves when it loses focus or on <kbd>Enter</kbd>; an empty one goes back to the
+ archive’s own answer. The date the archive carries is the date the{" "}
+ <em>copy</em> was uploaded — a VOD mirror is often years after the stream, which is
+ what <code className="font-mono">date</code> is for.
+ </p>
+ {ATTRIB.map(([k, label, why]) => (
+ <p key={k}>
+ <span className="font-mono text-[var(--color-text)]">{label}</span> — {why}
+ </p>
+ ))}
</div>
- )}
- <button
- type="button"
- data-rerender=""
- className={buttonVariants({ variant: "primary", size: "sm" })}
- disabled={!!busy}
- onClick={() => void rerender()}
- >
- re-render this clip
- </button>
- <p className="text-[11px] text-[var(--color-dim)]">
- One clip, no crossfade, no chapters — it overwrites this clip’s segment and
- nothing else. The header is drawn by ffmpeg from a text file, so this is the only place
- the line above can be checked rather than believed.
- </p>
- </div>
- </div>
-
- {/* ---- the warnings, before the instrument ---- */}
- {midSentence && (
- <p
- data-warn="mid-sentence"
- className="rounded border border-[var(--color-dirty)] bg-[color-mix(in_srgb,var(--color-dirty)_10%,transparent)] px-3 py-1.5 text-[12px] text-[var(--color-dirty)]"
- >
- This ends mid-sentence — the cut lands inside “…
- {endCue?.text.trim().slice(-56)}”. Run it to the end of the sentence, or set{" "}
- <code className="font-mono">lockEnd</code> to say you meant to cut here.
- </p>
- )}
- {data.noPunctuation && (
- <p
- data-warn="no-punctuation"
- className="rounded border border-[var(--color-line)] px-3 py-1.5 text-[12px] text-[var(--color-dim)]"
- >
- This upload’s ASR carries no punctuation, so there are no sentence boundaries to
- find and widening cannot help. Set these edges by ear and lock them.
- </p>
- )}
- {wouldRevert && (
- <p
- data-warn="would-revert"
- className="rounded border border-[var(--color-dirty)] px-3 py-1.5 text-[12px] text-[var(--color-dirty)]"
- >
- <code className="font-mono">resolve-windows --write</code> would make this{" "}
- {hms(proposed!.start)} – {hms(proposed!.end)}, reverting your edit. Set the matching lock
- if this window is deliberate.{" "}
- <button
- type="button"
- className={buttonVariants({ variant: "primary", size: "sm" })}
- onClick={() =>
- void save({
- start: round2(sel.from),
- end: round2(sel.to),
- lockStart: Math.abs(proposed!.start - round2(sel.from)) > 0.05,
- lockEnd: Math.abs(proposed!.end - round2(sel.to)) > 0.05,
- })
- }
- >
- save and lock
- </button>
- </p>
- )}
-
- {/* ---- the waveform ---- */}
- <Waveform
- view={view}
- sel={sel}
- cand={{ from: clip.start, to: clip.end }}
- words={cues.map((c) => ({ start: c.start, end: c.end, w: c.text.slice(0, 24) }))}
- peaks={peaks}
- playhead={playhead}
- onSel={onSel}
- onReachEdge={() => {
- /* widening is a FETCH here, not a redraw -- see the button below */
- }}
- />
-
- {(atStartEdge || atEndEdge) && (
- <div className="flex items-center gap-2 text-[12px]">
- <span className="text-[var(--color-dirty)]">
- that is the edge of what is cached{cached ? ` (${cached.name})` : ""}
- </span>
- <button
- type="button"
- className={buttonVariants({ variant: "primary", size: "sm" })}
- disabled={!!busy}
- onClick={() => void fetchMore()}
- >
- fetch 20s more
- </button>
+ </details>
</div>
- )}
-
- {/* ---- the cue rail ---- */}
- <div>
- <div className="micro mb-1">
- what is being said — a marked cue closes a sentence; click an edge to snap to it
- </div>
- <div className="relative h-16 w-full overflow-hidden rounded border border-[var(--color-line)] bg-[var(--color-panel-2)]">
- {cues.map((c) => {
- const inSel = c.end > sel.from && c.start < sel.to;
- return (
- <button
- type="button"
- key={`${c.start}-${c.end}`}
- data-cue={c.start}
- data-ends-sentence={c.endsSentence ? "1" : "0"}
- title={c.text}
- onClick={() => {
- // Snap the NEARER edge to this cue's nearer boundary.
- const dStart = Math.abs(sel.from - c.start);
- const dEnd = Math.abs(sel.to - c.end);
- onSel(dStart <= dEnd ? { ...sel, from: c.start } : { ...sel, to: c.end }, false);
- }}
- className={`absolute top-0 h-full overflow-hidden border-l px-1 text-left text-[10px] leading-tight ${
- inSel
- ? "border-[var(--color-sel)] text-[var(--color-text)]"
- : "border-[var(--color-line)] text-[var(--color-dim)] opacity-60"
- }`}
- style={{ left: pct(c.start), width: `calc(${pct(c.end)} - ${pct(c.start)})` }}
- >
- {c.endsSentence && (
- <span className="mr-1 text-[var(--color-meter)]" aria-label="closes a sentence">
- ¶
- </span>
- )}
- {c.text}
- </button>
- );
- })}
- </div>
- </div>
-
- <div className="flex flex-wrap items-center gap-2 text-[11px]">
- <span className={badgeVariants({ variant: "info", size: "sm" })}>{clip.video}</span>
- {clip.channel && <span className={badgeVariants({ size: "sm" })}>{clip.channel}</span>}
- {windows.length > 1 && (
- <span className="micro">{windows.length} cached windows for this source</span>
- )}
- <Link
- href={`/browse/${data.project}`}
- className="ml-auto text-[var(--color-sel)] hover:underline"
- >
- ← the whole cut
- </Link>
</div>
</div>
);
diff --git a/umtool/components/projects/ClipBenchPage.tsx b/umtool/components/projects/ClipBenchPage.tsx
@@ -3,7 +3,7 @@ import { notFound } from "next/navigation";
import BrowseHeader from "@/components/BrowseHeader";
import ClipBench, { type ClipBenchData } from "./ClipBench";
import { manifestToken } from "@/lib/report/manifest.mjs";
-import { cuesInWindow, readClipDetail, readManifest } from "@/lib/projects/report.mjs";
+import { clipVerdict, cuesInWindow, readClipDetail, readManifest } from "@/lib/projects/report.mjs";
import { segmentFor, windowsFor } from "@/lib/report/serve.mjs";
import type { ProjectRef } from "@/lib/project-types";
@@ -60,6 +60,7 @@ export default async function ClipBenchPage({
lock: !!entry.lock,
lockStart: !!entry.lockStart,
lockEnd: !!entry.lockEnd,
+ verdict: entry.verdict === "confirmed" ? "confirmed" : null,
},
view,
windows: windows.map((w: { name: string; from: number; to: number }) => ({
@@ -81,6 +82,13 @@ export default async function ClipBenchPage({
sourceChannel: entry.sourceChannel ?? null,
prev: i > 0 ? String(clips[i - 1].id) : null,
next: i >= 0 && i < clips.length - 1 ? String(clips[i + 1].id) : null,
+ index: i + 1,
+ total: clips.length,
+ // The OTHERS, so the bench can add this clip's own answer to the count
+ // without a reload and without knowing anything about the rest of the cut.
+ reviewedOthers: clips.filter(
+ (e: { id: string }) => e.id !== clipId && clipVerdict(e) !== "unreviewed",
+ ).length,
cues: cues?.cues ?? [],
token: await manifestToken(project.dir),
fetchPad: manifest.render?.fetchPad ?? 3,
@@ -107,7 +115,9 @@ export default async function ClipBenchPage({
{ href: `/browse/${project.id}`, label: project.name },
{ label: clipId },
]}
- note={`clip ${i + 1} of ${clips.length} · ${entry.video}`}
+ // The progress reading moved INTO the bench, where pressing `y` can
+ // move it. What is left here is which source is playing.
+ note={`${entry.video}${entry.channel ? ` · ${entry.channel}` : ""}`}
/>
<main className="deck-main flex-1 p-4">
<ClipBench data={data} />