import { withEditNotes } from "@/lib/report/guard"; import { StaleToken, updateClip } from "@/lib/report/manifest.mjs"; import { cuesInWindow } from "@/lib/projects/report.mjs"; import { resolveClip } from "@/lib/report/serve.mjs"; import { cutToQuote } from "umtool-report-to-video/resolve-windows"; export const dynamic = "force-dynamic"; // "Cut to quote", for ONE clip. // // The CLI pass (`resolve-windows --cut-to-quote --write`) does the whole // manifest, which is the right shape for a first pass over a fresh cut and the // wrong one for somebody sitting with a single clip: it would write cuts for // nineteen other clips they have not looked at. So the matcher is imported -- // the same function, never a second implementation -- and run here over this // clip's cues, and the result goes through the one writer with the same token, // so a stale tab cannot overwrite a judgement. // // The cues are read a little wider than the extent because the matcher snaps // outward to sentence edges before clamping back inside it. const LOOK = 30; export async function POST(request: Request) { const body = (await request.json().catch(() => ({}))) as Record; 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 { project, clip } = r; if (!String(clip.quote ?? "").trim()) { return Response.json( { error: "this clip has no quote to cut to — write one first", ok: false }, { status: 400 }, ); } const doc = await cuesInWindow(project.dir, clipId, clip.start - LOOK, clip.end + LOOK); if (!doc?.cues?.length) { return Response.json( { error: "no cue file for this source, so there is nothing to match against", ok: false }, { status: 400 }, ); } const minMatch = Number.isFinite(Number(body.minMatch)) ? Number(body.minMatch) : 0.6; const hit = cutToQuote(doc.cues, clip.quote, { start: clip.start, end: clip.end }, { minMatch }) as | { ok: true; cutStart: number; cutEnd: number; score: number; matched: string } | { ok: false; score: number; why: string; matched: string }; if (!hit.ok) { // NOT an error to recover from: a quote the transcript does not contain is // a fact about the report, and guessing a cut would hide it. return Response.json( { ok: false, score: hit.score, matched: hit.matched, error: `no cut written — ${hit.why} (match ${hit.score.toFixed(2)})`, }, { status: 200, headers: { "cache-control": "no-store" } }, ); } try { const { result: res, editNotes } = await withEditNotes(project, () => updateClip( project.dir, clipId, { cutStart: hit.cutStart, cutEnd: hit.cutEnd }, { token: body.token === undefined ? null : String(body.token) }, ), ); return Response.json( { ok: true, entry: res.entry, token: res.token, score: hit.score, matched: hit.matched, editNotes }, { 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 }); } }