// evidenceClip.mjs — where a cited span's media already is in the corpus, and // the cut that takes the span out of it. // // Two consumers ask the same question — "which file on this disk holds these // seconds of this record?" — and must get the same answer: // - umtool's report-to-video build (report-to-video/sources.mjs), which asks // its own raw cache first and then these tiers; // - the report-site prepare step (lib/evidenceClip-server.ts), which cuts a // self-hosted evidence clip of every cited span. // So the corpus half of the lookup lives HERE, once, and sources.mjs imports // and re-exports it. Plain ESM with no app imports: umtool's scripts run under // bare node, which cannot load a `.ts` file. // // THE CORPUS TIERS, in the order they are consulted: // // 1. corpus-window the editor's window cache, // `channels//data//clips/-.` // (lib/clipWindow.ts) // 2. saved-video the whole source container: the saved-video store's // pointer (`data//saved-video.json` -> `/`), // else a `data//source-media.` not yet moved there. // It is the window [0, its probed duration]. // 3. audio `data//audio.`: the recording's SOUND alone, the // window [0, its probed duration]. Consulted only when the // caller allows it (`audio: true` on the tier): being last, // it never beats a picture already on disk. // // A window qualifies only if it holds the REQUESTED span whole — the span plus // its pad — to WIN_EPS. Within a tier the tightest wins; across tiers the order // above wins. // // A file in `data//` may be a RELATIVE symlink into `media/`, which may be // an absolute symlink to another drive. Every candidate is `stat`ed through its // links before it is returned, and a dangling one — an unmounted drive — is // "not here", never an error: the lookup falls through to the next tier. // // It never computes a path from the cwd: every root arrives as an argument // (umtool's Next build bundles this file, and a cwd join makes its tracer walk // the corpus). import { execFile } from "node:child_process"; import { readdir, readFile, stat } from "node:fs/promises"; import path from "node:path"; import { promisify } from "node:util"; const execFileP = promisify(execFile); /** * Platforms whose records have no picture: a feed of episodes. A span of one * is served by its sound. */ export const AUDIO_ONLY_PLATFORMS = new Set(["podcast", "feed", "rss"]); // A window read back from a 2 dp name can sit a hair outside the request that // produced it; the same tolerance lib/clipWindow.ts and resolve-windows.mjs // use, for the same reason. export const WIN_EPS = 0.02; export const WINDOW_RE = /^(\d+(?:\.\d+)?)-(\d+(?:\.\d+)?)$/; /** * A directory listing, or [] for a directory that is not there. * @param {string} dir * @returns {Promise} */ export async function listNames(dir) { try { return await readdir(dir); } catch { return []; } } /** * @typedef {{ name: string, path: string, from: number, to: number, height?: number }} SourceWindow * @typedef {{ kind: string, path: string, name: string, windowStart: number, windowEnd: number, height?: number }} LocalSource * @typedef {(file: string) => Promise<{ duration: number, height?: number } | null>} SourceProbe * @typedef {{ video: string, slug?: string | null, channelsDir?: string | null, rawDir?: string | null, probe?: SourceProbe }} TierContext * @typedef {{ kind: string, windows: (ctx: TierContext) => Promise, audio?: boolean }} SourceTier */ /** * Does this window hold [from, to] whole, to the tolerance? * @param {{ from: number, to: number }} w * @param {number} from * @param {number} to */ export function windowContains(w, from, to) { return !(w.from > from + WIN_EPS || w.to < to - WIN_EPS); } /** * The tightest of `windows` containing [from, to], or null. * @template {{ from: number, to: number }} W * @param {W[]} windows * @param {number} from * @param {number} to * @returns {W | null} */ export function tightestContaining(windows, from, to) { /** @type {W | null} */ let best = null; for (const w of windows) { if (!windowContains(w, from, to)) continue; if (!best || w.to - w.from < best.to - best.from) best = w; } return best; } /** * A video's directory in a channels tree: `//data/`. * @param {string} channelsDir * @param {string} slug * @param {string} video */ export function videoDirOf(channelsDir, slug, video) { return path.join(/* turbopackIgnore: true */ channelsDir, slug, "data", video); } /** * Where the editor's fetch-window puts a video's windows (lib/clipWindow.ts). * @param {string} videoDir */ export function corpusClipsDir(videoDir) { return path.join(/* turbopackIgnore: true */ videoDir, "clips"); } // The extensions a corpus window may wear -- lib/clipWindow.ts's // CLIP_WINDOW_EXTS. The editor writes `.mp4`; the other two are what an older // fetch left. Never `.json` (the sidecar) and never `.part.mp4` (in flight: // its stem is not `a-b`, so the pattern refuses it). export const CORPUS_WINDOW_EXTS = [".mp4", ".mkv", ".webm"]; /** * The windows in a corpus clips dir: un-prefixed, because the directory is * already per video -- `-.`. * @param {string[]} names * @param {string} dir * @returns {SourceWindow[]} */ export function windowsFromBareNames(names, dir) { /** @type {SourceWindow[]} */ const out = []; for (const name of names) { const ext = CORPUS_WINDOW_EXTS.find((x) => name.endsWith(x)); if (!ext) continue; const m = WINDOW_RE.exec(name.slice(0, -ext.length)); if (!m) continue; const from = Number(m[1]); const to = Number(m[2]); if (!(to > from)) continue; out.push({ name, path: path.join(/* turbopackIgnore: true */ dir, name), from, to }); } return out; } export const SAVED_VIDEO_POINTER = "saved-video.json"; // lib/mediaFiles.ts's anchored `source-media.`, over its // VIDEO_CONTAINER_EXTS: a picture is the point here, so an audio-only // container is not a source for this tier. const SOURCE_MEDIA_RE = /^source-media\.(?:mp4|webm|mkv|mov|m4v|ogv|avi)$/i; /** * The saved-video store's pointer for a video, read the way lib/savedVideo.ts * parses it (`dir` and `file`, both non-empty), or null. Re-implemented rather * than imported: this runs under bare node. `height` is the format the persist * recorded taking, when it recorded one. * @param {string} videoDir * @returns {Promise<{ dir: string, file: string, path: string, height?: number } | null>} */ export async function readSavedVideoPointer(videoDir) { let raw; try { raw = JSON.parse(await readFile(path.join(/* turbopackIgnore: true */ videoDir, SAVED_VIDEO_POINTER), "utf8")); } catch { return null; } if (!raw || typeof raw !== "object") return null; if (typeof raw.dir !== "string" || raw.dir === "") return null; if (typeof raw.file !== "string" || raw.file === "") return null; const height = Number(raw.format?.height); return { dir: raw.dir, file: raw.file, path: path.join(/* turbopackIgnore: true */ raw.dir, raw.file), ...(Number.isInteger(height) && height > 0 ? { height } : {}), }; } /** * The whole-source containers a video has: the store's (through the pointer) * first, then any `source-media.` still in the video dir. Not yet checked * for existence -- that is `present`'s job, once, for every tier. * @param {string} videoDir * @returns {Promise<{ name: string, path: string, height?: number }[]>} */ export async function wholeContainersOf(videoDir) { /** @type {{ name: string, path: string, height?: number }[]} */ const out = []; const pointer = await readSavedVideoPointer(videoDir); if (pointer) out.push({ name: pointer.file, path: pointer.path, height: pointer.height }); const local = (await listNames(videoDir)).filter((n) => SOURCE_MEDIA_RE.test(n)).sort(); for (const name of local) { const p = path.join(/* turbopackIgnore: true */ videoDir, name); if (!out.some((c) => c.path === p)) out.push({ name, path: p }); } return out; } // The sound files a video dir may hold: lib/mediaFiles.ts's anchored // `audio.` over AUDIO_EXTS, plus the `.webm`/`.mp4` an extract-to-mp3 that // failed leaves behind (sound only). Read in AUDIO_PREFERENCE's order -- the // file the transcribe path reads -- then by name. const AUDIO_FILE_RE = /^audio\.(?:mp3|m4a|aac|ogg|oga|opus|wav|flac|webm|mp4)$/i; export const AUDIO_PREFERENCE = ["audio.mp3", "audio.m4a", "audio.opus"]; /** * A video dir's audio files, best first. Not yet checked for existence. * @param {string} videoDir * @returns {Promise<{ name: string, path: string }[]>} */ export async function audioFilesOf(videoDir) { const names = (await listNames(videoDir)).filter((n) => AUDIO_FILE_RE.test(n)); const rank = (/** @type {string} */ n) => { const i = AUDIO_PREFERENCE.indexOf(n); return i < 0 ? AUDIO_PREFERENCE.length : i; }; names.sort((a, b) => rank(a) - rank(b) || a.localeCompare(b)); return names.map((name) => ({ name, path: path.join(/* turbopackIgnore: true */ videoDir, name) })); } /** * Is there a readable file at `p`, through every link on the way? A dangling * link, a missing file and an unanswering drive are all "no", never a throw. * @param {string} p */ export async function present(p) { try { return (await stat(p)).isFile(); } catch { return false; } } /** * The default probe: one ffprobe for the container's duration and its first * video stream's height. Null when ffprobe cannot read it -- which makes the * container "not a source", not a failure. * @param {string} file * @param {string} [bin] the ffprobe binary (default: FFPROBE_BIN, else `ffprobe`) * @returns {Promise<{ duration: number, height?: number } | null>} */ export async function ffprobeSource(file, bin = process.env.FFPROBE_BIN ?? "ffprobe") { try { const { stdout } = await execFileP(bin, [ "-v", "error", "-select_streams", "v:0", "-show_entries", "stream=height:format=duration", "-of", "json", file, ]); const doc = JSON.parse(stdout); const duration = Number(doc?.format?.duration); if (!Number.isFinite(duration) || duration <= 0) return null; const height = Number(doc?.streams?.[0]?.height); return { duration, ...(Number.isInteger(height) && height > 0 ? { height } : {}) }; } catch { return null; } } // ---- the tiers --------------------------------------------------------------- // Each lists a video's candidate windows `{name, path, from, to, height?}` for // one context `{video, slug, channelsDir, probe}`. A tier that cannot apply (no // channelsDir or slug) lists nothing. /** @param {TierContext} ctx */ async function corpusWindows({ video, slug, channelsDir }) { if (!channelsDir || !slug) return []; const dir = corpusClipsDir(videoDirOf(channelsDir, slug, video)); return windowsFromBareNames(await listNames(dir), dir); } /** @param {TierContext} ctx */ async function savedVideoWindows({ video, slug, channelsDir, probe = ffprobeSource }) { if (!channelsDir || !slug) return []; /** @type {SourceWindow[]} */ const out = []; for (const c of await wholeContainersOf(videoDirOf(channelsDir, slug, video))) { // Existence BEFORE the probe: an unmounted drive must not cost a timeout. if (!(await present(c.path))) continue; const info = await probe(c.path); const duration = Number(info?.duration); // No duration is no entry rather than a guess: claiming a span a file may // not cover is the one failure worse than a miss. if (!Number.isFinite(duration) || duration <= 0) continue; const height = c.height ?? info?.height; out.push({ name: c.name, path: c.path, from: 0, to: duration, ...(height ? { height } : {}) }); } return out; } // The ONE best audio file, not every one: they are all the same recording, and // probing three formats of it would buy nothing. /** @param {TierContext} ctx */ async function audioWindows({ video, slug, channelsDir, probe = ffprobeSource }) { if (!channelsDir || !slug) return []; for (const f of await audioFilesOf(videoDirOf(channelsDir, slug, video))) { if (!(await present(f.path))) continue; const duration = Number((await probe(f.path))?.duration); if (!Number.isFinite(duration) || duration <= 0) continue; return [{ name: f.name, path: f.path, from: 0, to: duration }]; } return []; } /** * The corpus tiers, in order. `audio: true` marks a tier consulted only when * the caller allows it. * @type {SourceTier[]} */ export const CORPUS_TIERS = [ { kind: "corpus-window", windows: corpusWindows }, { kind: "saved-video", windows: savedVideoWindows }, { kind: "audio", windows: audioWindows, audio: true }, ]; /** * @param {string} kind * @param {SourceWindow} w * @returns {LocalSource} */ export const asSource = (kind, w) => ({ kind, path: w.path, name: w.name, windowStart: w.from, windowEnd: w.to, ...(w.height ? { height: w.height } : {}), }); /** * The first source among `tiers` holding [from, to] whole and present on disk. * A tier is only listed when every tier before it missed, so the saved-video * probe (an ffprobe, perhaps on a platter) is paid for only by a span nothing * nearer could serve. * * @param {SourceTier[]} tiers * @param {TierContext} ctx * @param {number} from * @param {number} to * @param {{ allow?: (tier: SourceTier) => boolean, preferName?: (tier: SourceTier) => string | null }} [opts] * `allow` skips a tier (default: every tier but an audio one); * `preferName` names, per tier, a file that wins over the tightest. * @returns {Promise} */ export async function resolveFromTiers(tiers, ctx, from, to, opts = {}) { const { allow = (tier) => !tier.audio, preferName = () => null } = opts; for (const tier of tiers) { if (!allow(tier)) continue; const windows = (await tier.windows(ctx)).filter((w) => windowContains(w, from, to)); const exactName = preferName(tier); windows.sort((a, b) => Number(b.name === exactName) - Number(a.name === exactName) || (a.to - a.from) - (b.to - b.from)); for (const w of windows) { if (await present(w.path)) return asSource(tier.kind, w); } } return null; } /** * The corpus source for one span of one record, or null when nothing on disk * holds it. * * @param {{ video: string, slug: string, from: number, to: number }} want * `from`/`to` are the PADDED span. * @param {{ channelsDir: string, probe?: SourceProbe, audio?: boolean }} config * `audio` admits the audio tier (default false). * @returns {Promise} */ export async function resolveCorpusSource(want, config) { const { video, slug, from, to } = want; const { channelsDir, probe, audio = false } = config; if (!video || !slug || !Number.isFinite(from) || !Number.isFinite(to)) return null; return resolveFromTiers(CORPUS_TIERS, { video, slug, channelsDir, probe }, from, to, { allow: (tier) => (tier.audio ? audio : true), }); } /** * The input half of a cut of [a, b] seconds of `raw`: seek on the input, so a * re-encode starts on exactly the asked-for frame. report-to-video's segment * pass and the evidence cutter both use it, so the seconds a video renders and * the seconds a moment page plays are the same arithmetic. * @param {string} raw * @param {number} a * @param {number} b */ export function cutArgs(raw, a, b) { return ["-ss", a.toFixed(3), "-to", b.toFixed(3), "-i", raw]; }