Archilyzer · Source

archilyzer

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

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:
Mumtool/app/api/report/clip/route.ts | 13+++++++++++--
Aumtool/app/api/report/segment/route.ts | 78++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mumtool/components/projects/ClipBench.tsx | 340++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-----
Mumtool/components/projects/ClipBenchPage.tsx | 25++++++++++++++++++++-----
Mumtool/lib/projects/report.mjs | 7+++++++
Mumtool/lib/report/serve.mjs | 32+++++++++++++++++++++++++++++++-
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&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. + </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> + + <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&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 @@ -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> - &ldquo;{clip.quote}&rdquo; - </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,