import { getJob, jobView, runningJob, startJob } from "@/lib/jobs"; import { FETCH_MAX_PAD, editorFetchSteps, editorFullSourceSteps, fetchSteps, localFetch, } from "@/lib/report/driver.mjs"; import { projectCache, resolveClip, windowsFor } 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 = FETCH_MAX_PAD; /** Same tolerance the build uses for "this file holds that window". */ const WIN_EPS = 0.02; export async function POST(request: Request) { const body = (await request.json().catch(() => ({}))) as Record; const projectId = String(body.project ?? ""); const clipId = String(body.clip ?? ""); // ONE EDGE AT A TIME. `pad` stays the symmetric shorthand; the bench sends // the side it is extending and the side it already has, so a fetch never // quietly re-downloads twenty seconds of lead-in nobody asked for. const side = (v: unknown, fallback: number) => Math.max(0, Math.min(MAX_PAD, Number(v ?? fallback))); const pad = Math.max(1, Math.min(MAX_PAD, Number(body.pad ?? 20))); const padBefore = side(body.padBefore, pad); const padAfter = side(body.padAfter, pad); // THE WHOLE RECORDING, when widening a window one side at a time is the wrong // shape of answer: a clip whose windows would tile the entire runtime, or a // bench session that wants to re-cut freely without a fetch per attempt. // // It SKIPS the cached-window check below, and that is not an oversight. That // check asks "does a file already hold this span"; a full source is not a // span, and a window that happens to cover the clip is not a reason to refuse // the request — the operator asked for the recording, not for these seconds. // The editor's own cached branch is what makes the second ask free. // // No local twin (see editorFullSourceSteps): a machine with no editor has // nowhere to put a full source that anything else would find. const wantFull = body.full === true; const r = await resolveClip(projectId, clipId); if ("error" in r) return Response.json({ error: r.error }, { status: r.status }); // ASK THE CACHE FIRST. // // `--fetch-only --pad P` is [start-P, end+P] measured from the CLIP, not // from what is already on disk, and the pipeline reuses any file that // contains it. So a second "fetch 20s more" on a window fetched at pad 20 -- // or at the pad 10 a prior CLI run used -- downloads nothing, exits 0, and // the bench reported "fetched" over a cache that had not moved. A no-op is // not a success, and saying so here is cheaper than a job that proves it. // Through projectCache, so a window the EDITOR already fetched into the // corpus counts as cached. Without it this route would queue a job to // download bytes that are on the disk, and the bench would show a fetch that // moved nothing. const windows = wantFull ? [] : ((await windowsFor( r.project, r.clip, await projectCache(r.project, r.manifest), )) as { from: number; to: number }[]); // CLAMPED AT 0, because the FETCH clamps at 0 — build-video.mjs's fetchClip // and fetch-via-editor.mjs both do `Math.max(0, start - padBefore)`, since a // recording has no seconds before its first. Asking here whether a window // holds a NEGATIVE start asks for something no file can ever hold, so this // check could never fire for a clip within `pad` seconds of the beginning — // and those are exactly the clips a sweep starts on. The route then queued a // job whose only finding was that the bytes were already there. const want = { from: Math.max(0, Number(r.clip.start) - padBefore), to: Number(r.clip.end) + padAfter, }; const holds = windows.find((w) => w.from <= want.from + WIN_EPS && w.to >= want.to - WIN_EPS); if (holds) { // What is already there, PER SIDE, in the pads the bench speaks in. const haveBefore = Math.floor(Number(r.clip.start) - holds.from); const haveAfter = Math.floor(holds.to - Number(r.clip.end)); return Response.json( { error: `already cached to −${haveBefore} s / +${haveAfter} s — ask for more on one side` + (padBefore >= MAX_PAD || padAfter >= MAX_PAD ? ` (${MAX_PAD} s is the widest one side goes)` : ""), cachedBefore: haveBefore, cachedAfter: haveAfter, /** The narrower side, for a caller that speaks in one number. */ cachedPad: Math.min(haveBefore, haveAfter), maxPad: MAX_PAD, }, { status: 409 }, ); } const running = runningJob(); if (running) { return Response.json( { error: `a job is already running (${running.kind})`, job: jobView(running) }, { status: 409 }, ); } // ASK THE EDITOR, unless this instance has been told to fetch locally. // // The editor's fetch is the managed one: cookie policy, per-platform sleeps, // the 429 cooldown and its backoff, a job log, and bytes that land in the // corpus where the next report reuses them instead of paying again. Running // yt-dlp from here has none of that, which is why it is now the opt-out // (UMTOOL_LOCAL_FETCH=1) rather than the default. const steps = wantFull ? editorFullSourceSteps(r.project, clipId, { manifest: r.manifest }) : localFetch() ? fetchSteps(r.project, clipId, { padBefore, padAfter }) : editorFetchSteps(r.project, clipId, { padBefore, padAfter }); const job = startJob( wantFull ? `fetch whole source ${projectId}/${clipId}` : `fetch ${projectId}/${clipId}`, steps, { project: r.project.id, }, ); // Which side this run is growing, so the caller can say so rather than // guessing from a file name. `full` says it grew neither. return Response.json( { job: jobView(job), padBefore, padAfter, full: wantFull }, { 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" } }, ); }