commit a077a89c6a9ae7dafe1839190d55a390b3011875
parent 9132b9be1ffa1daf837ea5295caddf8e2b48a411
Author: I Mean I'm Just Saying <imeanimjustsaying@kiwifarms.st>
Date: Wed, 30 Sep 2026 22:03:54 -0400
deck S5: on-screen title and subtitle on the clip bench
The bench's answer column gains the on-screen pair beside the attribution
fields. Both save as one onscreen value through PUT /api/report/window (an
empty pair clears the key), on blur or Enter, queued behind any save in
flight; a field re-syncs to the stored value only if it still holds what
was sent. The auto title and subtitle from GET /api/report/onscreen are the
placeholders, with the maxChars counter on the title.
When the manifest has render.chrome, the bench composes the cut's preview
once per clip and shows this clip's segment of it twice: a strip under the
fields that follows the player's playhead mapped onto the cut's clock
(mid-segment when the player is outside the played span), and, while
"this clip as rendered" is open, an overlay on the rendered segment at the
deck rect, clocked by the segment's own time, with its play/scrub row
below the frame since the panel covers where native controls sit. Typing
patches both by postMessage; nothing recomposes. ClipBenchPage passes the
entry's onscreen and deckOn.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Diffstat:
2 files changed, 286 insertions(+), 2 deletions(-)
diff --git a/umtool/components/projects/ClipBench.tsx b/umtool/components/projects/ClipBench.tsx
@@ -10,6 +10,16 @@ import { buttonVariants } from "@/components/ui/button";
// disagrees with the header by a character is worth less than no preview: you
// would only find out twenty minutes into a build.
import { attributionLine } from "umtool-report-to-video/attribution";
+import {
+ DeckFrame,
+ NeutralFrame,
+ composePreview,
+ midOf,
+ onscreenValue,
+ type DeckPreviewDoc,
+ type DeckTexts,
+ type Onscreen,
+} from "./OnscreenSection";
// ---------------------------------------------------------------------------
// Editing a clip's window against the audio and the words.
@@ -92,8 +102,15 @@ type Clip = {
lockCut: boolean;
/** "confirmed" / "incorrect", or null for "nobody has looked at this yet". */
verdict: "confirmed" | "incorrect" | null;
+ /** What the on-screen panel says over this clip. Absent means the auto text. */
+ onscreen: Onscreen | null;
};
+/** The on-screen fields as the inputs hold them. */
+type OsDraft = { title: string; subtitle: string };
+const osDraftOf = (o: Onscreen | null): OsDraft => ({ title: o?.title ?? "", subtitle: o?.subtitle ?? "" });
+const sameOs = (a: OsDraft, b: OsDraft) => a.title.trim() === b.title.trim() && a.subtitle.trim() === b.subtitle.trim();
+
/**
* Has anybody looked at this clip, and did they agree with the description?
*
@@ -164,6 +181,7 @@ const fromEntry = (prev: Clip, e: Record<string, unknown>): Clip => ({
cutEnd: e.cutEnd == null ? null : Number(e.cutEnd),
lockCut: !!e.lockCut,
verdict: e.verdict === "confirmed" || e.verdict === "incorrect" ? e.verdict : null,
+ onscreen: (e.onscreen as Onscreen | undefined) ?? null,
});
export type ClipBenchData = {
@@ -219,6 +237,8 @@ export type ClipBenchData = {
mixHref: string | null;
/** Every other clip in the cut from this same recording, by start. */
siblings: Sibling[];
+ /** Is the cut built with the on-screen panel (`render.chrome`)? */
+ deckOn: boolean;
};
const hms = (t: number) => {
@@ -313,6 +333,20 @@ export default function ClipBench({ data }: { data: ClipBenchData }) {
const [playback, setPlayback] = useState<Playback>(PLAYBACK_DEFAULT);
const [segment, setSegment] = useState(data.segment);
const [segmentMtime, setSegmentMtime] = useState(data.segmentMtime);
+ // ---- the on-screen panel ------------------------------------------------
+ // Its two fields, the text the panel says with them empty (`osAuto`, from
+ // the On-screen route), and a composed preview of the whole cut's panel, of
+ // which this bench shows this clip's segment.
+ const [os, setOs] = useState<OsDraft>(() => osDraftOf(data.clip.onscreen));
+ const [osAuto, setOsAuto] = useState<OsDraft | null>(null);
+ const [osMax, setOsMax] = useState(48);
+ const [deckPreview, setDeckPreview] = useState<DeckPreviewDoc | null>(null);
+ const [deckError, setDeckError] = useState<string | null>(null);
+ // The rendered strip is folded; its overlay only exists while it is open.
+ const [renderedOpen, setRenderedOpen] = useState(false);
+ const [segT, setSegT] = useState<number | null>(null);
+ const [segPaused, setSegPaused] = useState(true);
+ const [segDur, setSegDur] = useState(0);
const router = useRouter();
const video = useRef<HTMLVideoElement | null>(null);
@@ -392,6 +426,64 @@ export default function ClipBench({ data }: { data: ClipBenchData }) {
// ---- playback preferences ------------------------------------------------
useEffect(() => setPlayback(readPlayback()), []);
+ // ---- the on-screen preview ----------------------------------------------
+ //
+ // Composed once per clip: the cut's whole panel, from which the strip and
+ // the overlay show this clip's segment. Typing never recomposes -- the
+ // fields are patched in by message, before anything is saved.
+ useEffect(() => {
+ if (!data.deckOn) return;
+ let live = true;
+ void (async () => {
+ const [pv, table] = await Promise.all([
+ composePreview(data.project, null),
+ fetch(`/api/report/onscreen?project=${encodeURIComponent(data.project)}`, { cache: "no-store" })
+ .then((r) => (r.ok ? r.json() : null))
+ .catch(() => null) as Promise<{
+ rows?: { id: string; auto: OsDraft }[];
+ maxChars?: number;
+ } | null>,
+ ]);
+ if (!live) return;
+ if ("error" in pv) setDeckError(pv.error);
+ else setDeckPreview(pv);
+ const row = table?.rows?.find((r) => r.id === clip.id);
+ if (row) setOsAuto(row.auto);
+ if (table?.maxChars) setOsMax(table.maxChars);
+ })();
+ return () => {
+ live = false;
+ };
+ }, [data.deckOn, data.project, clip.id]);
+
+ // The rendered segment drives the overlay's clock: its own time, plus where
+ // its segment starts in the cut.
+ useEffect(() => {
+ const el = segVideo.current;
+ if (!el || !renderedOpen) return;
+ const tick = () => setSegT(el.currentTime);
+ const state = () => setSegPaused(el.paused);
+ const meta = () => {
+ setSegDur(el.duration || 0);
+ // A quarter in, not zero: at the segment's first frame the PREVIOUS
+ // clip's title is still handing over, which reads as the wrong title.
+ if (el.currentTime === 0 && el.duration) el.currentTime = el.duration / 4;
+ };
+ el.addEventListener("timeupdate", tick);
+ el.addEventListener("seeked", tick);
+ el.addEventListener("play", state);
+ el.addEventListener("pause", state);
+ el.addEventListener("loadedmetadata", meta);
+ if (el.readyState >= 1) meta();
+ return () => {
+ el.removeEventListener("timeupdate", tick);
+ el.removeEventListener("seeked", tick);
+ el.removeEventListener("play", state);
+ el.removeEventListener("pause", state);
+ el.removeEventListener("loadedmetadata", meta);
+ };
+ }, [segment, segmentMtime, renderedOpen, deckPreview]);
+
/**
* Change a preference AND remember it. Never an effect on `playback`.
*
@@ -437,7 +529,7 @@ export default function ClipBench({ data }: { data: ClipBenchData }) {
const again = () => apply(el);
el.addEventListener("loadedmetadata", again);
return () => el.removeEventListener("loadedmetadata", again);
- }, [playback, segment, cached]);
+ }, [playback, segment, cached, renderedOpen, deckPreview]);
// ---- audition -----------------------------------------------------------
const play = useCallback(
@@ -570,6 +662,17 @@ export default function ClipBench({ data }: { data: ClipBenchData }) {
for (const [k] of ATTRIB) if (patch[k] !== undefined) out[k] = attribValue(next, k);
return out;
});
+ // The on-screen pair saves as one value. Each field goes back to what was
+ // stored only if it still holds what was sent -- typing on into the
+ // other field while this was in flight must not be undone by it.
+ if (patch.onscreen !== undefined) {
+ const sent = osDraftOf(patch.onscreen as Onscreen | null);
+ const stored = osDraftOf(next.onscreen);
+ setOs((d) => ({
+ title: d.title.trim() === sent.title ? stored.title : d.title,
+ subtitle: d.subtitle.trim() === sent.subtitle ? stored.subtitle : d.subtitle,
+ }));
+ }
token.current = String(j.token ?? "");
setNote("saved");
// The widener's opinion changes when the window does.
@@ -629,6 +732,12 @@ export default function ClipBench({ data }: { data: ClipBenchData }) {
[draft, clip, save, needNote],
);
+ /** Persist the on-screen pair, on blur or Enter, if either field changed. */
+ const commitOs = useCallback(() => {
+ if (sameOs(os, osDraftOf(clip.onscreen))) return;
+ void save({ onscreen: onscreenValue(os) });
+ }, [os, clip.onscreen, save]);
+
// ---- the window patch, one rule, two callers ------------------------------
//
// `save window` writes it on demand; a confirmation carries it when the edges
@@ -1133,6 +1242,28 @@ export default function ClipBench({ data }: { data: ClipBenchData }) {
const verdict = verdictOf(clip);
const reviewed = data.reviewedOthers + (verdict === "unreviewed" ? 0 : 1);
+ // ---- the on-screen panel, as this clip's segment of it --------------------
+ const deckSeg = deckPreview?.schedule.segments.find((sg) => sg.id === clip.id) ?? null;
+ const osShown: OsDraft = {
+ title: os.title.trim() || osAuto?.title || "",
+ subtitle: os.subtitle.trim() || osAuto?.subtitle || "",
+ };
+ // Nothing is patched in until the auto text is known: an empty field would
+ // otherwise blank the line the composition was built with.
+ const osTexts: DeckTexts = osAuto ? { [clip.id]: osShown } : {};
+ const osChanged = !sameOs(os, osDraftOf(clip.onscreen));
+ const osOver = osShown.title.length > osMax;
+ // The strip follows the player when the player is inside what the cut
+ // plays, mapped onto the cut's clock; anywhere else it holds mid-segment.
+ const playFrom = clip.cutStart ?? clip.start;
+ const stripT = !deckSeg
+ ? 0
+ : playhead != null && playhead >= playFrom - 0.05 && playhead <= playFrom + deckSeg.duration
+ ? deckSeg.start + Math.max(0, playhead - playFrom)
+ : midOf(deckSeg);
+ const overlayT = deckSeg ? deckSeg.start + Math.min(segT ?? deckSeg.duration / 4, deckSeg.duration) : 0;
+ const showOverlay = data.deckOn && !!deckPreview && renderedOpen;
+
return (
// ONE SCREEN, at a desk.
//
@@ -1756,6 +1887,7 @@ export default function ClipBench({ data }: { data: ClipBenchData }) {
bench taller than a laptop. */}
<details
data-rendered-strip=""
+ onToggle={(e) => setRenderedOpen((e.currentTarget as HTMLDetailsElement).open)}
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)]">
@@ -1768,7 +1900,59 @@ export default function ClipBench({ data }: { data: ClipBenchData }) {
</summary>
<div className="mt-2 grid gap-3 sm:grid-cols-[minmax(0,1fr)_minmax(0,16rem)]">
<div className="space-y-2">
- {segSrc ? (
+ {segSrc && showOverlay ? (
+ // The segment with the on-screen panel laid over it, where the
+ // build overlays it. The panel covers the bottom of the frame
+ // -- where native controls would be -- so the player's
+ // controls are a row of its own underneath.
+ <div className="space-y-1">
+ <DeckFrame preview={deckPreview!} t={overlayT} texts={osTexts} testid="bench-deck-preview">
+ <video
+ ref={segVideo}
+ data-testid="segment-video"
+ src={segSrc}
+ className="absolute inset-0 h-full w-full cursor-pointer object-contain"
+ preload="metadata"
+ onClick={(e) => {
+ const el = e.currentTarget;
+ if (el.paused) void el.play().catch(() => {});
+ else el.pause();
+ }}
+ />
+ </DeckFrame>
+ <div className="flex items-center gap-2">
+ <button
+ type="button"
+ data-testid="segment-play"
+ className={buttonVariants({ size: "sm" })}
+ onClick={() => {
+ const el = segVideo.current;
+ if (!el) return;
+ if (el.paused) void el.play().catch(() => {});
+ else el.pause();
+ }}
+ >
+ {segPaused ? "play" : "pause"}
+ </button>
+ <input
+ type="range"
+ data-testid="segment-scrub"
+ min={0}
+ max={segDur || 0}
+ step={0.01}
+ value={Math.min(segT ?? 0, segDur || 0)}
+ onChange={(e) => {
+ const el = segVideo.current;
+ if (el) el.currentTime = Number(e.target.value);
+ }}
+ className="min-w-0 flex-1"
+ />
+ <span className="num font-mono text-[11px] text-[var(--color-dim)]">
+ {(segT ?? 0).toFixed(1)} / {segDur.toFixed(1)}s
+ </span>
+ </div>
+ </div>
+ ) : segSrc ? (
<video
ref={segVideo}
data-testid="segment-video"
@@ -1777,6 +1961,17 @@ export default function ClipBench({ data }: { data: ClipBenchData }) {
preload="metadata"
controls
/>
+ ) : showOverlay ? (
+ <div data-testid="no-segment">
+ <DeckFrame
+ preview={deckPreview!}
+ t={deckSeg ? midOf(deckSeg) : 0}
+ texts={osTexts}
+ testid="bench-deck-preview"
+ >
+ <NeutralFrame geometry={deckPreview!.geometry} label="nothing rendered for this clip yet" />
+ </DeckFrame>
+ </div>
) : (
<div
data-testid="no-segment"
@@ -2046,6 +2241,85 @@ export default function ClipBench({ data }: { data: ClipBenchData }) {
})}
</div>
+ {/* ---- on-screen: what the panel under the footage says ----
+ Beside the header's fields because it is the same sitting: the
+ words you hear are the words a title should summarise. Both
+ save together, on blur or Enter; an empty one is the grey text. */}
+ <div
+ data-testid="bench-onscreen"
+ data-deck-on={data.deckOn ? "1" : "0"}
+ className="space-y-1 rounded border border-[var(--color-line)] px-2 py-1.5"
+ >
+ <div className="flex flex-wrap items-baseline gap-2">
+ <span className="micro">on-screen</span>
+ {osChanged && <span className="text-[11px] text-[var(--color-dirty)]">unsaved</span>}
+ {!data.deckOn && (
+ <span className="text-[11px] text-[var(--color-dim)]">
+ off for this report — kept, and drawn once it is on (project page → On-screen)
+ </span>
+ )}
+ </div>
+ {(
+ [
+ ["title", "The big line over this clip. Empty: no title, and the source line takes its place."],
+ ["subtitle", "The line under it. Empty: the source and date, from the archive."],
+ ] as const
+ ).map(([k, why]) => (
+ <label key={k} className="flex items-center gap-2" title={why}>
+ <span className="w-14 shrink-0 font-mono text-[11px] text-[var(--color-text)]">{k}</span>
+ <input
+ type="text"
+ data-testid={`bench-onscreen-${k}`}
+ name={`onscreen-${k}`}
+ value={os[k]}
+ maxLength={200}
+ placeholder={
+ osAuto?.[k] ||
+ (k === "title" ? "(no title — the source line takes its place)" : "(the source and date)")
+ }
+ onChange={(e) => setOs((d) => ({ ...d, [k]: e.target.value }))}
+ onBlur={commitOs}
+ onKeyDown={(e) => {
+ if (e.key === "Enter") {
+ e.preventDefault();
+ commitOs();
+ }
+ }}
+ className={`min-w-0 flex-1 rounded border bg-[var(--color-panel-2)] px-2 py-1 text-[12px] placeholder:italic placeholder:text-[var(--color-dim)] ${
+ k === "title" && osOver ? "border-[var(--color-dirty)]" : "border-[var(--color-line)]"
+ }`}
+ />
+ {k === "title" && (
+ <span
+ data-testid="bench-onscreen-count"
+ data-over={osOver ? "1" : "0"}
+ className={`num w-12 shrink-0 text-right font-mono text-[11px] ${osOver ? "text-[var(--color-dirty)]" : "text-[var(--color-dim)]"}`}
+ >
+ {osShown.title.length}/{osMax}
+ </span>
+ )}
+ {k === "subtitle" && <span className="w-12 shrink-0" />}
+ </label>
+ ))}
+ {data.deckOn &&
+ (deckPreview ? (
+ <DeckFrame
+ preview={deckPreview}
+ t={stripT}
+ texts={osTexts}
+ mode="strip"
+ testid="bench-deck-strip"
+ />
+ ) : (
+ <p className="text-[11px] text-[var(--color-dim)]" data-testid="bench-deck-pending">
+ {deckError ? `no preview: ${deckError}` : "composing the panel…"}
+ </p>
+ ))}
+ {deckPreview && !deckSeg && (
+ <p className="text-[11px] text-[var(--color-dirty)]">this clip is not in the default cut</p>
+ )}
+ </div>
+
<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">
diff --git a/umtool/components/projects/ClipBenchPage.tsx b/umtool/components/projects/ClipBenchPage.tsx
@@ -12,6 +12,7 @@ import {
} from "@/lib/projects/report.mjs";
import { projectCache, segmentFor, windowsFor } from "@/lib/report/serve.mjs";
import { FETCH_MAX_PAD } from "@/lib/report/driver.mjs";
+import { deckOn } from "umtool-report-to-video/deck";
import type { ProjectRef } from "@/lib/project-types";
// The server half of the bench: resolve the clip, read what it needs, and hand
@@ -89,7 +90,16 @@ export default async function ClipBenchPage({
lockCut: !!entry.lockCut,
verdict:
entry.verdict === "confirmed" || entry.verdict === "incorrect" ? entry.verdict : null,
+ // From the manifest's own entry: what the on-screen panel says over this
+ // clip, when somebody has written it.
+ onscreen:
+ ((manifest.timeline ?? []) as { id: string; onscreen?: { title?: string; subtitle?: string } }[]).find(
+ (e) => e.id === clipId,
+ )?.onscreen ?? null,
},
+ // Whether the cut is built with the on-screen panel. Decides whether the
+ // bench composes a preview of it; the fields are there either way.
+ deckOn: deckOn(manifest.render ?? {}),
view,
windows: windows.map((w: { name: string; from: number; to: number }) => ({
name: w.name,