// Cutting ONE clip out of the window already on disk. // // The deliverable a written report ships is `clips/.mp4` -- the players in // report.html read exactly that -- and the first batch of them was fetched one // per clip with `yt-dlp --download-sections`. That is a second download of // seconds already paid for: the bench fetches a generous window per clip into // `out/clips-raw` (or, now, into the corpus) before anybody can judge it, and // every confirmed clip therefore already has its own frames on this disk. // // So this cuts from the cache and never touches the network. A clip with no // containing window is NOT cut here and is not an error either -- it is a clip // nobody has fetched yet, which is the bench's own "not fetched" state and has // its own button. // // The ffmpeg cut itself is build-video's: `cutArgs()` is the render's segment // pass, exported rather than copied, so the seconds this writes and the seconds // the video renders are the same arithmetic. import { execFile } from "node:child_process"; import { rename, rm } from "node:fs/promises"; import path from "node:path"; import { promisify } from "node:util"; import { FFMPEG_BIN, cutArgs } from "umtool-report-to-video/build-video"; import { clipsOf, readManifest } from "../projects/report.mjs"; import { ACCURATE_CUT_ARGS, probeSeconds } from "./encode.mjs"; import { projectCache } from "./serve.mjs"; import { deliverableDir } from "./storage.mjs"; const execFileP = promisify(execFile); /** Where the report's own players look. Not configurable: build.py hardcodes it. */ export const CLIPS_DIR = "clips"; /** * How far out a stream-copied cut may land before it is re-encoded. * * A copy can only start on a keyframe. A window fetched with * `--force-keyframes-at-cuts` has one exactly where the clip begins, so the * copy is exact and free; a window fetched any other way -- or a clip whose * edges were MOVED on the bench after the fetch -- has one wherever the encoder * put it, and the copy silently begins seconds early. That is not a rounding * error to tolerate: it is the wrong sentence. */ export const CUT_TOLERANCE = 0.05; /** ffmpeg gets a generous cap; a cut of a cached window is seconds of work. */ const CUT_TIMEOUT_MS = 5 * 60_000; /** * Cut `clipId` of `project` out of the cached window that contains it. * * @param {{ id: string, dir: string }} project * @param {string} clipId * @param {{ reencode?: "auto" | "always" | "never", manifest?: object | null, * cache?: Awaited> | null }} [opts] * `reencode` is the operator's override of the keyframe test above: * "always" for a window whose copy is known to be wrong, "never" for a * re-cut that must not lose a generation. * @returns {Promise<{ ok: boolean, id: string, reason?: string, error?: string, * path?: string, rel?: string, seconds?: number, want?: number, * mode?: "copy" | "reencode", window?: { name: string, from: number, to: number } }>} */ export async function cutClipFromCache( project, clipId, { reencode = "auto", manifest = null, cache = null } = {}, ) { const m = manifest ?? (await readManifest(project.dir)); if (!m) return { ok: false, id: clipId, reason: "no-manifest", error: "no manifest" }; const clip = clipsOf(m).find((e) => e.id === clipId); if (!clip) return { ok: false, id: clipId, reason: "no-clip", error: "no such clip" }; const start = Number(clip.start); const end = Number(clip.end); if (!Number.isFinite(start) || !Number.isFinite(end) || !(end > start)) { return { ok: false, id: clipId, reason: "no-window", error: "this clip has no window" }; } // THE EXTENT, not the cut-to-quote. `cutStart`/`cutEnd` is what the VIDEO // plays; a written report's player is the reviewed extent, which is what // clips.json carries and what the caption under it describes. const c = cache ?? (await projectCache(project, m)); const win = c.containing(clip.video, start, end); if (!win) { // Not an error. Nobody has fetched this one yet, and the bench has a // button for exactly that. return { ok: false, id: clipId, reason: "not-fetched", error: "no cached window holds this clip end to end", }; } const want = end - start; const a = Math.max(0, start - win.from); const b = a + want; // Made where the project's deliverables switch says: a directory, or a link // to the media root (release 17). A dangling link refuses here, before any // ffmpeg runs, and nothing is made in its place. The tmp file below sits // beside the final one either way, so the rename never crosses a volume. let dir; try { dir = await deliverableDir(project.dir, CLIPS_DIR); } catch (e) { return { ok: false, id: clipId, reason: "storage", error: e instanceof Error ? e.message : String(e) }; } const out = path.join(dir, `${clipId}.mp4`); const tmp = path.join(dir, `.${clipId}.cutting.mp4`); const run = async (args) => { await rm(tmp, { force: true }); await execFileP( FFMPEG_BIN, ["-nostdin", "-v", "error", "-y", ...cutArgs(win.path, a, b), ...args, tmp], { maxBuffer: 1 << 24, timeout: CUT_TIMEOUT_MS }, ); return probeSeconds(tmp); }; let mode = reencode === "always" ? "reencode" : "copy"; let got = null; try { if (mode === "copy") { // `-avoid_negative_ts make_zero` so the copied packets' timestamps start // at zero: without it a copy that began on an earlier keyframe carries // the window's own clock into the file, and every player disagrees about // how long it is. got = await run(["-c", "copy", "-avoid_negative_ts", "make_zero", "-movflags", "+faststart"]); const off = got == null ? Infinity : Math.abs(got - want); if (off > CUT_TOLERANCE && reencode !== "never") { // The window's keyframes are not where this clip's edges are, so the // copy is the wrong seconds. Pay for one generation and get the cut // that was asked for. mode = "reencode"; got = await run(ACCURATE_CUT_ARGS); } } else { got = await run(ACCURATE_CUT_ARGS); } } catch (e) { await rm(tmp, { force: true }); return { ok: false, id: clipId, reason: "ffmpeg", error: e instanceof Error ? e.message : String(e), }; } await rename(tmp, out); return { ok: true, id: clipId, path: out, rel: path.posix.join(CLIPS_DIR, `${clipId}.mp4`), seconds: got == null ? null : Number(got.toFixed(3)), want: Number(want.toFixed(3)), mode, window: { name: win.name, from: win.from, to: win.to }, }; }