Archilyzer · Source

archilyzer

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

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:
Aumtool/app/api/report/clip/route.ts | 75+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Aumtool/app/api/report/cues/route.ts | 26++++++++++++++++++++++++++
Aumtool/app/api/report/fetch/route.ts | 49+++++++++++++++++++++++++++++++++++++++++++++++++
Aumtool/app/api/report/peaks/route.ts | 64++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Aumtool/app/api/report/raw/route.ts | 76++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Aumtool/app/api/report/window/route.ts | 52++++++++++++++++++++++++++++++++++++++++++++++++++++
Aumtool/components/projects/ClipBench.tsx | 534+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Aumtool/components/projects/ClipBenchPage.tsx | 86+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mumtool/components/projects/ProjectView.tsx | 8++++++--
Mumtool/components/projects/ReportProject.tsx | 9++++++++-
Aumtool/e2e/clip-bench.spec.ts | 209+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mumtool/e2e/fixtures/make-fixture.mjs | 24+++++++++++++++++++++++-
Mumtool/e2e/projects.spec.ts | 27+++++++++++++++++----------
Mumtool/lib/jobs.ts | 122+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++--------
Aumtool/lib/report/driver.mjs | 119+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Aumtool/lib/report/manifest.mjs | 161+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Aumtool/lib/report/serve.mjs | 50++++++++++++++++++++++++++++++++++++++++++++++++++
Mumtool/lib/trim.ts | 18+++++++++++++++++-
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 &ldquo;… + {endCue?.text.trim().slice(-56)}&rdquo;. Run it to the end of the sentence, or set{" "} + <code className="font-mono">lockEnd</code> to say you meant to cut here. + </p> + )} + {data.noPunctuation && ( + <p + data-warn="no-punctuation" + className="rounded border border-[var(--color-line)] px-3 py-1.5 text-[12px] text-[var(--color-dim)]" + > + This upload&rsquo;s ASR carries no punctuation, so there are no sentence boundaries to + find and widening cannot help. Set these edges by ear and lock them. + </p> + )} + {wouldRevert && ( + <p + data-warn="would-revert" + className="rounded border border-[var(--color-dirty)] px-3 py-1.5 text-[12px] text-[var(--color-dirty)]" + > + <code className="font-mono">resolve-windows --write</code> would make this{" "} + {hms(proposed!.start)} – {hms(proposed!.end)}, reverting your edit. Set the matching lock + if this window is deliberate.{" "} + <button + type="button" + className={buttonVariants({ variant: "primary", size: "sm" })} + onClick={() => + void save({ + start: round2(sel.from), + end: round2(sel.to), + lockStart: Math.abs(proposed!.start - round2(sel.from)) > 0.05, + lockEnd: Math.abs(proposed!.end - round2(sel.to)) > 0.05, + }) + } + > + save and lock + </button> + </p> + )} + + {/* ---- the waveform ---- */} + <Waveform + view={view} + sel={sel} + cand={{ from: clip.start, to: clip.end }} + words={cues.map((c) => ({ start: c.start, end: c.end, w: c.text.slice(0, 24) }))} + peaks={peaks} + playhead={playhead} + onSel={onSel} + onReachEdge={() => { + /* widening is a FETCH here, not a redraw -- see the button below */ + }} + /> + + {(atStartEdge || atEndEdge) && ( + <div className="flex items-center gap-2 text-[12px]"> + <span className="text-[var(--color-dirty)]"> + that is the edge of what is cached{cached ? ` (${cached.name})` : ""} + </span> + <button + type="button" + className={buttonVariants({ variant: "primary", size: "sm" })} + disabled={!!busy} + onClick={() => void fetchMore()} + > + fetch 20s more + </button> + </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> + &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>} + {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"];