// 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 { createReadStream } from "node:fs"; import { realpath, stat } from "node:fs/promises"; import { Readable } from "node:stream"; import { REPORTS_ROOT, inside, resolveInRoots } from "../paths.mjs"; import { walkProjects } from "../projects/walk.mjs"; import { DEFAULT_VARIANT, VARIANTS, WIN_EPS, variantPaths } from "umtool-report-to-video/build-video"; import { rawCacheOf } from "./raw-cache.mjs"; import { channelsDirFor, clipWindowDirs, clipsOf, hasShadowChannels, 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 same membership rule for a request that names a PROJECT and a cut, and * no clip: the deck's routes. `variant` is checked against the pipeline's own * list; absent or empty it is the default cut. * * @param {string} projectId * @param {string | null} [variant] */ export async function resolveReport(projectId, variant = null) { const v = variant || DEFAULT_VARIANT; if (!VARIANTS.includes(v)) { return { error: `variant must be one of ${VARIANTS.join(", ")}`, status: 400 }; } 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 }; return { project, manifest, variant: v }; } /** The clips-raw cache, re-exported so `serve.mjs` stays the bench's one door. */ export { rawCacheOf } from "./raw-cache.mjs"; /** * The cache for a project, INCLUDING the corpus clips dirs the editor fetches * into. * * Use this rather than rawCacheOf(project.dir) anywhere a clip's cached windows * are the question. A bare rawCacheOf sees only out/clips-raw, so a window the * editor fetched reads as "nothing fetched for this clip yet" and the bench * offers to download bytes that are already on the disk. */ export async function projectCache(project, manifest = null) { const m = manifest ?? (await readManifest(project.dir)); const shadowExists = await hasShadowChannels(project.dir); const channelsDir = channelsDirFor(project.dir, m, { shadowExists }); return rawCacheOf(project.dir, { extraWindows: await clipWindowDirs(m, channelsDir) }); } /** * The cached source windows for a clip: the ones that hold ITS window, widest * first, then any that merely overlap it. * * 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. * * "Containing" is not optional. clips-raw is keyed by VIDEO, and a report * cites the same stream more than once -- ElfpireEva cites one video four * times -- so the directory holds a file per clip and "widest for this video" * is another clip's file as often as not. The bench then seeks `start - from` * into a 44-second file, i.e. 1146 s past its end, and plays nothing or the * wrong seconds. A caller with no window (a ledger claim asking for the raw * files of its video) still gets every file, widest first. * * @param {{dir: string}} project * @param {{video: string, start?: number, end?: number}} clip * @param {Awaited> | null} [cache] a clips-raw * listing already read this request; one is built when none is passed. */ export async function windowsFor(project, clip, cache = null) { // The cache is optional and passed in by callers that already have one (the // bench page asks for every clip), so the directory is read once per request. const c = cache ?? (await projectCache(project)); const all = c.windows(clip.video); const width = (w) => w.to - w.from; const { start, end } = clip; if (!Number.isFinite(start) || !Number.isFinite(end)) { return all.sort((a, b) => width(b) - width(a)); } const overlap = (w) => Math.max(0, Math.min(w.to, end) - Math.max(w.from, start)); const contains = (w) => (w.from <= start + WIN_EPS && w.to >= end - WIN_EPS ? 1 : 0); return all .filter((w) => overlap(w) > 0) .sort((a, b) => contains(b) - contains(a) || overlap(b) - overlap(a) || width(b) - width(a)); } 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; } /** * The BUILT segment for a clip -- the mp4 with the header and the QR burned in. * * Two directories, because a two-cut manifest writes its default variant under * `out//segments` while an older tree wrote `out/segments`; the same * pair readClipDetail looks in, and for the same reason. * * The name is built from the clip id the timeline already vouched for, never * from anything the client typed, and still goes through resolveInRoots -- the * membership check and the root check are two guards and both are cheap. * * Returns the mtime as well, because a re-render writes the SAME path: without * it in the URL a browser would keep showing the segment it cached before the * edit, which is the one thing a re-render button must never do. */ export async function segmentFor(project, clipId) { for (const rel of [ path.posix.join("out", DEFAULT_VARIANT, "segments"), path.posix.join("out", "segments"), ]) { const relFile = path.posix.join(rel, `${clipId}.mp4`); const abs = resolveInRoots(path.join(project.dir, relFile)); if (!abs) continue; const st = await stat(abs).catch(() => null); if (st?.isFile()) return { rel: relFile, abs, size: st.size, mtimeMs: Math.round(st.mtimeMs) }; } return null; } /** * The same membership rule, for a LEDGER claim. * * A claim id is checked against the ledger of the project named in the request, * exactly as a clip id is checked against its timeline. Nothing here takes a * path either. */ export async function resolveClaim(projectId, claimId) { 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 claim = (manifest.ledger ?? []).find((e) => e.id === claimId); if (!claim) return { error: "no such claim", status: 404 }; return { project, manifest, claim }; } // --------------------------------------------------------------------------- // Byte ranges. // // One implementation for every route here that hands an mp4 to a