commit b4265b0b618b17f52fdd12d01e84a5e6ff62c264
parent 88f972bb90a71f93f192e1e16a38151f616d2b70
Author: I Mean I'm Just Saying <imeanimjustsaying@kiwifarms.st>
Date: Tue, 18 Aug 2026 22:13:42 -0400
umtool: the clip bench — edit a window against the audio and the words
"How much context does this clip need" was a loop of hand-editing JSON,
re-running two CLIs and watching an mp4. This is that loop in one place, at
/browse/<project>/clip/<id>.
EVERYTHING IS IN ABSOLUTE SOURCE SECONDS -- the manifest's, the cue file's, the
QR's. The cached file's own start is the only relative number and exists solely
to set video.currentTime. Letting those mix is how a window ends up off by the
pad with nobody able to see why.
The preview is the cached out/clips-raw/<video>_<a>-<b>.mp4 served WHOLE with
byte ranges, and all windowing happens in the browser. No ffmpeg per drag. A 206
is not optional for that: without one the <video> element will not seek in a
stream it did not fully download. The file is immutable -- its window is in its
name -- so it is cached hard and analyseMedia's envelope can never miss twice.
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 fetch runs the pipeline's own --fetch-only path,
so the file lands named the way the build expects with the same format pin and
the same Rumble HLS retry -- and containing-window reuse then makes that
generous fetch BE the build's cache rather than a second one.
Three things the bench says that the JSON cannot:
- ENDS MID-SENTENCE, quoting the cue the cut lands inside. The standing rule
mechanised; one 14-clip cut shipped with 8 clips ending mid-thought.
`lockEnd` is the acknowledgement and silences it.
- THIS SOURCE HAS NO PUNCTUATION, when the ASR emitted no terminators at all.
An admission rather than a confident "this cut is fine".
- WHAT THE WIDENER WOULD DO, computed in-process because widen() is pure once
the cues are read. Moving an edge somewhere resolve-windows would not produce
means the next --write reverts it, so the bench offers to set the matching
lock. That is why five of six real manifests are 100% locked.
Verified end to end: the bench predicted 3.00-6.00 would widen to 3.00-9.00;
saving 3.00-9.00 makes `resolve-windows` a no-op on that clip. The round trip is
the whole argument for the bench, and it is now a test.
Writing the manifest keeps four rules, each because getting it wrong is silent:
2 dp (resolve-windows' fixed point depends on it), the CLI's own formatting
(indent 2 + newline, or a two-number edit is a 600-line diff), tmp+rename under a
lock, and an mtime TOKEN -- a stale one is a 409 with both values, never a silent
overwrite, because the other writer is usually somebody's judgement.
lib/jobs.ts gains what the fetch needs and the build will: per-step timeouts (the
15-minute default catches the accidental hour-long job and would SIGKILL a
19-clip build), process-GROUP kill (build-video shells out, so the thing burning
CPU is a grandchild that child.kill leaves running), a cancel flag, and NDJSON
events kept out of the rolling log.
The bench specs write, so they got their own bench-fixture: sharing one project
with the read-only index and decision specs made the suite pass or fail on which
file playwright ran first, and the failure named the wrong thing entirely.
e2e: 128 passed. The one red is undo.spec:93, the documented load-dependent
flake, which passes in isolation.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Diffstat:
18 files changed, 1683 insertions(+), 26 deletions(-)
diff --git a/umtool/app/api/report/clip/route.ts b/umtool/app/api/report/clip/route.ts
@@ -0,0 +1,75 @@
+import path from "node:path";
+import { resolveClip, windowsFor } from "@/lib/report/serve.mjs";
+import { manifestToken } from "@/lib/report/manifest.mjs";
+import { cuesInWindow, readClipDetail } from "@/lib/projects/report.mjs";
+
+export const dynamic = "force-dynamic";
+
+// Everything the bench needs to open a clip, in one request.
+//
+// The window, the cached source files it can draw from, the cues around it, and
+// the manifest's mtime as a WRITE TOKEN -- handed out here so a save can be
+// refused when somebody (a `resolve-windows --write`, an agent, another tab)
+// has written in between.
+export async function GET(request: Request) {
+ const url = new URL(request.url);
+ const projectId = url.searchParams.get("project") ?? "";
+ const clipId = url.searchParams.get("clip") ?? "";
+
+ const r = await resolveClip(projectId, clipId);
+ if ("error" in r) return Response.json({ error: r.error }, { status: r.status });
+ const { project, manifest, clip } = r;
+
+ const windows = await windowsFor(project, clip);
+ const detail = await readClipDetail(project.dir, { manifest });
+ const entry = detail?.entries.find((e: { id: string }) => e.id === clipId) ?? null;
+
+ // The view is the widest cached file when there is one, else a generous span
+ // around the window so the waveform is not a blank rectangle before a fetch.
+ const widest = windows[0] ?? null;
+ const view = widest
+ ? { from: widest.from, to: widest.to }
+ : { from: Math.max(0, clip.start - 20), to: clip.end + 20 };
+
+ const cues = await cuesInWindow(project.dir, clipId, view.from, view.to);
+
+ return Response.json(
+ {
+ project: project.id,
+ dir: project.dir,
+ clip: {
+ id: clip.id,
+ video: clip.video,
+ channel: entry?.channel ?? null,
+ start: clip.start,
+ end: clip.end,
+ cite: clip.cite ?? null,
+ quote: clip.quote ?? null,
+ note: clip.note ?? null,
+ lock: !!clip.lock,
+ lockStart: !!clip.lockStart,
+ lockEnd: !!clip.lockEnd,
+ },
+ view,
+ windows: windows.map((w: { name: string; from: number; to: number }) => ({
+ name: w.name,
+ from: w.from,
+ to: w.to,
+ })),
+ // What resolve-windows WOULD do, computed in-process because widen() is
+ // pure once the cues are read. "Run the widener and see" stops being a
+ // leap of faith.
+ proposed: entry?.proposed ?? null,
+ endsSentence: entry?.endsSentence ?? null,
+ noPunctuation: entry?.noPunctuation ?? false,
+ sourceDuration: entry?.duration ?? null,
+ segment: entry?.segment ?? null,
+ cues: cues?.cues ?? [],
+ punctuationRate: cues?.punctuationRate ?? null,
+ token: await manifestToken(project.dir),
+ fetchPad: manifest.render?.fetchPad ?? 3,
+ outLabel: path.posix.join(project.id, "out"),
+ },
+ { headers: { "cache-control": "no-store" } },
+ );
+}
diff --git a/umtool/app/api/report/cues/route.ts b/umtool/app/api/report/cues/route.ts
@@ -0,0 +1,26 @@
+import { resolveClip } from "@/lib/report/serve.mjs";
+import { cuesInWindow } from "@/lib/projects/report.mjs";
+
+export const dynamic = "force-dynamic";
+
+// The cues around a clip, so "what am I cutting off" is READ rather than
+// inferred from a waveform. Each carries whether it closes a sentence, computed
+// with the same regex resolve-windows.mjs uses -- imported, not re-written, so
+// the rail and the widener can never disagree about where a sentence ends.
+export async function GET(request: Request) {
+ const url = new URL(request.url);
+ const projectId = url.searchParams.get("project") ?? "";
+ const clipId = url.searchParams.get("clip") ?? "";
+ const from = Number(url.searchParams.get("from"));
+ const to = Number(url.searchParams.get("to"));
+ if (!Number.isFinite(from) || !Number.isFinite(to) || to <= from) {
+ return Response.json({ error: "bad window" }, { status: 400 });
+ }
+
+ const r = await resolveClip(projectId, clipId);
+ if ("error" in r) return Response.json({ error: r.error }, { status: r.status });
+
+ const cues = await cuesInWindow(r.project.dir, clipId, from, to);
+ if (!cues) return Response.json({ error: "no cue file for this source" }, { status: 404 });
+ return Response.json(cues, { headers: { "cache-control": "no-store" } });
+}
diff --git a/umtool/app/api/report/fetch/route.ts b/umtool/app/api/report/fetch/route.ts
@@ -0,0 +1,49 @@
+import { getJob, jobView, runningJob, startJob } from "@/lib/jobs";
+import { fetchSteps } from "@/lib/report/driver.mjs";
+import { resolveClip } from "@/lib/report/serve.mjs";
+
+export const dynamic = "force-dynamic";
+
+// "Fetch 20s more", from the clip bench.
+//
+// A DRAG NEVER DOWNLOADS. Dragging past the cached window clamps and offers this
+// button instead, because a handle that silently starts a 12-second network
+// fetch is a handle you stop trusting. It runs the pipeline's own fetch path
+// (build-video.mjs --fetch-only), so the file it writes is named the way the
+// build expects, gets the same format pin, and inherits the Rumble HLS retry --
+// and containing-window reuse then makes this generous fetch BE the build's
+// cache rather than a second one.
+
+const MAX_PAD = 120;
+
+export async function POST(request: Request) {
+ const body = (await request.json().catch(() => ({}))) as Record<string, unknown>;
+ const projectId = String(body.project ?? "");
+ const clipId = String(body.clip ?? "");
+ const pad = Math.max(1, Math.min(MAX_PAD, Number(body.pad ?? 20)));
+
+ const r = await resolveClip(projectId, clipId);
+ if ("error" in r) return Response.json({ error: r.error }, { status: r.status });
+
+ const running = runningJob();
+ if (running) {
+ return Response.json(
+ { error: `a job is already running (${running.kind})`, job: jobView(running) },
+ { status: 409 },
+ );
+ }
+
+ const job = startJob(`fetch ${projectId}/${clipId}`, fetchSteps(r.project, clipId, pad));
+ return Response.json({ job: jobView(job) }, { status: 202 });
+}
+
+export async function GET(request: Request) {
+ const url = new URL(request.url);
+ const id = url.searchParams.get("job");
+ const job = id ? getJob(id) : runningJob();
+ if (!job) return Response.json({ job: null }, { headers: { "cache-control": "no-store" } });
+ return Response.json(
+ { job: jobView(job, Number(url.searchParams.get("since") ?? 0), Number(url.searchParams.get("sinceEvent") ?? 0)) },
+ { headers: { "cache-control": "no-store" } },
+ );
+}
diff --git a/umtool/app/api/report/peaks/route.ts b/umtool/app/api/report/peaks/route.ts
@@ -0,0 +1,64 @@
+import { absOf, pickWindow, resolveClip, windowsFor } from "@/lib/report/serve.mjs";
+import { ENV_RATE, analyseMedia } from "@/lib/media";
+
+export const dynamic = "force-dynamic";
+
+// The envelope of a cached source window, in ABSOLUTE SOURCE SECONDS.
+//
+// Everything in the bench is in source seconds -- the manifest's, the cues',
+// the QR's. The file's own start is the only relative number, and it is added
+// here so nothing downstream has to remember to.
+//
+// analyseMedia decodes once and caches to MIX_CACHE keyed by mtime and size. A
+// raw clip's window is IN ITS NAME, so the file is immutable and that cache can
+// never miss twice for the same window.
+
+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 Response.json({ error: r.error }, { status: r.status });
+
+ const windows = await windowsFor(r.project, r.clip);
+ const win = pickWindow(windows, url.searchParams.get("file"));
+ if (!win) return Response.json({ error: "no cached window for this clip" }, { status: 404 });
+ const abs = absOf(win);
+ if (!abs) return Response.json({ error: "outside the roots" }, { status: 400 });
+
+ const buckets = Math.max(64, Math.min(4000, Number(url.searchParams.get("n") ?? 1200)));
+
+ let analysis;
+ try {
+ analysis = await analyseMedia(abs);
+ } catch (e) {
+ return Response.json({ error: e instanceof Error ? e.message : String(e) }, { status: 500 });
+ }
+
+ const env = analysis.peak;
+ const n = env.length;
+ const min: number[] = [];
+ const max: number[] = [];
+ for (let b = 0; b < buckets; b += 1) {
+ const i0 = Math.floor((b / buckets) * n);
+ const i1 = Math.max(i0 + 1, Math.floor(((b + 1) / buckets) * n));
+ let p = 0;
+ for (let i = i0; i < i1 && i < n; i += 1) if (env[i] > p) p = env[i];
+ max.push(p);
+ // analyseMedia's envelope is positive-only. Mirroring it is what makes the
+ // canvas draw the symmetric shape a waveform is expected to be, rather than
+ // a row of upward spikes.
+ min.push(-p);
+ }
+
+ return Response.json(
+ {
+ from: win.from,
+ to: win.from + (n / ENV_RATE),
+ duration: analysis.duration,
+ fetchStart: win.from,
+ window: win.name,
+ min,
+ max,
+ },
+ { headers: { "cache-control": "private, max-age=3600" } },
+ );
+}
diff --git a/umtool/app/api/report/raw/route.ts b/umtool/app/api/report/raw/route.ts
@@ -0,0 +1,76 @@
+import { createReadStream } from "node:fs";
+import { stat } from "node:fs/promises";
+import { Readable } from "node:stream";
+import { absOf, pickWindow, resolveClip, windowsFor } from "@/lib/report/serve.mjs";
+
+export const dynamic = "force-dynamic";
+
+// The cached source window, served WHOLE with byte ranges.
+//
+// The alternative -- an ffmpeg slice per requested window, like
+// /api/clip/[key]/video does -- would put an encode in front of every drag. The
+// file is small (one clip plus its pad), immutable (its window is in its name),
+// and the browser only needs to be told where to seek: the bench sets
+// currentTime = t - fetchStart and windows entirely on the client.
+//
+// Range support is not optional for that. 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 windows = await windowsFor(r.project, r.clip);
+ // A member of the server's own scan, never a path from the client.
+ const win = pickWindow(windows, url.searchParams.get("file"));
+ if (!win) return new Response("no cached window for this clip", { status: 404 });
+ const abs = absOf(win);
+ if (!abs) return new Response("outside the roots", { status: 400 });
+
+ const st = await stat(abs).catch(() => null);
+ if (!st) return new Response("gone", { status: 404 });
+
+ const headers: Record<string, string> = {
+ "content-type": "video/mp4",
+ "accept-ranges": "bytes",
+ // The file is immutable -- rename the window and it is a different file --
+ // so it may be cached hard. That is what makes dragging feel instant.
+ "cache-control": "private, max-age=3600, immutable",
+ // The absolute source second the file starts at, so the client can convert
+ // without a second request.
+ "x-fetch-start": String(win.from),
+ "x-fetch-end": String(win.to),
+ "x-window": win.name,
+ };
+
+ const range = request.headers.get("range");
+ const m = range ? /^bytes=(\d*)-(\d*)$/.exec(range.trim()) : null;
+ if (m) {
+ const size = st.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);
+ const stream = createReadStream(abs, { start, end });
+ return new Response(Readable.toWeb(stream) as ReadableStream, {
+ status: 206,
+ headers: {
+ ...headers,
+ "content-range": `bytes ${start}-${end}/${size}`,
+ "content-length": String(end - start + 1),
+ },
+ });
+ }
+
+ return new Response(Readable.toWeb(createReadStream(abs)) as ReadableStream, {
+ headers: { ...headers, "content-length": String(st.size) },
+ });
+}
diff --git a/umtool/app/api/report/window/route.ts b/umtool/app/api/report/window/route.ts
@@ -0,0 +1,52 @@
+import { StaleToken, updateClip } from "@/lib/report/manifest.mjs";
+import { resolveClip } from "@/lib/report/serve.mjs";
+
+export const dynamic = "force-dynamic";
+
+// Saving a window.
+//
+// The client sends a project id, a clip id, numbers, and the TOKEN it was given
+// when it opened the clip. It never sends a path, and it cannot ask for an
+// entry to move: re-ordering recomputes `sectionEnter` and changes the cut, so
+// it is a different operation with a different button.
+//
+// A stale token is a 409 with both values, not a silent overwrite. The other
+// writer is usually `resolve-windows --write` or an agent running `umtool`, and
+// what it wrote is somebody's judgement.
+export async function PUT(request: Request) {
+ let body: Record<string, unknown>;
+ try {
+ body = await request.json();
+ } catch {
+ return Response.json({ error: "expected JSON" }, { status: 400 });
+ }
+
+ const projectId = String(body.project ?? "");
+ const clipId = String(body.clip ?? "");
+ const r = await resolveClip(projectId, clipId);
+ if ("error" in r) return Response.json({ error: r.error }, { status: r.status });
+
+ const patch: Record<string, unknown> = {};
+ for (const k of ["start", "end", "lock", "lockStart", "lockEnd", "note"]) {
+ if (body[k] !== undefined) patch[k] = body[k];
+ }
+ if (!Object.keys(patch).length) return Response.json({ error: "nothing to change" }, { status: 400 });
+
+ try {
+ const res = await updateClip(r.project.dir, clipId, patch, {
+ token: body.token === undefined ? null : String(body.token),
+ });
+ return Response.json(
+ { ok: true, entry: res.entry, before: res.before, token: res.token },
+ { headers: { "cache-control": "no-store" } },
+ );
+ } catch (e) {
+ if (e instanceof StaleToken) {
+ return Response.json(
+ { error: e.message, expected: e.expected, got: e.got, stale: true },
+ { status: 409 },
+ );
+ }
+ return Response.json({ error: e instanceof Error ? e.message : String(e) }, { status: 400 });
+ }
+}
diff --git a/umtool/components/projects/ClipBench.tsx b/umtool/components/projects/ClipBench.tsx
@@ -0,0 +1,534 @@
+"use client";
+
+import { useCallback, useEffect, useRef, useState } from "react";
+import Link from "next/link";
+import Waveform, { type Peaks } from "@/components/Waveform";
+import { badgeVariants } from "@/components/ui/badge";
+import { buttonVariants } from "@/components/ui/button";
+
+// ---------------------------------------------------------------------------
+// Editing a clip's window against the audio and the words.
+//
+// "How much context does this clip need" was a loop of hand-editing JSON,
+// re-running two CLIs and watching an mp4. This is that loop, in one place.
+//
+// EVERYTHING IS IN ABSOLUTE SOURCE SECONDS -- the manifest's, the cue file's,
+// the QR's. The cached file's own start (`fetchStart`) is the only relative
+// number in the whole component, and it exists solely to set video.currentTime.
+// The moment those two are allowed to mix is the moment a window is off by the
+// pad and nobody can see why.
+//
+// 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.
+// ---------------------------------------------------------------------------
+
+type Cue = { start: number; end: number; text: string; endsSentence: boolean };
+type Win = { name: string; from: number; to: number };
+
+type Clip = {
+ id: string;
+ video: string;
+ channel: string | null;
+ start: number;
+ end: number;
+ cite: number | null;
+ quote: string | null;
+ note: string | null;
+ lock: boolean;
+ lockStart: boolean;
+ lockEnd: boolean;
+};
+
+export type ClipBenchData = {
+ project: string;
+ clip: Clip;
+ view: { from: number; to: number };
+ windows: Win[];
+ proposed: { start: number; end: number } | null;
+ endsSentence: boolean | null;
+ noPunctuation: boolean;
+ sourceDuration: number | null;
+ segment: string | null;
+ cues: Cue[];
+ token: string | null;
+ fetchPad: number;
+};
+
+const hms = (t: number) => {
+ const s = Math.max(0, t);
+ const m = Math.floor(s / 60);
+ const sec = s - m * 60;
+ const h = Math.floor(m / 60);
+ return h > 0
+ ? `${h}:${String(m % 60).padStart(2, "0")}:${sec.toFixed(2).padStart(5, "0")}`
+ : `${m}:${sec.toFixed(2).padStart(5, "0")}`;
+};
+
+const round2 = (n: number) => Number(n.toFixed(2));
+
+export default function ClipBench({ data }: { data: ClipBenchData }) {
+ const [clip, setClip] = useState<Clip>(data.clip);
+ const [token, setToken] = useState(data.token);
+ const [windows, setWindows] = useState<Win[]>(data.windows);
+ const [cues, setCues] = useState<Cue[]>(data.cues);
+ const [proposed, setProposed] = useState(data.proposed);
+ const [view, setView] = useState(data.view);
+ const [sel, setSel] = useState({ from: data.clip.start, to: data.clip.end });
+ const [peaks, setPeaks] = useState<Peaks | null>(null);
+ const [playhead, setPlayhead] = useState<number | null>(null);
+ const [note, setNote] = useState<string | null>(null);
+ const [busy, setBusy] = useState<string | null>(null);
+ const [dirty, setDirty] = useState(false);
+
+ const video = useRef<HTMLVideoElement | null>(null);
+ const stopAt = useRef<number | 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.
+ const cached = windows[0] ?? null;
+ const fetchStart = cached?.from ?? 0;
+ const cachedTo = cached?.to ?? 0;
+
+ // ---- peaks --------------------------------------------------------------
+ useEffect(() => {
+ if (!cached) return;
+ let live = true;
+ void (async () => {
+ const r = await fetch(
+ `/api/report/peaks?project=${encodeURIComponent(data.project)}&clip=${encodeURIComponent(clip.id)}&file=${encodeURIComponent(cached.name)}`,
+ { cache: "no-store" },
+ );
+ if (!r.ok || !live) return;
+ setPeaks((await r.json()) as Peaks);
+ })();
+ return () => {
+ live = false;
+ };
+ }, [cached, data.project, clip.id]);
+
+ // ---- audition -----------------------------------------------------------
+ const play = useCallback(
+ (from: number, to: number) => {
+ const el = video.current;
+ if (!el || !cached) return;
+ el.currentTime = Math.max(0, from - fetchStart);
+ stopAt.current = to;
+ void el.play();
+ },
+ [cached, fetchStart],
+ );
+
+ useEffect(() => {
+ const el = video.current;
+ if (!el) return;
+ const tick = () => {
+ const t = el.currentTime + fetchStart;
+ setPlayhead(t);
+ if (stopAt.current != null && t >= stopAt.current) {
+ el.pause();
+ stopAt.current = null;
+ }
+ };
+ el.addEventListener("timeupdate", tick);
+ return () => el.removeEventListener("timeupdate", tick);
+ }, [fetchStart]);
+
+ // ---- the selection ------------------------------------------------------
+ //
+ // Clamped to the cached file. Past its edge the handle stops and the "fetch
+ // more" button appears; past the source's own duration it stops for good.
+ const clampTo = Math.min(cachedTo || Infinity, data.sourceDuration ?? Infinity);
+ const onSel = useCallback(
+ (next: { from: number; to: number }, dragging: boolean) => {
+ const from = Math.max(fetchStart, next.from);
+ const to = Math.min(clampTo, next.to);
+ setSel({ from, to });
+ setDirty(true);
+ // Audition on pointer-UP, never during a drag: the Deck's rule, and the
+ // reason is that a sound restarting on every pointermove is unusable.
+ if (!dragging) play(from, Math.min(to, from + 6));
+ },
+ [fetchStart, clampTo, play],
+ );
+
+ const atStartEdge = sel.from <= fetchStart + 0.05 && fetchStart > 0;
+ 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;
+ default:
+ return;
+ }
+ e.preventDefault();
+ };
+ window.addEventListener("keydown", onKey);
+ return () => window.removeEventListener("keydown", onKey);
+ }, [sel, clip.start, clip.end, onSel, play]);
+
+ // ---- saving -------------------------------------------------------------
+ const save = useCallback(
+ async (patch: Partial<Clip> & { start?: number; end?: number }) => {
+ setBusy("saving…");
+ setNote(null);
+ const r = await fetch("/api/report/window", {
+ method: "PUT",
+ headers: { "content-type": "application/json" },
+ body: JSON.stringify({ project: data.project, clip: clip.id, token, ...patch }),
+ });
+ const j = (await r.json()) as Record<string, unknown>;
+ setBusy(null);
+ if (!r.ok) {
+ setNote(
+ j.stale
+ ? "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)}`,
+ );
+ return false;
+ }
+ const entry = j.entry as Clip;
+ setClip((c) => ({ ...c, ...entry }));
+ setSel({ from: entry.start, to: entry.end });
+ 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],
+ );
+
+ const refresh = useCallback(async () => {
+ const r = await fetch(
+ `/api/report/clip?project=${encodeURIComponent(data.project)}&clip=${encodeURIComponent(clip.id)}`,
+ { cache: "no-store" },
+ );
+ if (!r.ok) return;
+ const j = (await r.json()) as ClipBenchData;
+ setWindows(j.windows);
+ setCues(j.cues);
+ setProposed(j.proposed);
+ setToken(j.token);
+ if (j.windows[0]) setView({ from: j.windows[0].from, to: j.windows[0].to });
+ }, [data.project, clip.id]);
+
+ // ---- fetching more --------------------------------------------------------
+ const fetchMore = useCallback(async () => {
+ setBusy("fetching a wider window…");
+ setNote(null);
+ const r = await fetch("/api/report/fetch", {
+ method: "POST",
+ headers: { "content-type": "application/json" },
+ body: JSON.stringify({ project: data.project, clip: clip.id, pad: 20 }),
+ });
+ if (!r.ok) {
+ const j = (await r.json()) as { error?: string };
+ setBusy(null);
+ setNote(j.error ?? "could not start the fetch");
+ return;
+ }
+ const { job } = (await r.json()) as { job: { id: string } };
+ // Poll rather than stream: one 12-second job, and a dead poll is harmless.
+ for (let i = 0; i < 240; i += 1) {
+ await new Promise((res) => setTimeout(res, 500));
+ const s = await fetch(`/api/report/fetch?job=${job.id}`, { cache: "no-store" });
+ const sj = (await s.json()) as { job: { state: string; error: string | null } | null };
+ if (!sj.job || sj.job.state === "running") continue;
+ setBusy(null);
+ if (sj.job.state === "failed") setNote(`the fetch failed: ${sj.job.error ?? "unknown"}`);
+ else {
+ setNote("fetched — the build will reuse this file, not download it again");
+ await refresh();
+ }
+ return;
+ }
+ setBusy(null);
+ setNote("the fetch is still running; 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;
+ const midSentence = endsSentence === false && !clip.lockEnd && !clip.lock;
+
+ // Moving an edge somewhere widen() would not produce means the next
+ // `resolve-windows --write` reverts it. Offering the matching lock is what
+ // stops that being a silent loss -- and is why five of six real manifests are
+ // 100% locked.
+ const wouldRevert =
+ !clip.lock &&
+ proposed != null &&
+ (Math.abs(proposed.start - round2(sel.from)) > 0.05 ||
+ Math.abs(proposed.end - round2(sel.to)) > 0.05);
+
+ const span = Math.max(1e-6, view.to - view.from);
+ const pct = (t: number) => `${((t - view.from) / span) * 100}%`;
+
+ return (
+ <div className="space-y-3" data-bench={clip.id}>
+ {/* ---- the picture ---- */}
+ <div className="grid gap-3 lg:grid-cols-[minmax(0,1fr)_320px]">
+ <div>
+ {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"
+ 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)]">
+ 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)]">
+ {hms(sel.from)} – {hms(sel.to)}{" "}
+ <span className="text-[var(--color-dim)]">({(sel.to - sel.from).toFixed(2)}s)</span>
+ </div>
+ {dirty && (
+ <div className="text-[var(--color-dirty)]">
+ unsaved — was {hms(clip.start)} – {hms(clip.end)}
+ </div>
+ )}
+ </div>
+
+ <div className="flex flex-wrap 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>
+ </div>
+
+ <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
+ </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>
+
+ {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)]">
+ {note}
+ </div>
+ )}
+ </div>
+ </div>
+
+ {/* ---- the warnings, before the instrument ---- */}
+ {midSentence && (
+ <p
+ data-warn="mid-sentence"
+ className="rounded border border-[var(--color-dirty)] bg-[color-mix(in_srgb,var(--color-dirty)_10%,transparent)] px-3 py-1.5 text-[12px] text-[var(--color-dirty)]"
+ >
+ This ends mid-sentence — the cut lands inside “…
+ {endCue?.text.trim().slice(-56)}”. Run it to the end of the sentence, or set{" "}
+ <code className="font-mono">lockEnd</code> to say you meant to cut here.
+ </p>
+ )}
+ {data.noPunctuation && (
+ <p
+ data-warn="no-punctuation"
+ className="rounded border border-[var(--color-line)] px-3 py-1.5 text-[12px] text-[var(--color-dim)]"
+ >
+ This upload’s ASR carries no punctuation, so there are no sentence boundaries to
+ find and widening cannot help. Set these edges by ear and lock them.
+ </p>
+ )}
+ {wouldRevert && (
+ <p
+ data-warn="would-revert"
+ className="rounded border border-[var(--color-dirty)] px-3 py-1.5 text-[12px] text-[var(--color-dirty)]"
+ >
+ <code className="font-mono">resolve-windows --write</code> would make this{" "}
+ {hms(proposed!.start)} – {hms(proposed!.end)}, reverting your edit. Set the matching lock
+ if this window is deliberate.{" "}
+ <button
+ type="button"
+ className={buttonVariants({ variant: "primary", size: "sm" })}
+ onClick={() =>
+ void save({
+ start: round2(sel.from),
+ end: round2(sel.to),
+ lockStart: Math.abs(proposed!.start - round2(sel.from)) > 0.05,
+ lockEnd: Math.abs(proposed!.end - round2(sel.to)) > 0.05,
+ })
+ }
+ >
+ save and lock
+ </button>
+ </p>
+ )}
+
+ {/* ---- the waveform ---- */}
+ <Waveform
+ view={view}
+ sel={sel}
+ cand={{ from: clip.start, to: clip.end }}
+ words={cues.map((c) => ({ start: c.start, end: c.end, w: c.text.slice(0, 24) }))}
+ peaks={peaks}
+ playhead={playhead}
+ onSel={onSel}
+ onReachEdge={() => {
+ /* widening is a FETCH here, not a redraw -- see the button below */
+ }}
+ />
+
+ {(atStartEdge || atEndEdge) && (
+ <div className="flex items-center gap-2 text-[12px]">
+ <span className="text-[var(--color-dirty)]">
+ that is the edge of what is cached{cached ? ` (${cached.name})` : ""}
+ </span>
+ <button
+ type="button"
+ className={buttonVariants({ variant: "primary", size: "sm" })}
+ disabled={!!busy}
+ onClick={() => void fetchMore()}
+ >
+ fetch 20s more
+ </button>
+ </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>
+
+ {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>}
+ {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
@@ -0,0 +1,86 @@
+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 { 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
+// it over. The clip id is validated as a MEMBER of the timeline, never as a
+// path -- the same rule every other name that crosses the wire here follows.
+
+export default async function ClipBenchPage({
+ project,
+ clipId,
+}: {
+ project: ProjectRef;
+ clipId: string;
+}) {
+ const manifest = await readManifest(project.dir);
+ if (!manifest) notFound();
+
+ const detail = await readClipDetail(project.dir, { manifest });
+ if (!detail) notFound();
+ const entry = detail.entries.find(
+ (e: { id: string; kind: string }) => e.id === clipId && e.kind === "clip",
+ );
+ if (!entry) notFound();
+
+ const windows = await windowsFor(project, entry);
+ const widest = windows[0] ?? null;
+ const view = widest
+ ? { from: widest.from, to: widest.to }
+ : { from: Math.max(0, entry.start - 20), to: entry.end + 20 };
+ const cues = await cuesInWindow(project.dir, clipId, view.from, view.to);
+
+ const data: ClipBenchData = {
+ project: project.id,
+ clip: {
+ id: entry.id,
+ video: entry.video,
+ channel: entry.channel ?? null,
+ start: entry.start,
+ end: entry.end,
+ cite: entry.cite ?? null,
+ quote: entry.quote ?? null,
+ note: entry.note ?? null,
+ lock: !!entry.lock,
+ lockStart: !!entry.lockStart,
+ lockEnd: !!entry.lockEnd,
+ },
+ view,
+ windows: windows.map((w: { name: string; from: number; to: number }) => ({
+ name: w.name,
+ from: w.from,
+ to: w.to,
+ })),
+ proposed: entry.proposed ?? null,
+ endsSentence: entry.endsSentence ?? null,
+ noPunctuation: !!entry.noPunctuation,
+ sourceDuration: entry.duration ?? null,
+ segment: entry.segment ?? null,
+ cues: cues?.cues ?? [],
+ token: await manifestToken(project.dir),
+ fetchPad: manifest.render?.fetchPad ?? 3,
+ };
+
+ 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
+ crumbs={[
+ { href: "/browse", label: "projects" },
+ { href: `/browse/${project.id}`, label: project.name },
+ { label: clipId },
+ ]}
+ note={`clip ${i + 1} of ${clips.length} · ${entry.video}`}
+ />
+ <main className="deck-main flex-1 p-4">
+ <ClipBench data={data} />
+ </main>
+ </div>
+ );
+}
diff --git a/umtool/components/projects/ProjectView.tsx b/umtool/components/projects/ProjectView.tsx
@@ -3,6 +3,7 @@ import type { ProjectRef } from "@/lib/project-types";
import SongProject from "./SongProject";
import CutPage from "./CutPage";
import ReportProject from "./ReportProject";
+import ClipBenchPage from "./ClipBenchPage";
import SweepProject from "./SweepProject";
// ---------------------------------------------------------------------------
@@ -37,8 +38,11 @@ export default async function ProjectView({
}
case "report-video": {
if (rest.length === 0) return <ReportProject project={project} search={search} />;
- // The clip bench lands here in its own phase; until then an unknown view
- // is a 404 rather than a page that silently drops the rest of the URL.
+ // `/browse/<project>/clip/<id>` -- the bench. A view the kind does not
+ // declare is a 404 rather than a page that silently drops half its URL.
+ if (rest.length === 2 && rest[0] === "clip") {
+ return <ClipBenchPage project={project} clipId={rest[1]} />;
+ }
return notFound();
}
case "sweep-report": {
diff --git a/umtool/components/projects/ReportProject.tsx b/umtool/components/projects/ReportProject.tsx
@@ -201,7 +201,14 @@ export default async function ReportProject({
widener would move it
</Pill>
)}
- <span className="micro ml-auto">§{e.section ?? 0}</span>
+ <Link
+ href={`/browse/${project.id}/clip/${e.id}`}
+ data-bench-link={e.id}
+ className="ml-auto text-[11px] text-[var(--color-sel)] hover:underline"
+ >
+ bench →
+ </Link>
+ <span className="micro">§{e.section ?? 0}</span>
</div>
{(showAll || midSentence || e.proposed) && e.quote && (
<p className="mt-1 text-[11px] leading-snug text-[var(--color-dim)]">
diff --git a/umtool/e2e/clip-bench.spec.ts b/umtool/e2e/clip-bench.spec.ts
@@ -0,0 +1,209 @@
+import { test, expect } from "@playwright/test";
+import { execFileSync } from "node:child_process";
+import { readFileSync, existsSync } from "node:fs";
+import path from "node:path";
+import { fileURLToPath } from "node:url";
+
+// ---------------------------------------------------------------------------
+// The clip bench.
+//
+// The fixture is built so every answer here is exact rather than plausible:
+//
+// vid1 is PUNCTUATED and carries a run-on cue at 3-6s. So c01 (3.00-6.00)
+// ends mid-sentence, and widen() must carry its end to 9.00 -- the next cue
+// that closes one. c02 (9.00-12.00) ends on a full stop. c04 ends inside a
+// run-on cue too but sets lockEnd, which is the author saying "I meant to cut
+// here" and must silence the warning.
+//
+// vid2 has no terminator anywhere, which is the real degradation in this
+// corpus. c03's edges cannot be judged against sentences at all, and the
+// bench has to SAY that rather than quietly claiming the cut is fine.
+//
+// vid1_0.00-9.00.mp4 is tone / silence / tone / silence / tone with the
+// silences centred on 3.0s and 6.0s. Verified in the file itself:
+// silencedetect reports 2.90-3.11 and 5.92-6.11.
+// ---------------------------------------------------------------------------
+
+const HERE = path.dirname(fileURLToPath(import.meta.url));
+const UMTOOL = path.join(HERE, "..");
+const FIXTURE = path.join(UMTOOL, ".e2e-song");
+// bench-fixture, NOT report-fixture. Every test in this file WRITES, and the
+// index and decision specs assert what report-fixture's windows are -- sharing
+// one project made the suite pass or fail on which spec file ran first.
+const PROJECT = "reports/bench-fixture";
+const MANIFEST = path.join(FIXTURE, "reports", "bench-fixture", "video.manifest.json");
+const bench = (clip: string) => `/browse/${PROJECT}/clip/${clip}`;
+
+const readClip = (id: string) => {
+ const m = JSON.parse(readFileSync(MANIFEST, "utf8")) as {
+ timeline: { id: string; start: number; end: number; lockEnd?: boolean }[];
+ };
+ return m.timeline.find((e) => e.id === id)!;
+};
+
+const token = async (request: { get: (u: string) => Promise<{ json: () => Promise<unknown> }> }, clip: string) => {
+ const r = await request.get(`/api/report/clip?project=${encodeURIComponent(PROJECT)}&clip=${clip}`);
+ return (await r.json()) as { token: string };
+};
+
+test("the bench opens on the cached window, with the clip inside it", async ({ page }) => {
+ await page.goto(bench("c01"));
+ await expect(page.locator("[data-bench=c01]")).toBeVisible();
+ await expect(page.getByTestId("waveform")).toBeVisible();
+ // The <video> is the cached source window served whole, not an ffmpeg slice
+ // per drag -- that is what makes dragging free.
+ await expect(page.getByTestId("clip-video")).toHaveAttribute("src", /api\/report\/raw/);
+});
+
+test("a clip that ends mid-sentence says so; lockEnd silences it", async ({ page }) => {
+ await page.goto(bench("c01"));
+ await expect(page.locator("[data-warn=mid-sentence]")).toBeVisible();
+ // The cut lands inside this cue, and quoting it is the point -- "ends
+ // mid-sentence" alone does not tell you what you are cutting off.
+ await expect(page.locator("[data-warn=mid-sentence]")).toContainText("and because");
+
+ await page.goto(bench("c04"));
+ await expect(page.locator("[data-warn=mid-sentence]")).toHaveCount(0);
+});
+
+test("an unpunctuated source is admitted rather than judged", async ({ page }) => {
+ await page.goto(bench("c03"));
+ // Not "this cut is fine": the question cannot be answered from this source.
+ await expect(page.locator("[data-warn=no-punctuation]")).toBeVisible();
+ await expect(page.locator("[data-warn=mid-sentence]")).toHaveCount(0);
+});
+
+test("the bench predicts what the widener would do before you run it", async ({ page }) => {
+ await page.goto(bench("c01"));
+ const warn = page.locator("[data-warn=would-revert]");
+ await expect(warn).toBeVisible();
+ // 3.00-6.00 widens to 3.00-9.00: the next cue that closes a sentence. Knowing
+ // that BEFORE `resolve-windows --write` is what stops the widener silently
+ // reverting an edit.
+ await expect(warn).toContainText("0:09.00");
+});
+
+test("the cue rail marks the cues that close a sentence", async ({ page }) => {
+ await page.goto(bench("c01"));
+ // vid1: four of its first five cues end in a full stop; the run-on one does not.
+ await expect(page.locator("[data-cue][data-ends-sentence='1']").first()).toBeVisible();
+ const runOn = page.locator("[data-cue='3'][data-ends-sentence='0']");
+ await expect(runOn).toBeVisible();
+});
+
+test("a saved window is stored at 2 dp, and the CLI's formatting survives", async ({ request }) => {
+ const { token: t } = await token(request, "c01");
+ const res = await request.put("/api/report/window", {
+ data: { project: PROJECT, clip: "c01", start: 3.123456, end: 6.987654, token: t },
+ });
+ expect(res.ok()).toBeTruthy();
+
+ // 2 dp is NOT cosmetic: resolve-windows.mjs is a fixed point, and both its EPS
+ // lookup and its 0.05s deadband assume it. 4 dp makes the widener grow the
+ // same clip on every run.
+ const e = readClip("c01");
+ expect(e.start).toBe(3.12);
+ expect(e.end).toBe(6.99);
+
+ // Indent 2 plus a trailing newline, which is what the CLI writes. The default
+ // writeJsonAtomic uses indent 1 and would turn this into a 600-line diff.
+ const raw = readFileSync(MANIFEST, "utf8");
+ expect(raw).toContain('\n "schemaVersion": 1,');
+ expect(raw.endsWith("}\n")).toBe(true);
+
+ // One copy of the last hand-authored state, made on the first write.
+ expect(existsSync(`${MANIFEST}.bak`)).toBe(true);
+});
+
+test("a stale token is refused rather than allowed to overwrite", async ({ request }) => {
+ const { token: t } = await token(request, "c02");
+ const first = await request.put("/api/report/window", {
+ data: { project: PROJECT, clip: "c02", start: 9, end: 11.5, token: t },
+ });
+ expect(first.ok()).toBeTruthy();
+
+ // The same token again: somebody (resolve-windows --write, an agent, another
+ // tab) wrote in between, and what they wrote is a judgement.
+ const second = await request.put("/api/report/window", {
+ data: { project: PROJECT, clip: "c02", start: 9, end: 10, token: t },
+ });
+ expect(second.status()).toBe(409);
+ const j = (await second.json()) as { stale: boolean };
+ expect(j.stale).toBe(true);
+ // And it did NOT write.
+ expect(readClip("c02").end).toBe(11.5);
+});
+
+test("the edit the bench predicts is a FIXED POINT for the widener", async ({ request }) => {
+ const { token: t } = await token(request, "c01");
+ // The value the bench said resolve-windows would produce.
+ await request.put("/api/report/window", {
+ data: { project: PROJECT, clip: "c01", start: 3.0, end: 9.0, token: t },
+ });
+
+ const out = execFileSync(
+ "node",
+ [path.join(UMTOOL, "..", "scripts", "report-to-video", "resolve-windows.mjs"), MANIFEST],
+ { encoding: "utf8", env: { ...process.env, CHANNELS_DIR: path.join(FIXTURE, "channels") } },
+ );
+ // Setting an edge where a sentence actually ends means the next --write is a
+ // no-op on that clip. That round-trip is the whole argument for the bench.
+ const line = out.split("\n").find((l) => l.startsWith("c01"))!;
+ expect(line).toContain("3.0–9.0 -> 3.0–9.0");
+});
+
+test("the raw window serves byte ranges, and refuses a file that is not this clip's", async ({
+ request,
+}) => {
+ const q = `project=${encodeURIComponent(PROJECT)}&clip=c01`;
+ const full = await request.get(`/api/report/raw?${q}`);
+ expect(full.status()).toBe(200);
+ expect(full.headers()["accept-ranges"]).toBe("bytes");
+ // The absolute source second the file starts at, so the client converts
+ // without a second request.
+ expect(full.headers()["x-fetch-start"]).toBe("0");
+
+ const part = await request.get(`/api/report/raw?${q}`, { headers: { Range: "bytes=0-1023" } });
+ // Without a 206 the <video> element will not seek in a stream it did not
+ // fully download, which is the whole interaction.
+ expect(part.status()).toBe(206);
+ expect(part.headers()["content-range"]).toMatch(/^bytes 0-1023\/\d+$/);
+
+ // `file` must be a member of the server's own scan for THIS clip's video.
+ expect((await request.get(`/api/report/raw?${q}&file=vid2_0.00-9.00.mp4`)).status()).toBe(404);
+ expect((await request.get(`/api/report/raw?${q}&file=../../../etc/passwd`)).status()).toBe(404);
+});
+
+test("peaks come back in absolute source seconds, symmetric, with the silences in them", async ({
+ request,
+}) => {
+ const r = await request.get(
+ `/api/report/peaks?project=${encodeURIComponent(PROJECT)}&clip=c01&n=900`,
+ );
+ const j = (await r.json()) as { from: number; fetchStart: number; min: number[]; max: number[] };
+ // Absolute, not relative to the fetched file.
+ expect(j.from).toBe(0);
+ expect(j.fetchStart).toBe(0);
+ // analyseMedia's envelope is positive-only; mirroring it is what makes the
+ // canvas draw a waveform rather than a row of upward spikes.
+ expect(j.min[10]).toBe(-j.max[10]);
+
+ // The synthesised silence at 3.0s. 900 buckets over 9s is one per 10ms.
+ const at = (t: number) => j.max[Math.round((t / 9) * j.max.length)];
+ expect(at(3.0)).toBe(0);
+ expect(at(6.0)).toBe(0);
+ expect(at(1.0)).toBeGreaterThan(0.05);
+});
+
+test("a bench save shows up on the project page", async ({ page, request }) => {
+ const { token: t } = await token(request, "c01");
+ await request.put("/api/report/window", {
+ data: { project: PROJECT, clip: "c01", start: 3, end: 9, lockEnd: true, token: t },
+ });
+
+ await page.goto(`/browse/${PROJECT}`);
+ const row = page.locator("[data-entry=c01]");
+ await expect(row).toContainText("end pinned");
+ // lockEnd is the acknowledgement, so the row's warning goes with it.
+ await expect(row).toHaveAttribute("data-mid-sentence", "0");
+});
diff --git a/umtool/e2e/fixtures/make-fixture.mjs b/umtool/e2e/fixtures/make-fixture.mjs
@@ -719,6 +719,23 @@ writeProject(
]),
);
+// A SECOND copy of the same project, for the specs that WRITE.
+//
+// The clip bench saves windows and sets locks; the index and decision specs
+// assert what report-fixture's windows are. One fixture for both means the
+// suite passes or fails depending on which file playwright happened to run
+// first -- which it did, once, and the failure named the wrong thing entirely.
+// So the read-only assertions get report-fixture and every mutation gets this.
+const BENCH = writeProject(
+ "bench-fixture",
+ manifest("bench-fixture", "The Bench Fixture", { siteOrigin: "https://archive.example" }, [
+ { type: "clip", id: "c01", video: "vid1", start: 3.0, end: 6.0, cite: 3, section: 0, quote: "and because" },
+ { type: "clip", id: "c02", video: "vid1", start: 9.0, end: 12.0, cite: 9, section: 0, quote: "another whole sentence" },
+ { type: "clip", id: "c03", video: "vid2", start: 1.0, end: 4.0, cite: 1, section: 0, quote: "no punctuation" },
+ { type: "clip", id: "c04", video: "vid1", start: 15.0, end: 18.0, cite: 15, section: 0, lockEnd: true, quote: "trailing off" },
+ ]),
+);
+
mkdirSync(path.join(reports, "bike-fixture"), { recursive: true });
writeFileSync(
path.join(reports, "bike-fixture", "sweep-report.md"),
@@ -750,6 +767,11 @@ ff([
"-c:v", "libx264", "-pix_fmt", "yuv420p", "-c:a", "aac", "-ar", "48000", "-ac", "2",
path.join(REPORT, "out", "clips-raw", "vid1_0.00-9.00.mp4"),
]);
+mkdirSync(path.join(BENCH, "out", "clips-raw"), { recursive: true });
+copyFileSync(
+ path.join(REPORT, "out", "clips-raw", "vid1_0.00-9.00.mp4"),
+ path.join(BENCH, "out", "clips-raw", "vid1_0.00-9.00.mp4"),
+);
console.log(`fixture at ${dest}`);
if (planned) console.log(` planned clip (used in a build): ${planned}`);
@@ -768,5 +790,5 @@ console.log(` SONG_REPORTS_DIR=${reports}`);
console.log(` CHANNELS_DIR=${CHANNELS} (testchan/vid1 punctuated, vid2 not)`);
console.log(` projects: report-fixture (4 clips, 1 mid-sentence), no-origin-fixture,`);
console.log(` localhost-fixture, bike-fixture (sweep), find/ (shadowed),`);
-console.log(` deep/nested/solo-fixture (collapse case)`);
+console.log(` deep/nested/solo-fixture (collapse case), bench-fixture (writable)`);
console.log(` ${taken} candidate files copied, 2 mix tracks synthesised`);
diff --git a/umtool/e2e/projects.spec.ts b/umtool/e2e/projects.spec.ts
@@ -30,7 +30,7 @@ const UMTOOL = path.join(HERE, "..");
test("the index lists every kind, and says which state each project is in", async ({ page }) => {
await page.goto("/browse");
- await expect(page.locator("[data-kind='report-video']")).toHaveCount(5);
+ await expect(page.locator("[data-kind='report-video']")).toHaveCount(6);
// NOT an exact count: browse.spec.ts creates a song through /api/browse/init,
// so the number here depends on what else has run. What matters is that the
// kind is present and that the fixture's own songs are in it.
@@ -62,7 +62,7 @@ test("a kind chip filters, and the counts do not move when it does", async ({ pa
await chip.click();
await expect(page).toHaveURL(/kind=report-video/);
await expect(page.locator("[data-kind='song']")).toHaveCount(0);
- await expect(page.locator("[data-kind='report-video']")).toHaveCount(5);
+ await expect(page.locator("[data-kind='report-video']")).toHaveCount(6);
// Counts come from the UNFILTERED set on purpose: a chip whose number changes
// when you click a different chip moves under the cursor.
@@ -106,22 +106,29 @@ test("a clip that ends mid-sentence is reported once, and lockEnd acknowledges i
}) => {
await page.goto("/browse/decisions?kind=clip-mid-sentence");
+ // Scoped to one project rather than counted globally: the bench specs get
+ // their own writable copy of this manifest, so a global count would be
+ // asserting how many fixtures exist rather than what the rule does.
+ //
// c01 ends inside "And this one runs on and because". c04 ends inside
// "trailing off and then" but sets lockEnd -- which is the author saying "I
// meant to cut here", so it must NOT appear.
- await expect(page.locator("[data-decision='clip-mid-sentence']")).toHaveCount(1);
- await expect(page.locator("[data-decision='clip-mid-sentence']")).toHaveAttribute(
- "data-target",
- "c01",
+ const rows = page.locator(
+ "[data-project='reports/report-fixture'] [data-decision='clip-mid-sentence']",
);
+ await expect(rows).toHaveCount(1);
+ await expect(rows).toHaveAttribute("data-target", "c01");
});
test("an unpunctuated source is said ONCE, not once per clip", async ({ page }) => {
await page.goto("/browse/decisions?kind=no-punctuation");
- // vid2 has no terminator anywhere. Six real projects produced forty-odd of
- // these rows before they were collapsed, which is an inbox whose blocking
- // rows have scrolled off the top.
- await expect(page.locator("[data-decision='no-punctuation']")).toHaveCount(1);
+ // vid2 has no terminator anywhere, and one clip cites it. Six real projects
+ // produced forty-odd of these rows before they were collapsed, which is an
+ // inbox whose blocking rows have scrolled off the top -- so the assertion is
+ // ONE row per project, not one per source.
+ await expect(
+ page.locator("[data-project='reports/report-fixture'] [data-decision='no-punctuation']"),
+ ).toHaveCount(1);
});
test("a project named for a tool page is BLOCKING, and the tool page still wins", async ({
diff --git a/umtool/lib/jobs.ts b/umtool/lib/jobs.ts
@@ -31,6 +31,12 @@ export type Job = {
steps: Step[];
stepIndex: number;
log: string[];
+ /** NDJSON progress from steps that emit it. See push(). */
+ events: Record<string, unknown>[];
+ /** Every child pid started, so a stray one can be named after the fact. */
+ pids: number[];
+ /** Set by cancel(); the running step notices and kills its group. */
+ cancelling: boolean;
error: string | null;
};
@@ -48,14 +54,35 @@ export const recentJobs = (n = 10) =>
[...jobs.values()].sort((a, b) => b.startedAt - a.startedAt).slice(0, n);
/** The accidental-hour-long-job guard. None of the .sh builds fits under any
- * cap worth setting, which is the other reason they stay out. */
+ * cap worth setting, which is the other reason they stay out.
+ *
+ * A step may ask for more (Step.timeoutMs). A 19-clip crossfaded report build
+ * runs 20 to 40 minutes and would otherwise be SIGKILLed at 15 -- but raising
+ * this for everything would remove the guard from the jobs that need it. */
const STEP_TIMEOUT_MS = 15 * 60 * 1000;
+/** How long a killed process group gets to go quietly before SIGKILL. */
+const KILL_GRACE_MS = 5000;
/** Mirrors mix's stderr clamp: enough to diagnose, not enough to blow up RAM. */
const LOG_LIMIT = 400;
+/** One build emits a handful of events per clip; this is generous for any of them. */
+const EVENT_LIMIT = 2000;
-function push(job: Job, line: string) {
+function push(job: Job, line: string, ndjson = false) {
for (const l of line.split("\n")) {
if (!l.trim()) continue;
+ // A step declared as NDJSON emits one JSON object per line. They are kept
+ // separately so the UI can render per-clip state from them, and kept OUT of
+ // the rolling log so twenty clips of events cannot push the command that
+ // started the job off the top of it.
+ if (ndjson && l.startsWith("{")) {
+ try {
+ job.events.push(JSON.parse(l));
+ if (job.events.length > EVENT_LIMIT) job.events.splice(0, job.events.length - EVENT_LIMIT);
+ continue;
+ } catch {
+ /* not an event after all; fall through and log it */
+ }
+ }
job.log.push(l);
}
if (job.log.length > LOG_LIMIT) job.log.splice(0, job.log.length - LOG_LIMIT);
@@ -71,27 +98,79 @@ function runStep(job: Job, step: Step): Promise<void> {
cwd: step.cwd,
env: { ...process.env, ...step.env },
stdio: ["ignore", "pipe", "pipe"],
+ // Its own process GROUP, so it can be killed as one.
+ //
+ // build-video.mjs shells out to yt-dlp and ffmpeg through execFile, so the
+ // thing actually burning CPU (or holding a download open) is a GRANDCHILD.
+ // child.kill() reaps the node process and leaves those running -- the same
+ // failure the diarize backfill had, where killing the CLI left
+ // diarize-sherpa.py burning four threads.
+ detached: true,
});
+ job.pids.push(child.pid ?? 0);
+
+ const stop = (why: string) => {
+ push(job, ` ** ${why}`);
+ killGroup(child.pid);
+ };
+
+ const limit = step.timeoutMs ?? STEP_TIMEOUT_MS;
+ const timer = setTimeout(
+ () => stop(`killed after ${Math.round(limit / 60000)} minutes`),
+ limit,
+ );
+
+ // A cancel that arrives mid-step is what the abort flag is for; the step
+ // itself has no other way to hear about it.
+ const cancelTimer = setInterval(() => {
+ if (job.cancelling) {
+ clearInterval(cancelTimer);
+ stop("cancelled");
+ }
+ }, 250);
- const timer = setTimeout(() => {
- push(job, ` ** killed after ${STEP_TIMEOUT_MS / 60000} minutes`);
- child.kill("SIGKILL");
- }, STEP_TIMEOUT_MS);
+ const done = () => {
+ clearTimeout(timer);
+ clearInterval(cancelTimer);
+ };
- child.stdout.on("data", (b: Buffer) => push(job, b.toString()));
+ child.stdout.on("data", (b: Buffer) => push(job, b.toString(), step.ndjson));
child.stderr.on("data", (b: Buffer) => push(job, b.toString()));
child.on("error", (e) => {
- clearTimeout(timer);
+ done();
reject(e);
});
child.on("close", (code) => {
- clearTimeout(timer);
- if (code === 0) resolve();
+ done();
+ if (job.cancelling) reject(new Error("cancelled"));
+ else if (code === 0) resolve();
else reject(new Error(`${step.argv[0]} exited ${code}`));
});
});
}
+/**
+ * SIGTERM the process group, then SIGKILL what is left.
+ *
+ * The negative pid is the whole point: it addresses the GROUP, which is what
+ * `detached: true` created and what contains the yt-dlp and ffmpeg grandchildren.
+ */
+function killGroup(pid: number | undefined) {
+ if (!pid) return;
+ try {
+ process.kill(-pid, "SIGTERM");
+ } catch {
+ /* already gone */
+ }
+ setTimeout(() => {
+ try {
+ process.kill(-pid, "SIGKILL");
+ } catch {
+ /* already gone */
+ }
+ }, KILL_GRACE_MS);
+}
+
let seq = 0;
export function startJob(kind: string, steps: Step[]): Job {
@@ -108,6 +187,9 @@ export function startJob(kind: string, steps: Step[]): Job {
steps,
stepIndex: 0,
log: [],
+ events: [],
+ pids: [],
+ cancelling: false,
error: null,
};
jobs.set(job.id, job);
@@ -137,8 +219,24 @@ export function startJob(kind: string, steps: Step[]): Job {
return job;
}
+/**
+ * Cancel the running job.
+ *
+ * Safe to do at any point, and worth saying why: every artefact a report build
+ * makes is content-addressed -- a fetched window by its window, a segment by its
+ * clip id -- so re-running skips whatever finished. A cancelled build is a
+ * paused one.
+ */
+export function cancelJob(id: string): boolean {
+ const job = jobs.get(id);
+ if (!job || job.state !== "running") return false;
+ job.cancelling = true;
+ push(job, "** cancel requested");
+ return true;
+}
+
/** What the client sees. The steps are included so the command is inspectable. */
-export function jobView(job: Job, since = 0) {
+export function jobView(job: Job, since = 0, sinceEvent = 0) {
return {
id: job.id,
kind: job.kind,
@@ -150,5 +248,7 @@ export function jobView(job: Job, since = 0) {
error: job.error,
log: job.log.slice(Math.max(0, since)),
next: job.log.length,
+ events: job.events.slice(Math.max(0, sinceEvent)),
+ nextEvent: job.events.length,
};
}
diff --git a/umtool/lib/report/driver.mjs b/umtool/lib/report/driver.mjs
@@ -0,0 +1,119 @@
+// Turning a report project into a chain of steps lib/jobs.ts can run.
+//
+// SPAWN, not import. A 40-minute chain of yt-dlp and ffmpeg inside a request
+// handler has no cancellation story, its execFile buffers live in the server's
+// heap, and a runaway grandchild outlives the request that started it. The
+// scripts are given a progress protocol instead (`--progress ndjson`), so the
+// UI reads events rather than scraping prose.
+//
+// The client sends a PROJECT and a PRESET NAME. It never sends a path, an argv
+// or an env map -- the same contract /api/browse/build already keeps.
+import path from "node:path";
+
+/** Where the pipeline lives. One place, so a move is one edit. */
+export const PIPELINE_DIR = path.resolve(process.cwd(), "..", "scripts", "report-to-video");
+
+const script = (name) => path.join(PIPELINE_DIR, name);
+
+/**
+ * Presets, in the order somebody actually works.
+ *
+ * `preview` is for looking at ONE clip after moving its edges; `fast` is hard
+ * cuts over the whole timeline, which is minutes rather than tens of minutes and
+ * is what you watch to check the argument; `final` is the deliverable.
+ */
+export const PRESETS = {
+ preview: { label: "preview one clip", xfade: false, chapters: false, only: true },
+ fast: { label: "fast pass (hard cuts)", xfade: false, chapters: true, only: false },
+ final: { label: "final", xfade: true, chapters: true, only: false },
+};
+
+/** A build's timeout, scaled to the work rather than to a global guess. */
+export const buildTimeoutMs = (clipCount, xfade) =>
+ Math.max(15 * 60_000, clipCount * (xfade ? 120_000 : 60_000));
+
+/**
+ * The chain.
+ *
+ * Step 1 is the availability preflight and it is a STEP, not a preamble: it is
+ * the one fact about a manifest that goes stale in both directions, it costs
+ * seconds, and without it a dead source is discovered twenty minutes and a
+ * dozen paid-for fetches into the build.
+ *
+ * Step 2 runs resolve-windows DRY. If it reports changes, the chain stops and
+ * shows them -- a widener silently rewriting windows somebody just set in the
+ * bench is exactly the surprise `lock` exists to prevent. Applying is a second,
+ * explicit action.
+ */
+export function buildSteps(project, { preset = "fast", only = null, skipFetch = false, env = {} } = {}) {
+ const p = PRESETS[preset] ?? PRESETS.fast;
+ const manifest = path.join(project.dir, "video.manifest.json");
+ const outDir = path.join(project.dir, "out");
+ const base = { cwd: PIPELINE_DIR, env };
+
+ const steps = [
+ {
+ ...base,
+ label: "check every source is still fetchable",
+ argv: ["node", script("check-availability.mjs"), manifest, "--out", outDir],
+ timeoutMs: 10 * 60_000,
+ },
+ {
+ ...base,
+ label: "resolve windows (dry — nothing is written)",
+ argv: ["node", script("resolve-windows.mjs"), manifest],
+ timeoutMs: 5 * 60_000,
+ },
+ ];
+
+ const buildArgv = [
+ "node",
+ script("build-video.mjs"),
+ manifest,
+ "--out",
+ outDir,
+ "--progress",
+ "ndjson",
+ "--continue-on-error",
+ ];
+ if (!p.xfade) buildArgv.push("--no-xfade");
+ if (!p.chapters) buildArgv.push("--no-chapters");
+ if (skipFetch) buildArgv.push("--skip-fetch");
+ if (p.only && only) buildArgv.push("--only", only);
+
+ steps.push({
+ ...base,
+ label: p.label,
+ argv: buildArgv,
+ ndjson: true,
+ timeoutMs: buildTimeoutMs(project.clipCount ?? 20, p.xfade),
+ });
+
+ return steps;
+}
+
+/** Fetch ONE clip's window, wide. What the bench's "fetch more" runs. */
+export function fetchSteps(project, clipId, pad) {
+ return [
+ {
+ cwd: PIPELINE_DIR,
+ env: {},
+ label: `fetch ${clipId} with ${pad}s of pad`,
+ argv: [
+ "node",
+ script("build-video.mjs"),
+ path.join(project.dir, "video.manifest.json"),
+ "--out",
+ path.join(project.dir, "out"),
+ "--fetch-only",
+ clipId,
+ "--pad",
+ String(pad),
+ "--progress",
+ "ndjson",
+ ],
+ ndjson: true,
+ timeoutMs: 10 * 60_000,
+ },
+ ];
+}
diff --git a/umtool/lib/report/manifest.mjs b/umtool/lib/report/manifest.mjs
@@ -0,0 +1,161 @@
+// Writing a window back into video.manifest.json.
+//
+// This app is a SECOND writer of a file resolve-windows.mjs also writes, and an
+// agent running `umtool` is a third. Four rules follow from that, and each of
+// them is here because getting it wrong is silent:
+//
+// 1. ROUND TO 2 dp. resolve-windows.mjs is a fixed point, and both its EPS
+// lookup tolerance and its 0.05 s deadband assume 2 dp storage. Writing 4 dp
+// makes the widener grow the same clip a little on every subsequent run --
+// the exact bug its own comments document.
+//
+// 2. PRESERVE THE CLI'S FORMATTING. It writes `JSON.stringify(m, null, 2)` plus
+// a trailing newline. lib/state.ts's writeJsonAtomic uses indent 1, which
+// would turn a two-number edit into a 600-line diff and make the next real
+// change unreviewable.
+//
+// 3. TMP + RENAME, under the process-wide state lock. A reader must never see
+// half a manifest, and two requests must not interleave a read-modify-write.
+//
+// 4. GUARD ON THE FILE'S OWN MTIME. A PUT carrying a stale token is a 409, not
+// a silent overwrite -- somebody may have run `resolve-windows --write` in
+// between, and losing that is losing human judgement.
+import { copyFile, readFile, rename, stat, writeFile } from "node:fs/promises";
+import path from "node:path";
+
+// Its own write queue, not lib/state.ts's.
+//
+// Two reasons, and the second is the real one. lib/state.ts is TypeScript, so
+// importing it would stop `umtool window` running under plain node -- and the
+// whole point of one writer is that the CLI and the app go through it. And the
+// scope is genuinely different: lib/state's queue serialises the SONG state
+// files, which have nothing to do with a manifest.
+//
+// Within a process this serialises read-modify-write. ACROSS processes -- an
+// agent running the CLI while the app has a page open -- the guard is the
+// tmp+rename plus the mtime token, which is what actually stops a lost update.
+/** @type {Promise<unknown>} */
+let queue = Promise.resolve();
+/**
+ * @template T
+ * @param {() => Promise<T>} fn
+ * @returns {Promise<T>}
+ */
+function withManifestLock(fn) {
+ const run = queue.then(fn, fn);
+ queue = run.then(
+ () => undefined,
+ () => undefined,
+ );
+ return run;
+}
+
+export const MANIFEST_NAME = "video.manifest.json";
+const manifestFile = (dir) => path.join(dir, MANIFEST_NAME);
+
+/** 2 dp, and never NaN. The one number format this file will write. */
+const round2 = (n) => Number(Number(n).toFixed(2));
+
+/**
+ * The mtime a client must hand back to be allowed to write.
+ * @param {string} dir
+ * @returns {Promise<string | null>}
+ */
+export async function manifestToken(dir) {
+ const st = await stat(manifestFile(dir)).catch(() => null);
+ return st ? String(Math.round(st.mtimeMs)) : null;
+}
+
+const serialise = (m) => JSON.stringify(m, null, 2) + "\n";
+
+/** How long to leave between .bak copies of the same manifest. */
+const BAK_INTERVAL_MS = 10 * 60 * 1000;
+
+async function backupOnce(file) {
+ // A rolling stack of backups is worth less than one copy of the last
+ // hand-authored state, which is the precedent ferret-rescue already set by
+ // having a single video.manifest.json.bak beside it.
+ const bak = `${file}.bak`;
+ const [src, dst] = await Promise.all([
+ stat(file).catch(() => null),
+ stat(bak).catch(() => null),
+ ]);
+ if (!src) return;
+ if (dst && src.mtimeMs - dst.mtimeMs < BAK_INTERVAL_MS) return;
+ await copyFile(file, bak).catch(() => {});
+}
+
+async function writeManifestAtomic(dir, manifest) {
+ const file = manifestFile(dir);
+ await backupOnce(file);
+ const tmp = `${file}.tmp-${process.pid}-${Math.random().toString(36).slice(2, 8)}`;
+ await writeFile(tmp, serialise(manifest), "utf8");
+ await rename(tmp, file);
+ return manifestToken(dir);
+}
+
+export class StaleToken extends Error {
+ constructor(expected, got) {
+ super(`the manifest changed since you read it (${got} vs ${expected})`);
+ this.name = "StaleToken";
+ this.expected = expected;
+ this.got = got;
+ }
+}
+
+const WINDOW_FIELDS = ["start", "end"];
+const FLAG_FIELDS = ["lock", "lockStart", "lockEnd"];
+
+/**
+ * Patch ONE clip. Windows and locks only.
+ *
+ * Deliberately not a general editor: re-ordering is a different operation with
+ * different consequences (it has to recompute `sectionEnter`), and letting a
+ * window save quietly move an entry is how a cut changes without anybody
+ * deciding to change it.
+ */
+/**
+ * @param {string} dir
+ * @param {string} clipId
+ * @param {Record<string, unknown>} patch
+ * @param {{ token?: string | null }} [opts]
+ * @returns {Promise<{ entry: Record<string, unknown>, before: {start:number,end:number}, token: string | null }>}
+ */
+export async function updateClip(dir, clipId, patch, { token = null } = {}) {
+ return withManifestLock(async () => {
+ const current = await manifestToken(dir);
+ if (token !== null && current !== token) throw new StaleToken(token, current);
+
+ // Re-read INSIDE the lock, every time. Nothing is held across requests.
+ const raw = await readFile(manifestFile(dir), "utf8");
+ const manifest = JSON.parse(raw);
+ const entry = (manifest.timeline ?? []).find((e) => e.id === clipId);
+ if (!entry) throw new Error(`no timeline entry with id ${clipId}`);
+ if (entry.type === "card") throw new Error(`${clipId} is a card, not a clip`);
+
+ const before = { start: entry.start, end: entry.end };
+ for (const k of WINDOW_FIELDS) {
+ if (patch[k] === undefined) continue;
+ const v = Number(patch[k]);
+ if (!Number.isFinite(v) || v < 0) throw new Error(`${k} must be a number ≥ 0`);
+ entry[k] = round2(v);
+ }
+ if (entry.end - entry.start < 0.5) {
+ throw new Error(`a clip must be at least half a second (${entry.start}–${entry.end})`);
+ }
+ for (const k of FLAG_FIELDS) {
+ if (patch[k] === undefined) continue;
+ // `false` REMOVES the key rather than writing it. The manifests are read
+ // by humans, and `"lockEnd": false` is noise that reads like a decision.
+ if (patch[k]) entry[k] = true;
+ else delete entry[k];
+ }
+ if (patch.note !== undefined) {
+ if (patch.note) entry.note = String(patch.note);
+ else delete entry.note;
+ }
+
+ const nextToken = await writeManifestAtomic(dir, manifest);
+ return { entry, before, token: nextToken };
+ });
+}
diff --git a/umtool/lib/report/serve.mjs b/umtool/lib/report/serve.mjs
@@ -0,0 +1,50 @@
+// Resolving what a clip-bench request is allowed to open.
+//
+// Nothing here takes a path. A request names a PROJECT and a CLIP, both of which
+// must be members of the current scan, and a `file` -- which must be one of the
+// cached windows the server itself finds for that clip's video. So a plausible
+// 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 { REPORTS_ROOT, resolveInRoots } from "../paths.mjs";
+import { walkProjects } from "../projects/walk.mjs";
+import { cachedWindowsFor } from "report-to-video/build-video";
+import { clipsOf, readManifest } from "../projects/report.mjs";
+
+export async function resolveClip(projectId, clipId) {
+ const projects = await walkProjects(REPORTS_ROOT);
+ const project = projects.find((p) => p.id === projectId);
+ if (!project) return { error: "no such project", status: 404 };
+ const manifest = await readManifest(project.dir);
+ if (!manifest) return { error: "no manifest", status: 404 };
+ const clip = clipsOf(manifest).find((e) => e.id === clipId);
+ if (!clip) return { error: "no such clip", status: 404 };
+ return { project, manifest, clip };
+}
+
+/**
+ * The cached source windows for a clip, widest first.
+ *
+ * The bench wants the WIDEST containing file, because that is how much room
+ * there is to drag before anything has to be fetched. The BUILD wants the
+ * tightest, because it decodes the whole file to find a silence. They are
+ * different questions and both are asked here.
+ */
+export async function windowsFor(project, clip) {
+ const rawDir = path.join(project.dir, "out", "clips-raw");
+ const all = await cachedWindowsFor(rawDir, clip.video);
+ return all.sort((a, b) => b.to - b.from - (a.to - a.from));
+}
+
+export function pickWindow(windows, wantedName) {
+ if (wantedName) {
+ const hit = windows.find((w) => w.name === wantedName);
+ return hit ?? null;
+ }
+ return windows[0] ?? null;
+}
+
+/** Absolute, inside a read root, and a member of that clip's own scan. */
+export function absOf(win) {
+ return win ? resolveInRoots(win.path) : null;
+}
diff --git a/umtool/lib/trim.ts b/umtool/lib/trim.ts
@@ -180,7 +180,23 @@ export async function writeTrims(set: TrimSet, entries: TrimEntry[]): Promise<Tr
// half-finished run cannot corrupt the directory the shipped overlays name.
// ---------------------------------------------------------------------------
-export type Step = { label: string; argv: string[]; cwd: string; env: Record<string, string> };
+export type Step = {
+ label: string;
+ argv: string[];
+ cwd: string;
+ env: Record<string, string>;
+ /**
+ * How long this step may run, overriding the default.
+ *
+ * Per-step because the default is 15 minutes and exists to catch the
+ * accidental hour-long job -- while a 19-clip crossfaded build legitimately
+ * runs 20 to 40. Raising the default to fit the build would remove the guard
+ * for everything else, so the build asks for what it needs instead.
+ */
+ timeoutMs?: number;
+ /** Lines matching this are progress events, not log noise. */
+ ndjson?: boolean;
+};
/** Env keys a request may never set. The recipe owns both, for the reason above. */
export const ENV_DENY = ["HOOKDIR", "TAKE_DIR"];