import { rename, stat } from "node:fs/promises"; import path from "node:path"; import { cancelJob, getJob, jobView, recentJobs, runningJob, startJob } from "@/lib/jobs"; import { PRESETS, buildSteps } from "@/lib/report/driver.mjs"; import { clipsOf, readManifest } from "@/lib/projects/report.mjs"; import { projectRef } from "@/lib/projects"; import { recordBuildSnapshot } from "@/lib/report/snapshots.mjs"; import { DEFAULT_VARIANT, VARIANTS, variantPaths } from "umtool-report-to-video/build-video"; export const dynamic = "force-dynamic"; // Building a report video. // // The client sends a PROJECT ID and a PRESET NAME. It never sends a path, an // argv or an env map -- the same contract /api/browse/build keeps, and the // reason the output path is computed here rather than accepted. // // POLLING, NOT STREAMING, like every other job in this app. The progress that // matters is per-clip, and that arrives as NDJSON events the driver collects. /** `20260817-1408`, the stamp shape promote already uses for a demoted cut. */ function stamp(d = new Date()): string { const p = (n: number) => String(n).padStart(2, "0"); return `${d.getFullYear()}${p(d.getMonth() + 1)}${p(d.getDate())}-${p(d.getHours())}${p(d.getMinutes())}`; } export async function GET(request: Request) { const url = new URL(request.url); const id = url.searchParams.get("job"); const headers = { "cache-control": "no-store" }; if (!id) { const running = runningJob(); return Response.json( { running: running ? jobView(running) : null, jobs: recentJobs(5).map((j) => jobView(j, j.log.length, j.events.length)), presets: Object.entries(PRESETS).map(([k, v]) => ({ id: k, label: v.label })), variants: VARIANTS, }, { headers }, ); } const job = getJob(id); if (!job) return Response.json({ error: "no such job" }, { status: 404 }); return Response.json( jobView(job, Number(url.searchParams.get("since") ?? 0), Number(url.searchParams.get("sinceEvent") ?? 0)), { headers }, ); } export async function POST(request: Request) { const url = new URL(request.url); const dry = url.searchParams.get("dry") === "1"; const replace = url.searchParams.get("replace") === "1"; const cancel = url.searchParams.get("cancel"); if (cancel) { // Safe at any point, and worth saying why: every artefact 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 paused. return Response.json({ cancelled: cancelJob(cancel) }); } const body = (await request.json().catch(() => ({}))) as Record; const projectId = String(body.project ?? ""); const preset = String(body.preset ?? "fast"); const only = body.only ? String(body.only) : null; const skipFetch = !!body.skipFetch; if (!(preset in PRESETS)) { return Response.json( { error: `preset must be one of ${Object.keys(PRESETS).join(", ")}` }, { status: 400 }, ); } // ---- the options the pipeline has and the driver used to hide ---------- const raw = (body.options ?? {}) as Record; const options: { variant?: string; xfade?: boolean; chaptersOnly?: boolean; chromeOnly?: boolean; preview?: { at: number; dur: number } | null; } = {}; if (raw.variant !== undefined && raw.variant !== "") { const v = String(raw.variant); if (!(VARIANTS as string[]).includes(v)) { return Response.json({ error: `variant must be one of ${VARIANTS.join(", ")}` }, { status: 400 }); } options.variant = v; } if (raw.xfade === false) options.xfade = false; if (raw.chaptersOnly) options.chaptersOnly = true; // Re-render the on-screen deck over the segments on disk ("Re-render // on-screen"). Like chaptersOnly it rewrites the deliverable in place because // somebody asked it to, so the overwrite guard below does not apply. if (raw.chromeOnly) options.chromeOnly = true; if (raw.preview && typeof raw.preview === "object") { const pv = raw.preview as Record; const at = Number(pv.at); const dur = Number(pv.dur); if (!Number.isFinite(at) || at < 0 || !Number.isFinite(dur) || dur <= 0) { return Response.json({ error: "preview needs at ≥ 0 and dur > 0, in seconds" }, { status: 400 }); } options.preview = { at, dur }; } if ([options.chaptersOnly, options.chromeOnly, options.preview].filter(Boolean).length > 1) { return Response.json( { error: "chaptersOnly, chromeOnly and preview are different runs — pick one" }, { status: 400 }, ); } const project = await projectRef(projectId); if (!project) return Response.json({ error: "no such project" }, { status: 404 }); const manifest = await readManifest(project.dir); if (!manifest) return Response.json({ error: "no manifest" }, { status: 400 }); if (only && !clipsOf(manifest).some((e: { id: string }) => e.id === only)) { return Response.json({ error: `no clip with id ${only}` }, { status: 400 }); } const clipCount = clipsOf(manifest).length; const steps = buildSteps(project, { preset, only, skipFetch, clipCount, options }); const view = { project: project.id, preset, only, options, steps: steps.map((s: { label: string; argv: string[]; cwd: string; timeoutMs?: number }) => ({ label: s.label, argv: s.argv, cwd: s.cwd, timeoutMs: s.timeoutMs ?? null, })), }; // The exact argv before anything runs. Same contract as /api/mix/render?dry=1. if (dry) return Response.json({ dry: true, ...view }, { headers: { "cache-control": "no-store" } }); // ---- the overwrite guard -------------------------------------------------- // // build-video.mjs always passes -y. A deliverable that cost an hour of network // fetches must not be destroyed to make a new one, so an existing output that // is NEWER than the manifest is refused; on ?replace=1 it is stamped aside // rather than overwritten. // // Through variantPaths(), so `full` guards `-full.mp4` and never trips // on `sourced`'s file (or the other way round). A chapters-only run rewrites // the same file in place by design, and a preview writes a different one, so // neither is guarded. const finalPath = variantPaths(path.join(project.dir, "out"), manifest.slug, options.variant ?? DEFAULT_VARIANT).final; if (!only && !options.chaptersOnly && !options.chromeOnly && !options.preview) { const [fin, man] = await Promise.all([ stat(finalPath).catch(() => null), stat(path.join(project.dir, "video.manifest.json")).catch(() => null), ]); if (fin && man && fin.mtimeMs >= man.mtimeMs) { if (!replace) { return Response.json( { error: `${path.basename(finalPath)} is newer than the manifest — building would overwrite ` + "a deliverable nothing has asked to change. Pass replace=1 to stamp it aside.", needsReplace: true, }, { status: 409 }, ); } const aside = finalPath.replace(/\.mp4$/, `.${stamp()}.mp4`); await rename(finalPath, aside); // The manifest that produced the file it just moved aside, kept beside // it as a snapshot -- so a stamped deliverable and its manifest stay // paired instead of the file outliving the description of it. await recordBuildSnapshot(project.dir, path.basename(aside)).catch(() => null); } } const running = runningJob(); if (running) { return Response.json( { error: `a job is already running (${running.kind})`, running: jobView(running) }, { status: 409 }, ); } try { const job = startJob(`build ${project.id} (${preset})`, steps, { project: project.id }); return Response.json({ ok: true, ...view, job: jobView(job) }, { headers: { "cache-control": "no-store" } }); } catch (e) { return Response.json({ error: e instanceof Error ? e.message : String(e) }, { status: 409 }); } }