commit 998a627492e5220c76534291c839afb0341276a5
parent 37fd74d18b3bb358654aac78fed2e8e58298c31a
Author: I Mean I'm Just Saying <imeanimjustsaying@kiwifarms.st>
Date: Fri, 18 Sep 2026 17:35:06 -0400
clip bench: fix the attribution beside the edges, then look at it
Watching a clip is when you find out both that it starts too late and that the
header calls it by the archive's title and the archive's upload date — which
for a VOD mirror is years after the stream. So the five fields that decide the
burned-in line sit beside the handles, with a live preview built from the
RENDERER'S OWN attributionLine(); a preview that disagrees by a character is
worth less than none, because you would only find out twenty minutes into a
build.
Three more things the bench could not do:
- prev/next, as links and as `n`/`p`. Reviewing a cut is watching nineteen
clips in order, and the project page in between is nineteen round trips.
- re-render this one clip (`preview` preset, `--only`), polled like the fetch,
and show the result. The header is drawn by ffmpeg from a text file; no
amount of HTML can promise it wrapped the same way.
- GET /api/report/segment, modelled on /raw: names not paths, 206 ranges,
resolveInRoots. A re-render writes the SAME path, so the mtime is in the URL
and a stale `v` is served no-store rather than cached for an hour.
A window save no longer discards attribution drafts and an attribution save no
longer discards nudged edges; a deleted key now actually disappears from the
form, which `{...clip, ...entry}` quietly would not have done.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Diffstat:
6 files changed, 469 insertions(+), 26 deletions(-)
diff --git a/umtool/app/api/report/clip/route.ts b/umtool/app/api/report/clip/route.ts
@@ -1,5 +1,5 @@
import path from "node:path";
-import { resolveClip, windowsFor } from "@/lib/report/serve.mjs";
+import { resolveClip, segmentFor, windowsFor } from "@/lib/report/serve.mjs";
import { manifestToken } from "@/lib/report/manifest.mjs";
import { cuesInWindow, readClipDetail } from "@/lib/projects/report.mjs";
@@ -21,6 +21,9 @@ export async function GET(request: Request) {
const { project, manifest, clip } = r;
const windows = await windowsFor(project, clip);
+ // Re-read after a re-render: the segment lands on the SAME path, so its mtime
+ // is what tells the player it is looking at a different file.
+ const seg = await segmentFor(project, clipId);
const detail = await readClipDetail(project.dir, { manifest });
const entry = detail?.entries.find((e: { id: string }) => e.id === clipId) ?? null;
@@ -46,6 +49,9 @@ export async function GET(request: Request) {
cite: clip.cite ?? null,
quote: clip.quote ?? null,
note: clip.note ?? null,
+ title: clip.title ?? null,
+ date: clip.date ?? null,
+ citeUrl: clip.citeUrl ?? null,
lock: !!clip.lock,
lockStart: !!clip.lockStart,
lockEnd: !!clip.lockEnd,
@@ -63,7 +69,10 @@ export async function GET(request: Request) {
endsSentence: entry?.endsSentence ?? null,
noPunctuation: entry?.noPunctuation ?? false,
sourceDuration: entry?.duration ?? null,
- segment: entry?.segment ?? null,
+ segment: seg?.rel ?? entry?.segment ?? null,
+ segmentMtime: seg?.mtimeMs ?? null,
+ sourceTitle: entry?.sourceTitleClean ?? null,
+ uploadDate: entry?.uploadDate ?? null,
cues: cues?.cues ?? [],
punctuationRate: cues?.punctuationRate ?? null,
token: await manifestToken(project.dir),
diff --git a/umtool/app/api/report/segment/route.ts b/umtool/app/api/report/segment/route.ts
@@ -0,0 +1,78 @@
+import { createReadStream } from "node:fs";
+import { resolveClip, segmentFor } from "@/lib/report/serve.mjs";
+import { Readable } from "node:stream";
+
+export const dynamic = "force-dynamic";
+
+// The BUILT segment for one clip — the mp4 with the header and the QR burned in.
+//
+// /api/report/raw serves the raw source window, which is what you edit against.
+// This serves what the renderer made of it, which is the only way to see the
+// attribution line as it will actually appear: the header is drawn by ffmpeg
+// from a text file, and no amount of HTML can promise it wrapped the same way.
+//
+// Names, never paths. A project id and a clip id, both members of the server's
+// own scan, and the file name is built from the id the timeline vouched for.
+//
+// A re-render writes the SAME path, so the client passes the mtime it was told
+// about as `v`. When it matches, the file is immutable for as long as that
+// number holds and may be cached hard; when it does not, the client is looking
+// at a stale idea of the file and the answer must not be cached at all. That is
+// what makes "re-render, then watch it" show the new cut rather than the old.
+//
+// Range support is not optional: without a 206 the <video> element will not seek
+// in a stream it did not fully download.
+
+export async function GET(request: Request) {
+ const url = new URL(request.url);
+ const r = await resolveClip(
+ url.searchParams.get("project") ?? "",
+ url.searchParams.get("clip") ?? "",
+ );
+ if ("error" in r) return new Response(r.error, { status: r.status });
+
+ const seg = await segmentFor(r.project, r.clip.id);
+ if (!seg) {
+ // A missing segment is the NORMAL state of a clip nobody has rendered yet,
+ // not an error worth a stack trace. The bench says so in words.
+ return new Response("no segment has been built for this clip yet", { status: 404 });
+ }
+
+ const fresh = url.searchParams.get("v") === String(seg.mtimeMs);
+ const headers: Record<string, string> = {
+ "content-type": "video/mp4",
+ "accept-ranges": "bytes",
+ "cache-control": fresh ? "private, max-age=3600, immutable" : "private, no-store",
+ "x-segment-mtime": String(seg.mtimeMs),
+ "x-segment": seg.rel,
+ };
+
+ const range = request.headers.get("range");
+ const m = range ? /^bytes=(\d*)-(\d*)$/.exec(range.trim()) : null;
+ if (m) {
+ const size = seg.size;
+ let start = m[1] ? Number(m[1]) : 0;
+ let end = m[2] ? Number(m[2]) : size - 1;
+ if (!m[1] && m[2]) {
+ // A suffix range: the LAST n bytes.
+ start = Math.max(0, size - Number(m[2]));
+ end = size - 1;
+ }
+ if (!Number.isFinite(start) || !Number.isFinite(end) || start > end || start >= size) {
+ return new Response(null, { status: 416, headers: { "content-range": `bytes */${size}` } });
+ }
+ end = Math.min(end, size - 1);
+ return new Response(Readable.toWeb(createReadStream(seg.abs, { start, end })) as ReadableStream, {
+ status: 206,
+ headers: {
+ ...headers,
+ "content-range": `bytes ${start}-${end}/${size}`,
+ "content-length": String(end - start + 1),
+ },
+ });
+ }
+
+ return new Response(Readable.toWeb(createReadStream(seg.abs)) as ReadableStream, {
+ headers: { ...headers, "content-length": String(seg.size) },
+ });
+}
diff --git a/umtool/components/projects/ClipBench.tsx b/umtool/components/projects/ClipBench.tsx
@@ -2,9 +2,14 @@
import { useCallback, useEffect, useRef, useState } from "react";
import Link from "next/link";
+import { useRouter } from "next/navigation";
import Waveform, { type Peaks } from "@/components/Waveform";
import { badgeVariants } from "@/components/ui/badge";
import { buttonVariants } from "@/components/ui/button";
+// The renderer's own line, imported rather than re-written. A preview that
+// 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";
// ---------------------------------------------------------------------------
// Editing a clip's window against the audio and the words.
@@ -21,6 +26,13 @@ import { buttonVariants } from "@/components/ui/button";
// A DRAG NEVER DOWNLOADS. Dragging past the cached window clamps and offers a
// button, because a handle that silently starts a 12-second network fetch is a
// handle you stop trusting.
+//
+// THE EDGES AND THE ATTRIBUTION ARE ONE SITTING. Watching a clip is when you
+// find out both that it starts too late and that the header calls it by the
+// archive's title and the archive's upload date -- which for a VOD mirror is
+// years after the stream. So the fields that decide the burned-in line are here
+// beside the handles, with a live preview of exactly what ffmpeg will draw, and
+// a button to render this one clip and look at it.
// ---------------------------------------------------------------------------
type Cue = { start: number; end: number; text: string; endsSentence: boolean };
@@ -35,11 +47,55 @@ type Clip = {
cite: number | null;
quote: string | null;
note: string | null;
+ /** The attribution overrides. Absent means "use the archived record's own". */
+ title: string | null;
+ date: string | null;
+ citeUrl: string | null;
lock: boolean;
lockStart: boolean;
lockEnd: boolean;
};
+/** 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."],
+ ["date", "date", "The stream's own date, YYYY-MM-DD. Empty uses the archived copy's upload date — which for a VOD mirror is often years later."],
+ ["cite", "cite", "The second the header prints, in absolute source seconds. Empty uses the clip's start."],
+ ["citeUrl", "citeUrl", "Where the QR points. Empty derives it from the archive. Set it when the clip is cut from a mirror that reads better."],
+ ["quote", "quote", "The words this clip exists for. Not drawn on screen — it is what the cut is checked against."],
+] as const;
+
+type AttribKey = (typeof ATTRIB)[number][0];
+
+/** A field's SAVED value, as the input shows it. */
+const attribValue = (c: Clip, k: AttribKey): string =>
+ k === "cite" ? (c.cite == null ? "" : String(c.cite)) : (c[k] ?? "");
+
+const emptyDraft = (c: Clip) =>
+ Object.fromEntries(ATTRIB.map(([k]) => [k, attribValue(c, k)])) as Record<AttribKey, string>;
+
+/**
+ * A saved entry, back into the shape this component holds.
+ *
+ * Explicit rather than a spread: an empty value DELETES the key, so the entry
+ * that comes back has no `title` at all — and `{...clip, ...entry}` would leave
+ * the old one on screen as though the deletion had not happened.
+ */
+const fromEntry = (prev: Clip, e: Record<string, unknown>): Clip => ({
+ ...prev,
+ start: Number(e.start),
+ end: Number(e.end),
+ cite: e.cite == null ? null : Number(e.cite),
+ quote: (e.quote as string) ?? null,
+ note: (e.note as string) ?? null,
+ title: (e.title as string) ?? null,
+ date: (e.date as string) ?? null,
+ citeUrl: (e.citeUrl as string) ?? null,
+ lock: !!e.lock,
+ lockStart: !!e.lockStart,
+ lockEnd: !!e.lockEnd,
+});
+
export type ClipBenchData = {
project: string;
clip: Clip;
@@ -50,6 +106,14 @@ export type ClipBenchData = {
noPunctuation: boolean;
sourceDuration: number | null;
segment: string | null;
+ /** The built segment's mtime. In the URL, so a re-render busts the cache. */
+ segmentMtime: number | null;
+ /** The archived record's own title (already cleaned) and upload date. */
+ sourceTitle: string | null;
+ uploadDate: string | null;
+ /** Neighbours in the cut, computed server-side. Null at either end. */
+ prev: string | null;
+ next: string | null;
cues: Cue[];
token: string | null;
fetchPad: number;
@@ -82,7 +146,11 @@ export default function ClipBench({ data }: { data: ClipBenchData }) {
const [note, setNote] = useState<string | null>(null);
const [busy, setBusy] = useState<string | null>(null);
const [dirty, setDirty] = useState(false);
+ const [draft, setDraft] = useState<Record<AttribKey, string>>(() => emptyDraft(data.clip));
+ const [segment, setSegment] = useState(data.segment);
+ const [segmentMtime, setSegmentMtime] = useState(data.segmentMtime);
+ const router = useRouter();
const video = useRef<HTMLVideoElement | null>(null);
const stopAt = useRef<number | null>(null);
@@ -181,6 +249,17 @@ export default function ClipBench({ data }: { data: ClipBenchData }) {
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;
}
@@ -188,11 +267,11 @@ export default function ClipBench({ data }: { data: ClipBenchData }) {
};
window.addEventListener("keydown", onKey);
return () => window.removeEventListener("keydown", onKey);
- }, [sel, clip.start, clip.end, onSel, play]);
+ }, [sel, clip.start, clip.end, onSel, play, router, data.project, data.prev, data.next]);
// ---- saving -------------------------------------------------------------
const save = useCallback(
- async (patch: Partial<Clip> & { start?: number; end?: number }) => {
+ async (patch: Record<string, unknown>) => {
setBusy("saving…");
setNote(null);
const r = await fetch("/api/report/window", {
@@ -208,19 +287,43 @@ export default function ClipBench({ data }: { data: ClipBenchData }) {
? "the manifest changed since you opened this — reload before saving, or your edit would overwrite whatever was written"
: `could not save: ${String(j.error ?? r.status)}`,
);
+ // The draft is deliberately LEFT as typed. A rejected date is a typo to
+ // fix, and clearing the box would make the operator retype the other
+ // eight characters to find out which one was wrong.
return false;
}
- const entry = j.entry as Clip;
- setClip((c) => ({ ...c, ...entry }));
- setSel({ from: entry.start, to: entry.end });
+ const entry = j.entry as Record<string, unknown>;
+ const next = fromEntry(clip, entry);
+ setClip(next);
+ // Only a window save moves the selection. An attribution field saving on
+ // blur must not throw away edges somebody has nudged but not saved.
+ if (patch.start !== undefined || patch.end !== undefined) {
+ setSel({ from: next.start, to: next.end });
+ setDirty(false);
+ }
+ // Re-sync only the fields this save carried, and from what the writer
+ // actually stored -- which is trimmed, rounded, or gone.
+ setDraft((d) => {
+ const out = { ...d };
+ for (const [k] of ATTRIB) if (patch[k] !== undefined) out[k] = attribValue(next, k);
+ return out;
+ });
setToken(String(j.token ?? ""));
- setDirty(false);
setNote("saved");
// The widener's opinion changes when the window does.
void refresh();
return true;
},
- [data.project, clip.id, token],
+ [data.project, clip, token],
+ );
+
+ /** Persist one attribution field, on blur or Enter, if it actually changed. */
+ const commit = useCallback(
+ (k: AttribKey) => {
+ if (draft[k] === attribValue(clip, k)) return;
+ void save({ [k]: draft[k] });
+ },
+ [draft, clip, save],
);
const refresh = useCallback(async () => {
@@ -234,6 +337,8 @@ export default function ClipBench({ data }: { data: ClipBenchData }) {
setCues(j.cues);
setProposed(j.proposed);
setToken(j.token);
+ setSegment(j.segment);
+ setSegmentMtime(j.segmentMtime);
if (j.windows[0]) setView({ from: j.windows[0].from, to: j.windows[0].to });
}, [data.project, clip.id]);
@@ -271,6 +376,48 @@ export default function ClipBench({ data }: { data: ClipBenchData }) {
setNote("the fetch is still running; reload to pick it up");
}, [data.project, clip.id, refresh]);
+ // ---- re-rendering this one clip -------------------------------------------
+ //
+ // The `preview` preset is one clip and nothing else, and the segment is
+ // content-addressed by its id -- so this overwrites exactly the file being
+ // looked at and touches no deliverable. Polled the way the fetch is: one
+ // short job, and a dead poll is harmless.
+ //
+ // This is the only way to see the attribution as it will actually appear. The
+ // header is drawn by ffmpeg from a text file and no amount of HTML can promise
+ // it wrapped the same way.
+ const rerender = useCallback(async () => {
+ setBusy("rendering this clip…");
+ setNote(null);
+ const r = await fetch("/api/report/build", {
+ method: "POST",
+ headers: { "content-type": "application/json" },
+ body: JSON.stringify({ project: data.project, preset: "preview", only: clip.id }),
+ });
+ if (!r.ok) {
+ const j = (await r.json()) as { error?: string };
+ setBusy(null);
+ setNote(j.error ?? "could not start the render");
+ return;
+ }
+ const { job } = (await r.json()) as { job: { id: string } };
+ for (let i = 0; i < 900; i += 1) {
+ await new Promise((res) => setTimeout(res, 500));
+ const s = await fetch(`/api/report/build?job=${job.id}`, { cache: "no-store" });
+ const sj = (await s.json()) as { state?: string; error?: string | null };
+ if (!sj.state || sj.state === "running") continue;
+ setBusy(null);
+ if (sj.state === "failed") setNote(`the render failed: ${sj.error ?? "unknown"}`);
+ else {
+ setNote("rendered — the segment below is this clip as it will appear");
+ await refresh();
+ }
+ return;
+ }
+ setBusy(null);
+ setNote("the render is still going; reload to pick it up");
+ }, [data.project, clip.id, refresh]);
+
// ---- 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;
@@ -289,8 +436,51 @@ export default function ClipBench({ data }: { data: ClipBenchData }) {
const span = Math.max(1e-6, view.to - view.from);
const pct = (t: number) => `${((t - view.from) / span) * 100}%`;
+ const previewLine = attributionLine(
+ {
+ title: draft.title.trim() || null,
+ date: draft.date.trim() || null,
+ cite: draft.cite.trim() === "" ? null : Number(draft.cite),
+ start: clip.start,
+ },
+ { title: data.sourceTitle, uploadDate: data.uploadDate },
+ );
+
+ const segSrc = segment
+ ? `/api/report/segment?project=${encodeURIComponent(data.project)}&clip=${encodeURIComponent(clip.id)}&v=${segmentMtime ?? 0}`
+ : null;
+
return (
<div className="space-y-3" data-bench={clip.id}>
+ {/* ---- walking the cut ---- */}
+ <div className="flex items-center gap-3 text-[11px]">
+ {data.prev ? (
+ <Link
+ data-clip-nav="prev"
+ href={`/browse/${data.project}/clip/${data.prev}`}
+ className="text-[var(--color-sel)] hover:underline"
+ >
+ ← {data.prev}
+ </Link>
+ ) : (
+ <span className="text-[var(--color-dim)]">← first clip</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"
+ >
+ {data.next} →
+ </Link>
+ ) : (
+ <span className="ml-auto text-[var(--color-dim)]">last clip →</span>
+ )}
+ </div>
+
{/* ---- the picture ---- */}
<div className="grid gap-3 lg:grid-cols-[minmax(0,1fr)_320px]">
<div>
@@ -368,7 +558,8 @@ export default function ClipBench({ data }: { data: ClipBenchData }) {
<div className="micro">
<kbd>[</kbd> <kbd>]</kbd> start · <kbd>,</kbd> <kbd>.</kbd> end · <kbd>space</kbd>{" "}
- audition · <kbd>R</kbd> reset — hold shift for 0.5s
+ audition · <kbd>R</kbd> reset · <kbd>p</kbd> <kbd>n</kbd> walk the cut — hold shift for
+ 0.5s
</div>
{/* ---- lock, explained rather than labelled ---- */}
@@ -397,9 +588,6 @@ export default function ClipBench({ data }: { data: ClipBenchData }) {
))}
</div>
- {data.segment && (
- <div className="micro">a segment is already built for this clip</div>
- )}
{busy && <div className="text-[var(--color-meter)]">{busy}</div>}
{note && (
<div data-bench-note="" className="text-[var(--color-dirty)]">
@@ -409,6 +597,129 @@ export default function ClipBench({ data }: { data: ClipBenchData }) {
</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)]">
+ 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.
+ </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>
+
+ <div className="space-y-2">
+ {ATTRIB.map(([k, label, why]) => {
+ const changed = draft[k] !== attribValue(clip, k);
+ return (
+ <label key={k} className="block">
+ <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" ? (
+ <textarea
+ data-attrib-field={k}
+ name={k}
+ rows={2}
+ value={draft[k]}
+ disabled={!!busy}
+ 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]"
+ />
+ ) : (
+ <input
+ data-attrib-field={k}
+ name={k}
+ type="text"
+ value={draft[k]}
+ disabled={!!busy}
+ placeholder={
+ k === "date"
+ ? data.uploadDate
+ ? `${data.uploadDate.slice(0, 4)}-${data.uploadDate.slice(4, 6)}-${data.uploadDate.slice(6, 8)}`
+ : "YYYY-MM-DD"
+ : k === "cite"
+ ? String(clip.start)
+ : k === "title"
+ ? (data.sourceTitle ?? "")
+ : ""
+ }
+ onChange={(e) => setDraft((d) => ({ ...d, [k]: e.target.value }))}
+ onBlur={() => commit(k)}
+ onKeyDown={(e) => {
+ if (e.key === "Enter") {
+ e.preventDefault();
+ 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]"
+ />
+ )}
+ </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
+ </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
@@ -524,13 +835,6 @@ export default function ClipBench({ data }: { data: ClipBenchData }) {
</div>
</div>
- {clip.quote && (
- <p className="text-[12px] leading-snug text-[var(--color-dim)]">
- <span className="micro mr-2">the quote this clip exists for</span>
- “{clip.quote}”
- </p>
- )}
-
<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>}
diff --git a/umtool/components/projects/ClipBenchPage.tsx b/umtool/components/projects/ClipBenchPage.tsx
@@ -4,7 +4,7 @@ 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 { windowsFor } from "@/lib/report/serve.mjs";
+import { segmentFor, windowsFor } from "@/lib/report/serve.mjs";
import type { ProjectRef } from "@/lib/project-types";
// The server half of the bench: resolve the clip, read what it needs, and hand
@@ -28,7 +28,14 @@ export default async function ClipBenchPage({
);
if (!entry) notFound();
+ const clips = detail.entries.filter((e: { kind: string }) => e.kind === "clip");
+ const i = clips.findIndex((e: { id: string }) => e.id === clipId);
+
const windows = await windowsFor(project, entry);
+ // The mtime rides along so a re-render busts the browser's cache: the segment
+ // is written back to the SAME path, and without it the player would keep
+ // showing the cut from before the edit.
+ const seg = await segmentFor(project, clipId);
const widest = windows[0] ?? null;
const view = widest
? { from: widest.from, to: widest.to }
@@ -46,6 +53,9 @@ export default async function ClipBenchPage({
cite: entry.cite ?? null,
quote: entry.quote ?? null,
note: entry.note ?? null,
+ title: entry.title ?? null,
+ date: entry.date ?? null,
+ citeUrl: entry.citeUrl ?? null,
lock: !!entry.lock,
lockStart: !!entry.lockStart,
lockEnd: !!entry.lockEnd,
@@ -60,7 +70,15 @@ export default async function ClipBenchPage({
endsSentence: entry.endsSentence ?? null,
noPunctuation: !!entry.noPunctuation,
sourceDuration: entry.duration ?? null,
- segment: entry.segment ?? null,
+ segment: seg?.rel ?? entry.segment ?? null,
+ segmentMtime: seg?.mtimeMs ?? null,
+ // What the header would say with no overrides. Shown beside the fields that
+ // replace it, because "the archive says 2019" is the whole reason `date`
+ // exists.
+ sourceTitle: entry.sourceTitleClean ?? null,
+ uploadDate: entry.uploadDate ?? null,
+ prev: i > 0 ? String(clips[i - 1].id) : null,
+ next: i >= 0 && i < clips.length - 1 ? String(clips[i + 1].id) : null,
cues: cues?.cues ?? [],
token: await manifestToken(project.dir),
fetchPad: manifest.render?.fetchPad ?? 3,
@@ -79,9 +97,6 @@ export default async function ClipBenchPage({
: null,
};
- const clips = detail.entries.filter((e: { kind: string }) => e.kind === "clip");
- const i = clips.findIndex((e: { id: string }) => e.id === clipId);
-
return (
<div className="flex h-full flex-col">
<BrowseHeader
diff --git a/umtool/lib/projects/report.mjs b/umtool/lib/projects/report.mjs
@@ -8,6 +8,7 @@
import { readdir, readFile, stat } from "node:fs/promises";
import path from "node:path";
import { DEFAULT_VARIANT, cachedWindowsFor, findContainingWindow } from "umtool-report-to-video/build-video";
+import { cleanTitle } from "umtool-report-to-video/attribution";
/**
* Where a build's per-entry segments live.
@@ -865,6 +866,12 @@ export async function readClipDetail(dir, { manifest = null } = {}) {
hasCues: !!doc,
duration: doc?.duration ?? null,
sourceTitle: doc?.title ?? null,
+ // What the burned-in header would say if this clip carried no overrides:
+ // the title with the emoji and !commands stripped, and the ARCHIVED
+ // copy's upload date. The bench shows both beside the fields that replace
+ // them, so "2019" next to a 2016 stream is visible rather than inferred.
+ sourceTitleClean: doc?.title ? cleanTitle(doc.title) : null,
+ uploadDate: doc?.uploadDate ?? null,
punctuationRate: doc?.punctuationRate ?? null,
endCueText: endCue?.text ?? null,
endsSentence,
diff --git a/umtool/lib/report/serve.mjs b/umtool/lib/report/serve.mjs
@@ -6,9 +6,10 @@
// name that is simply not there fails, and a traversal fails twice: once on the
// membership check and once on resolveInRoots.
import path from "node:path";
+import { stat } from "node:fs/promises";
import { REPORTS_ROOT, resolveInRoots } from "../paths.mjs";
import { walkProjects } from "../projects/walk.mjs";
-import { cachedWindowsFor } from "umtool-report-to-video/build-video";
+import { DEFAULT_VARIANT, cachedWindowsFor } from "umtool-report-to-video/build-video";
import { clipsOf, readManifest } from "../projects/report.mjs";
export async function resolveClip(projectId, clipId) {
@@ -50,6 +51,35 @@ export function absOf(win) {
}
/**
+ * The BUILT segment for a clip -- the mp4 with the header and the QR burned in.
+ *
+ * Two directories, because a two-cut manifest writes its default variant under
+ * `out/<variant>/segments` while an older tree wrote `out/segments`; the same
+ * pair readClipDetail looks in, and for the same reason.
+ *
+ * The name is built from the clip id the timeline already vouched for, never
+ * from anything the client typed, and still goes through resolveInRoots -- the
+ * membership check and the root check are two guards and both are cheap.
+ *
+ * Returns the mtime as well, because a re-render writes the SAME path: without
+ * it in the URL a browser would keep showing the segment it cached before the
+ * edit, which is the one thing a re-render button must never do.
+ */
+export async function segmentFor(project, clipId) {
+ for (const rel of [
+ path.posix.join("out", DEFAULT_VARIANT, "segments"),
+ path.posix.join("out", "segments"),
+ ]) {
+ const relFile = path.posix.join(rel, `${clipId}.mp4`);
+ const abs = resolveInRoots(path.join(project.dir, relFile));
+ if (!abs) continue;
+ const st = await stat(abs).catch(() => null);
+ if (st?.isFile()) return { rel: relFile, abs, size: st.size, mtimeMs: Math.round(st.mtimeMs) };
+ }
+ return null;
+}
+
+/**
* The same membership rule, for a LEDGER claim.
*
* A claim id is checked against the ledger of the project named in the request,