Archilyzer · Source

archilyzer

Archilyzer
git clone https://archilyzer.pages.dev/source/archilyzer.git
Log | Files | Refs | README | LICENSE

commit b80edbeddd56875575592576441537a98f5cda9c
parent 20edeac3f8defb9b7e1401560aa697f9ef2df602
Author: I Mean I'm Just Saying <imeanimjustsaying@kiwifarms.st>
Date:   Mon,  5 Oct 2026 02:50:02 -0400

common: the corpus half of the local-source lookup (lib/evidenceClip.mjs); report-to-video's sources.mjs and cutArgs take it from there

The corpus-window, saved-video and audio tiers, the window predicates, the
probe and the input cut arguments move into one plain-ESM module in common,
which umtool re-exports under the same names; the build's raw cache stays
umtool's own tier in front of them.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

Diffstat:
Acommon/lib/evidenceClip.mjs | 400+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mcommon/package.json | 1+
Mumtool/report-to-video/build-video.mjs | 7++++---
Mumtool/report-to-video/sources.mjs | 296+++++++++++++------------------------------------------------------------------
4 files changed, 453 insertions(+), 251 deletions(-)

diff --git a/common/lib/evidenceClip.mjs b/common/lib/evidenceClip.mjs @@ -0,0 +1,400 @@ +// 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/<slug>/data/<id>/clips/<from>-<to>.<ext>` +// (lib/clipWindow.ts) +// 2. saved-video the whole source container: the saved-video store's +// pointer (`data/<id>/saved-video.json` -> `<dir>/<file>`), +// else a `data/<id>/source-media.<ext>` not yet moved there. +// It is the window [0, its probed duration]. +// 3. audio `data/<id>/audio.<ext>`: 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/<id>/` 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<string[]>} + */ +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<SourceWindow[]>, 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: `<channelsDir>/<slug>/data/<id>`. + * @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 -- `<from>-<to>.<ext>`. + * @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.<ext>`, 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.<ext>` 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.<ext>` 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<LocalSource | null>} + */ +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<LocalSource | null>} + */ +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]; +} diff --git a/common/package.json b/common/package.json @@ -32,6 +32,7 @@ "./components/virtualizer": "./components/virtualizer.ts", "./components/*": "./components/*.tsx", "./lib/detectPlatform.mjs": "./lib/detectPlatform.mjs", + "./lib/evidenceClip.mjs": "./lib/evidenceClip.mjs", "./lib/ports.mjs": "./lib/ports.mjs", "./lib/report/verdicts.mjs": "./lib/report/verdicts.mjs", "./lib/toolProbe.mjs": "./lib/toolProbe.mjs", diff --git a/umtool/report-to-video/build-video.mjs b/umtool/report-to-video/build-video.mjs @@ -92,6 +92,7 @@ import { cardWidth, contentWidth, reservedFooterHeight, } from "./render-cards.mjs"; import { DEFAULT_CHANNELS_DIR, createCueSource, siteOriginFromManifest } from "./cues.mjs"; +import { cutArgs } from "yt-dlp-transcript-common/lib/evidenceClip.mjs"; // Where a clip's media is ALREADY on disk -- the build's raw cache, the // editor's corpus windows, the saved source -- asked before anything fetches. import { @@ -648,9 +649,9 @@ export function clipFetchArgs({ url, from, to, fmt, dest, extra = [] }) { * @param {number} a seconds INTO that file where the clip starts * @param {number} b seconds into it where the clip ends */ -export function cutArgs(raw, a, b) { - return ["-ss", a.toFixed(3), "-to", b.toFixed(3), "-i", raw]; -} +// Common's (lib/evidenceClip.mjs) since a report site cuts its evidence clips +// with the same arguments: re-exported, never re-spelled. +export { cutArgs }; /** * The span a clip's source is needed over: its extent, widened by the fetch diff --git a/umtool/report-to-video/sources.mjs b/umtool/report-to-video/sources.mjs @@ -46,27 +46,52 @@ // links before it is returned, and a dangling one -- an unmounted drive -- is // "not here", never an error: the build falls through to the next tier. // +// THE CORPUS HALF IS COMMON'S (common/lib/evidenceClip.mjs): tiers 2-4, the +// window predicates and the probe live there, once, because the report-site +// prepare step (common/lib/evidenceClip-server.ts) asks the same question of +// the same disk and must get the same answer. This module adds the build's own +// raw cache in front of them and re-exports the rest, so every importer here +// keeps its names. +// // A LEAF. Plain ESM with no imports from build-video.mjs or umtool's lib, so // the build, the clip bench (lib/projects/report.mjs) and lib/report/raw-cache // can all import it without a cycle, and so the bench and the render cannot // disagree about what is local. It never computes a path from the cwd: every // root arrives as an argument (see umtool/lib/paths.mjs on why that matters to // the Next build). -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); +import { + AUDIO_ONLY_PLATFORMS, + CORPUS_TIERS, + WINDOW_RE, + listNames, + present, + resolveFromTiers, + windowContains, + tightestContaining, + ffprobeSource, +} from "yt-dlp-transcript-common/lib/evidenceClip.mjs"; + +export { + AUDIO_ONLY_PLATFORMS, + AUDIO_PREFERENCE, + CORPUS_WINDOW_EXTS, + SAVED_VIDEO_POINTER, + WIN_EPS, + audioFilesOf, + corpusClipsDir, + ffprobeSource, + present, + readSavedVideoPointer, + tightestContaining, + videoDirOf, + wholeContainersOf, + windowContains, + windowsFromBareNames, +} from "yt-dlp-transcript-common/lib/evidenceClip.mjs"; /** The source kinds, in the order they are consulted. */ -export const SOURCE_KINDS = ["raw-cache", "corpus-window", "saved-video", "audio"]; - -/** - * Platforms whose records have no picture: a feed of episodes. A clip from one - * reads its local audio BEFORE the network, since a fetch has no video to get. - */ -export const AUDIO_ONLY_PLATFORMS = new Set(["podcast", "feed", "rss"]); +export const SOURCE_KINDS = ["raw-cache", ...CORPUS_TIERS.map((t) => t.kind)]; /** * May the audio tier serve this clip, and as what? (See the note at the top.) @@ -91,13 +116,6 @@ export function audioUse({ entry = null, meta = null, render = null, network = t /** audioUse's yes or no: should the audio tier be consulted at all? */ export const audioAllowed = (args = {}) => audioUse(args) !== null; -// A window read back from a 2 dp name can sit a hair outside the request that -// produced it; the same tolerance resolve-windows.mjs and common's -// lib/clipWindow.ts use, for the same reason. -export const WIN_EPS = 0.02; - -const WINDOW_RE = /^(\d+(?:\.\d+)?)-(\d+(?:\.\d+)?)$/; - // ---- the build's raw cache (tier 1) ---------------------------------------- // A raw clip's window is IN ITS NAME, which makes the file immutable and the // cache content-addressed. TWO DECIMALS, always: the name is a function of the @@ -109,13 +127,7 @@ export function rawWindowName(video, from, to) { } /** A directory listing, or [] for a directory that is not there. */ -export async function listRawNames(rawDir) { - try { - return await readdir(rawDir); - } catch { - return []; - } -} +export const listRawNames = listNames; /** The windows THIS video's files hold, parsed out of a clips-raw listing. */ export function windowsFromNames(names, rawDir, video) { @@ -132,21 +144,6 @@ export function windowsFromNames(names, rawDir, video) { return out; } -/** Does this window hold [from, to] whole, to the manifest's tolerance? */ -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. */ -export function tightestContaining(windows, from, to) { - 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; -} - export async function cachedWindowsFor(rawDir, video) { return windowsFromNames(await listRawNames(rawDir), rawDir, video); } @@ -156,145 +153,6 @@ export async function findContainingWindow(rawDir, video, from, to) { return tightestContaining(await cachedWindowsFor(rawDir, video), from, to); } -// ---- the corpus (tiers 2 and 3) -------------------------------------------- - -/** A video's directory in a channels tree: `<channelsDir>/<slug>/data/<id>`. */ -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). */ -export function corpusClipsDir(videoDir) { - return path.join(/* turbopackIgnore: true */ videoDir, "clips"); -} - -// The extensions a corpus window may wear -- common/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 below 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 -- `<from>-<to>.<ext>`. The same numbers as clips-raw's - * names, one predicate over both. - */ -export function windowsFromBareNames(names, dir) { - 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"; - -// common/lib/mediaFiles.ts's anchored `source-media.<ext>`, 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 - * common/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. - */ -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.<ext>` still in the video dir. Not yet checked - * for existence -- that is `present`'s job, once, for every tier. - */ -export async function wholeContainersOf(videoDir) { - const out = []; - const pointer = await readSavedVideoPointer(videoDir); - if (pointer) out.push({ name: pointer.file, path: pointer.path, height: pointer.height }); - const local = (await listRawNames(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: common/lib/mediaFiles.ts's anchored -// `audio.<ext>` over AUDIO_EXTS, plus the `.webm`/`.mp4` an extract-to-mp3 that -// failed leaves behind (sound only). Read in AUDIO_READ_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. */ -export async function audioFilesOf(videoDir) { - const names = (await listRawNames(videoDir)).filter((n) => AUDIO_FILE_RE.test(n)); - const rank = (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. - */ -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 failed build. - */ -export async function ffprobeSource(file) { - try { - const { stdout } = await execFileP(process.env.FFPROBE_BIN ?? "ffprobe", [ - "-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, rawDir, channelsDir, probe}`. A tier that cannot @@ -305,58 +163,8 @@ async function rawCacheWindows({ video, rawDir }) { return cachedWindowsFor(rawDir, video); } -async function corpusWindows({ video, slug, channelsDir }) { - if (!channelsDir || !slug) return []; - const dir = corpusClipsDir(videoDirOf(channelsDir, slug, video)); - return windowsFromBareNames(await listRawNames(dir), dir); -} - -async function savedVideoWindows({ video, slug, channelsDir, probe = ffprobeSource }) { - if (!channelsDir || !slug) return []; - 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. -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 []; -} - // `audio: true` marks a tier consulted only when the caller allows it. -export const TIERS = [ - { kind: "raw-cache", windows: rawCacheWindows }, - { kind: "corpus-window", windows: corpusWindows }, - { kind: "saved-video", windows: savedVideoWindows }, - { kind: "audio", windows: audioWindows, audio: true }, -]; - -const asSource = (kind, w) => ({ - kind, - path: w.path, - name: w.name, - windowStart: w.from, - windowEnd: w.to, - ...(w.height ? { height: w.height } : {}), -}); +export const TIERS = [{ kind: "raw-cache", windows: rawCacheWindows }, ...CORPUS_TIERS]; /** * The local source for one span of one video, or null when nothing on disk @@ -382,26 +190,18 @@ export async function resolveLocalSource(want, config = {}) { if (rawDir) { const name = rawWindowName(video, from, to); const p = path.join(/* turbopackIgnore: true */ rawDir, name); - if (await present(p)) return asSource("raw-cache", { name, path: p, from, to }); + if (await present(p)) return { kind: "raw-cache", path: p, name, windowStart: from, windowEnd: to }; } // `--no-reuse` re-cuts no cached window, but the sound is not one. if (!audio) return null; } - const ctx = { video, slug, rawDir, channelsDir, probe }; - for (const tier of TIERS) { - if (kinds && !kinds.includes(tier.kind)) continue; - if (tier.audio ? !audio : exact) continue; - const windows = (await tier.windows(ctx)).filter((w) => windowContains(w, from, to)); - // The raw cache's EXACT name first, as it always was; then tightest. - const exactName = tier.kind === "raw-cache" ? rawWindowName(video, from, to) : null; - windows.sort((a, b) => - (b.name === exactName) - (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 raw cache's EXACT name first, as it always was; then tightest. + const exactName = rawWindowName(video, from, to); + return resolveFromTiers(TIERS, { video, slug, rawDir, channelsDir, probe }, from, to, { + allow: (tier) => (!kinds || kinds.includes(tier.kind)) && (tier.audio ? audio : !exact), + preferName: (tier) => (tier.kind === "raw-cache" ? exactName : null), + }); } /** @@ -419,8 +219,8 @@ export async function resolveLocalSource(want, config = {}) { export async function corpusWindowsOf({ video, slug, channelsDir, probe = ffprobeSource }) { const ctx = { video, slug, channelsDir, probe }; const out = []; - for (const tier of TIERS) { - if (tier.kind === "raw-cache" || tier.audio) continue; + for (const tier of CORPUS_TIERS) { + if (tier.audio) continue; for (const w of await tier.windows(ctx)) { if (await present(w.path)) out.push({ ...w, kind: tier.kind }); }