Archilyzer · Source

archilyzer

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

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:
Mumtool/components/projects/ClipBench.tsx | 818++++++++++++++++++++++++++++++++++++++++++++++++-------------------------------
Mumtool/components/projects/ClipBenchPage.tsx | 14++++++++++++--
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 &ldquo;… + {endCue?.text.trim().slice(-56)}&rdquo;. 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&rsquo;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&rsquo;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&rsquo;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&rsquo;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&rsquo;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&rsquo;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 &ldquo;… - {endCue?.text.trim().slice(-56)}&rdquo;. 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&rsquo;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} />