Archilyzer · Source

archilyzer

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

commit f0a3486d47ed0795be4c0a79a1a638d63ecf8547
parent b702f016c65853a65ad5a21555b9a181dcd283db
Author: I Mean I'm Just Saying <imeanimjustsaying@kiwifarms.st>
Date:   Sun,  4 Oct 2026 16:30:02 -0400

report-to-video: sources.mjs resolves a clip's media from disk before any fetch

Tiers, in order: the build's out/clips-raw, the corpus's
channels/<slug>/data/<id>/clips/ windows, a saved whole source (the store
pointer, else data/<id>/source-media.<ext>) as [0, probed duration]. Links
are followed and a dangling one is a miss. build-video's fetchClip asks it
first and cuts from the local file; --no-network finds every clip's source
up front and refuses the build listing each clip that would need a fetch.
The clip bench's clipWindowDirs lists its corpus windows through the same
module, and channelsDirFor's rule lives there too.

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

Diffstat:
Mumtool/lib/projects/report.mjs | 88+++++++++++++++++++++++++++++++++----------------------------------------------
Mumtool/lib/report/raw-cache.mjs | 52+++++++++++-----------------------------------------
Mumtool/lib/report/serve.mjs | 2+-
Mumtool/report-to-video/build-video.mjs | 288+++++++++++++++++++++++++++++++++++++++++++++++++++----------------------------
Mumtool/report-to-video/package.json | 1+
Aumtool/report-to-video/sources.mjs | 359+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
6 files changed, 594 insertions(+), 196 deletions(-)

diff --git a/umtool/lib/projects/report.mjs b/umtool/lib/projects/report.mjs @@ -8,6 +8,12 @@ import { readdir, readFile, stat } from "node:fs/promises"; import path from "node:path"; import { DEFAULT_VARIANT, cachedWindowsFor } from "umtool-report-to-video/build-video"; +import { + SHADOW_CHANNELS as SOURCES_SHADOW_CHANNELS, + channelsDirFor as sourcesChannelsDirFor, + corpusWindowsOf, + videoDirOf, +} from "umtool-report-to-video/sources"; import { rawCacheOf } from "../report/raw-cache.mjs"; import { deliverablesState, finishCommand, outDirState, leftoversOf } from "../report/storage.mjs"; import { CHANNELS_DIR, inside } from "../paths.mjs"; @@ -44,7 +50,7 @@ export const MANIFEST_NAME = "video.manifest.json"; export const GLOBAL_CHANNELS_DIR = () => CHANNELS_DIR; /** The conventional name make-shadow-channels.sh builds. */ -export const SHADOW_CHANNELS = ".shadow-channels"; +export const SHADOW_CHANNELS = SOURCES_SHADOW_CHANNELS; // --------------------------------------------------------------------------- // Which channels directory THIS project reads. @@ -62,11 +68,11 @@ export const SHADOW_CHANNELS = ".shadow-channels"; // detected because it already exists and nothing had to change on disk for it // to work. Neither is a guess: both are things the project itself wrote down. // --------------------------------------------------------------------------- +// +// The rule itself is report-to-video/sources.mjs's, so the render looks for +// local media in the tree this reads. export function channelsDirFor(dir, manifest, { shadowExists = false } = {}) { - const declared = manifest?.provenance?.channelsDir; - if (declared) return path.resolve(dir, declared); - if (shadowExists) return path.join(dir, SHADOW_CHANNELS); - return GLOBAL_CHANNELS_DIR(); + return sourcesChannelsDirFor(dir, manifest, { shadowExists, fallback: GLOBAL_CHANNELS_DIR() }); } export const hasShadowChannels = (dir) => @@ -85,17 +91,6 @@ const DEAD_ORIGIN = export const isDeadOrigin = (o) => !o || typeof o !== "string" || DEAD_ORIGIN.test(o); const stat0 = (p) => stat(p).then((s) => s, () => null); -const readJson0 = (p) => - readFile(p, "utf8").then( - (t) => { - try { - return JSON.parse(t); - } catch { - return null; - } - }, - () => null, - ); export const manifestPath = (dir) => path.join(dir, MANIFEST_NAME); @@ -326,14 +321,27 @@ export async function readAvailability(dir) { } /** - * The corpus directories that also hold windows of this manifest's videos. + * The windows OUTSIDE the project that also hold this manifest's videos, as + * `[{ video, windows }]` for rawCacheOf's `extraWindows`. * - * The editor fetches a clip window into channels/<slug>/data/<id>/clips/ -- - * managed, polite, provenanced, and REUSABLE, which is the whole point: a - * window one report paid for is a window the next one does not. One entry per - * distinct (channel, video) the timeline cites; rawCacheOf reads each once. + * Listed by the render's own module (report-to-video/sources.mjs), so the + * bench's "fetched" and the build's "local" are one set: the editor's window + * cache in channels/<slug>/data/<id>/clips/ -- managed, polite, provenanced, + * and REUSABLE, which is the whole point: a window one report paid for is a + * window the next one does not -- and a saved whole source, reached through + * the store's pointer (`full: true` puts the recording there, not in clips/) + * or still in the video dir. A dangling link -- a drive not mounted -- is no + * window, never an error. One entry per distinct (channel, video) the + * timeline cites. + * + * A WHOLE CONTAINER'S SPAN COMES FROM THE CUES, not from an ffprobe as the + * render's does: the cue doc is the archive's own record of how long the + * recording is, it is memoised against mtime (see below), and it is asked for + * only when a container is actually there -- which keeps the index load as + * cheap as it was. No duration is no window rather than a guess. */ export const clipWindowDirs = async (m, channelsDir) => { + const root = channelsDir ?? GLOBAL_CHANNELS_DIR(); const seen = new Set(); const out = []; for (const e of clipsOf(m)) { @@ -342,35 +350,13 @@ export const clipWindowDirs = async (m, channelsDir) => { const key = `${chan}/${e.video}`; if (seen.has(key)) continue; seen.add(key); - const videoDir = path.join(channelsDir ?? GLOBAL_CHANNELS_DIR(), chan, "data", e.video); - out.push({ video: e.video, dir: path.join(videoDir, "clips") }); - - // AND THE WHOLE SOURCE, when somebody asked the editor for one. - // - // `full: true` puts the entire recording in the saved-video STORE, not in - // clips/ -- that is where big containers already live, with a retention - // rule that leaves an explicitly-requested one alone. The store is on - // another path (and possibly another drive), so the only way back to it is - // the pointer the editor leaves in the video dir. `pointer.dir` is exactly - // the shape rawCacheOf's SOURCE_MEDIA_RE branch handles. - // - // THE DURATION IS READ FROM THE CUES, and ONLY for a video that actually - // has a pointer. rawCacheOf needs a finite duration to treat the container - // as the window [0, duration]; the cue doc is the archive's own record of - // how long the recording is. Reading it is the expensive thing this module - // memoises against mtime (see below) -- so it is asked for the handful of - // videos somebody fetched a full source for, and never for the rest, which - // keeps the index load exactly as cheap as it was. - const pointer = await readJson0(path.join(videoDir, "saved-video.json")); - if (!pointer?.dir) continue; - const cues = await readCues(path.join(videoDir, "transcript.cues.json")); - const duration = Number(cues?.duration); - // No duration is no entry rather than a guess: without one the cache - // cannot say what the container holds, and claiming a span a file may not - // cover is the one failure mode worse than a cache miss. - if (Number.isFinite(duration) && duration > 0) { - out.push({ video: e.video, dir: pointer.dir, duration }); - } + const cueFile = path.join(videoDirOf(root, chan, e.video), "transcript.cues.json"); + const probe = async () => { + const duration = Number((await readCues(cueFile))?.duration); + return Number.isFinite(duration) && duration > 0 ? { duration } : null; + }; + const windows = await corpusWindowsOf({ video: e.video, slug: chan, channelsDir: root, probe }); + if (windows.length) out.push({ video: e.video, windows }); } return out; }; @@ -1156,7 +1142,7 @@ export async function readClipDetail(dir, { manifest = null } = {}) { // ONE listing of out/clips-raw for the whole project. This used to be two // readdirs PER CLIP -- the padded lookup and the full list -- so a forty-clip // cut paid eighty directory reads to draw one page. - const raw = await rawCacheOf(dir, { extraDirs: await clipWindowDirs(m, channelsDir) }); + const raw = await rawCacheOf(dir, { extraWindows: await clipWindowDirs(m, channelsDir) }); const segDirs = segmentDirs(path.join(dir, "out")); const segLists = await Promise.all(segDirs.map((d) => readdir(d).catch(() => []))); const segWhich = segLists.findIndex((l) => l.length); diff --git a/umtool/lib/report/raw-cache.mjs b/umtool/lib/report/raw-cache.mjs @@ -13,27 +13,6 @@ import { windowsFromNames, } from "umtool-report-to-video/build-video"; -// A window file inside a CORPUS clips dir. Un-prefixed, because that directory -// is already per-video: channels/<slug>/data/<id>/clips/<from>-<to>.mp4. The -// project-local cache prefixes with the video id because one flat directory -// holds every source. Same numbers, two namings, one predicate over both. -const BARE_WINDOW_RE = /^(\d+(?:\.\d+)?)-(\d+(?:\.\d+)?)\.mp4$/; - -function windowsFromBareNames(names, dir) { - const out = []; - for (const name of names) { - const m = BARE_WINDOW_RE.exec(name); - if (!m) continue; - const from = Number(m[1]); - const to = Number(m[2]); - if (!(to > from)) continue; - out.push({ name, path: path.join(dir, name), from, to }); - } - return out; -} - -const SOURCE_MEDIA_RE = /^source-media\.[A-Za-z0-9]+$/; - /** * The cache. The directory is keyed by VIDEO and a page asks about it per clip * -- the bench page did two readdirs per clip, and a forty-clip cut paid eighty @@ -48,30 +27,21 @@ const SOURCE_MEDIA_RE = /^source-media\.[A-Za-z0-9]+$/; * ready to judge. * * @param {string} projectDir - * @param {{ extraDirs?: { video: string, dir: string, duration?: number }[] }} [opts] - * Directories OUTSIDE the project that also hold windows of a given video -- - * today, the corpus clips dir the editor fetches into. Each is read once, - * here, so `windows()` stays synchronous and every existing caller is - * unchanged. A `duration` makes a whole `source-media.*` container in that - * directory count as the window [0, duration]: a full source contains every - * window of its video, which is exactly what the predicate needs to know. + * @param {{ extraWindows?: { video: string, windows: { name: string, path: string, from: number, to: number }[] }[] }} [opts] + * Windows OUTSIDE the project that also hold a given video -- the corpus's + * clip windows and a saved whole source, as `clipWindowDirs` lists them + * through the render's own sources.mjs, so the bench and the build agree on + * what is local. A whole container is the window [0, duration]: a full + * source contains every window of its video, which is exactly what the + * predicate needs to know. */ -export async function rawCacheOf(projectDir, { extraDirs = [] } = {}) { +export async function rawCacheOf(projectDir, { extraWindows = [] } = {}) { const rawDir = path.join(projectDir, "out", "clips-raw"); const names = await listRawNames(rawDir); const extra = new Map(); - for (const e of extraDirs) { - if (!e?.video || !e?.dir) continue; - const listed = await listRawNames(e.dir); - const found = windowsFromBareNames(listed, e.dir); - if (Number.isFinite(e.duration)) { - const whole = listed.find((n) => SOURCE_MEDIA_RE.test(n)); - if (whole) { - found.push({ name: whole, path: path.join(e.dir, whole), from: 0, to: Number(e.duration) }); - } - } - if (!found.length) continue; - extra.set(e.video, [...(extra.get(e.video) ?? []), ...found]); + for (const e of extraWindows) { + if (!e?.video || !e?.windows?.length) continue; + extra.set(e.video, [...(extra.get(e.video) ?? []), ...e.windows]); } const byVideo = new Map(); const windows = (video) => { diff --git a/umtool/lib/report/serve.mjs b/umtool/lib/report/serve.mjs @@ -69,7 +69,7 @@ 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, { extraDirs: await clipWindowDirs(m, channelsDir) }); + return rawCacheOf(project.dir, { extraWindows: await clipWindowDirs(m, channelsDir) }); } /** diff --git a/umtool/report-to-video/build-video.mjs b/umtool/report-to-video/build-video.mjs @@ -26,6 +26,12 @@ // Fetched clips are cached by (video, start, end); re-running is cheap and only // changed entries re-download. Delete out/clips-raw to force a refetch. // +// Before any fetch, a clip's source is looked for ON DISK (sources.mjs): this +// build's out/clips-raw, then the corpus's channels/<slug>/data/<id>/clips/ +// windows the editor fetched, then a saved whole source. Only a miss in all +// three goes to the network, and --no-network refuses the build up front if +// any clip would. +// // In the app: not used. On the CLI: // node umtool/report-to-video/build-video.mjs <manifest.json> [options] // @@ -33,6 +39,9 @@ // --out <dir> Output root (default: manifest dir + /out) // --variant <name> Which cut to build (sourced | full; default sourced) // --skip-fetch Fail instead of downloading anything not already cached +// --no-network Find every clip's source on disk first (sources.mjs: the +// raw cache, the corpus's clip windows, a saved source) and +// refuse the build, listing each clip, if any needs a fetch // --only <id> Build a single entry's segment and stop (for iterating) // --no-xfade Hard cuts instead of crossfades (much faster; concat copy) // --progress ndjson One JSON event per line instead of prose (for umtool) @@ -76,7 +85,12 @@ import { renderLedgerCard, ledgerRevealAt, ledgerSeconds, cardWidth, contentWidth, reservedFooterHeight, } from "./render-cards.mjs"; -import { createCueSource, siteOriginFromManifest } from "./cues.mjs"; +import { DEFAULT_CHANNELS_DIR, createCueSource, siteOriginFromManifest } from "./cues.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 { + SHADOW_CHANNELS, channelsDirFor, findContainingWindow, rawWindowName, resolveLocalSource, +} from "./sources.mjs"; import { createPostChannelResolver, resolvePostLinks } from "./post-links.mjs"; import { ensureWriteDir } from "../lib/report/storage.mjs"; // The deck (`render.chrome`): its geometry, validation and schedule are pure @@ -340,12 +354,13 @@ const HUMAN = { start: (e) => `${e.title} — ${e.entries} entr(ies)`, card: (e) => `card ${e.id}`, clip: (e) => `clip ${e.id} (${e.video}) §${e.section}${e.sectionEnter ? " ⟶" : ""}`, + // One line per clip, naming where its source came from (`source`: a + // sources.mjs kind, or "network"). fetch: (e) => - e.reuse - ? ` fetch ${e.id}: ${e.reuse} already covers ${hms(e.from)}–${hms(e.to)} — no download` - : e.cached - ? null - : ` fetch ${e.id}: ${e.video} ${hms(e.from)}–${hms(e.to)}`, + e.cached + ? ` source ${e.id}: ${e.source ?? "raw-cache"} ${e.reuse ?? "(exact window)"} covers ` + + `${hms(e.from)}–${hms(e.to)} — no download` + (e.height ? ` (${e.height}p)` : "") + : ` fetch ${e.id}: ${e.video} ${hms(e.from)}–${hms(e.to)} (network)`, snap: (e) => ` snap ${e.id}: ${e.start ? "start✓" : "start–"} ${e.end ? "end✓" : "end–"} ` + `(${Number(e.seconds).toFixed(1)}s)`, @@ -470,66 +485,15 @@ const ytdlpOk = (err) => err?.code === 101; // costs more than the 14s one that would also have done. The clip bench fetches // deliberately wide, and this is what makes that generous fetch become the // build's cache rather than a second one. -const WINDOW_RE = /^(\d+(?:\.\d+)?)-(\d+(?:\.\d+)?)$/; - -// A window read back from a 2 dp manifest can sit a hair outside the file that -// produced it; the same tolerance resolve-windows.mjs uses for the same reason. -export const WIN_EPS = 0.02; - -/** - * The question is asked FOUR times per page -- the build, the bench, the - * project page's pill and the walk -- so it is one predicate in one place, - * split into the I/O and the arithmetic. A caller with a directory listing - * already in hand (the bench reads clips-raw ONCE per request and answers for - * every clip) uses the pure halves; the two original functions are those halves - * composed, and behave exactly as they did. - */ -export async function listRawNames(rawDir) { - try { - return await readdir(rawDir); - } catch { - return []; - } -} - -/** The windows THIS video's files hold, parsed out of a directory listing. */ -export function windowsFromNames(names, rawDir, video) { - const prefix = `${video}_`; - const out = []; - for (const name of names) { - if (!name.startsWith(prefix) || !name.endsWith(".mp4")) continue; - // The remainder must be exactly `a-b`, which is what stops a video id that - // is a prefix of another (or one containing `_`) from claiming its files. - const m = WINDOW_RE.exec(name.slice(prefix.length, -4)); - if (!m) continue; - out.push({ name, path: path.join(rawDir, name), from: Number(m[1]), to: Number(m[2]) }); - } - return out; -} - -/** Does this cached file 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); -} - -/** The tightest cached file containing [from, to], or null. */ -export async function findContainingWindow(rawDir, video, from, to) { - return tightestContaining(await cachedWindowsFor(rawDir, video), from, to); -} +// The cache's naming and the containment predicate live in sources.mjs now -- +// beside the corpus tiers the build also reads before it fetches -- and are +// re-exported here under the names umtool has always imported them by. The +// question is asked FOUR times per page (the build, the bench, the project +// page's pill and the walk), so it is one predicate in one place. +export { + WIN_EPS, cachedWindowsFor, findContainingWindow, listRawNames, rawWindowName, + tightestContaining, windowContains, windowsFromNames, +} from "./sources.mjs"; // The yt-dlp argv for one clip window. The platform's fixed args come from // common's table (`platformArgsForUrl`) -- the same copy every editor spawn @@ -578,39 +542,129 @@ export function cutArgs(raw, a, b) { return ["-ss", a.toFixed(3), "-to", b.toFixed(3), "-i", raw]; } -async function fetchClip(entry, meta, render, rawDir, opts) { - // Deliberately over-fetch: the snapping pass below needs room on both sides to - // find a silence, and a clip that has no slack can only be cut where the cue - // happened to break — which is what put words in half in the first place. - // ONE EDGE AT A TIME. Extending a window is nearly always one-sided -- you - // want the sentence that follows, not another twenty seconds of the lead-in - // you already heard -- and a symmetric pad makes every "20 more" fetch twice - // what was asked for. `--pad` stays the shorthand that sets both. +/** + * The span a clip's source is needed over: its extent, widened by the fetch + * pad on each side. + * + * Deliberately over-fetched: the snapping pass needs room on both sides to + * find a silence, and a clip that has no slack can only be cut where the cue + * happened to break -- which is what put words in half in the first place. + * ONE EDGE AT A TIME. Extending a window is nearly always one-sided -- you + * want the sentence that follows, not another twenty seconds of the lead-in + * you already heard -- and a symmetric pad makes every "20 more" fetch twice + * what was asked for. `--pad` stays the shorthand that sets both. + */ +export function fetchSpan(entry, render, opts = {}) { const pad = opts.pad ?? render.fetchPad ?? 3.0; const padBefore = opts.padBefore ?? pad; const padAfter = opts.padAfter ?? pad; - const from = Math.max(0, entry.start - padBefore); - const to = entry.end + padAfter; + return { from: Math.max(0, entry.start - padBefore), to: entry.end + padAfter }; +} + +/** + * Where this build looks for media already on disk: the raw cache, the + * channels tree and the clip's channel. One per build; `resolve` is memoised, + * so `--no-network`'s up-front pass and the clip's own fetch probe a saved + * container once between them. + */ +export function localSources({ rawDir, channelsDir, channelSlug = null, probe } = {}) { + const memo = new Map(); + const slugOf = (entry) => entry.channel ?? channelSlug ?? null; + return { + rawDir, + channelsDir, + slugOf, + async resolve(entry, span, { exact = false } = {}) { + const slug = slugOf(entry); + const key = `${slug}/${entry.video}/${span.from}/${span.to}/${exact}`; + if (memo.has(key)) return memo.get(key); + const hit = await resolveLocalSource( + { video: entry.video, slug, from: span.from, to: span.to }, + { rawDir, channelsDir, exact, probe }, + ); + // Hits only: a miss is about to be fetched into the raw cache, and the + // next clip asking for the same span must find that file. + if (hit) memo.set(key, hit); + return hit; + }, + }; +} + +/** + * The clips of `entries` that no local source can serve, i.e. the ones that + * would need a fetch: `--no-network` refuses the build on any of them before + * a frame is rendered. `index` is the entry's position in the manifest's + * timeline. + */ +export async function clipsNeedingFetch(entries, timeline, render, opts, local) { + const out = []; + for (const entry of entries) { + if (!isClipEntry(entry)) continue; + const span = fetchSpan(entry, render, opts); + if (await local.resolve(entry, span, { exact: !!opts.noReuse })) continue; + out.push({ + index: timeline.indexOf(entry), + id: entry.id, + slug: local.slugOf(entry), + video: entry.video, + from: span.from, + to: span.to, + }); + } + return out; +} + +/** One line per clip the network would have to serve, for the refusal. */ +export function needsFetchMessage(missing) { + return ( + `--no-network: ${missing.length} clip(s) have no local source and would need a fetch:\n` + + missing + .map((m) => + ` timeline[${m.index}] ${m.id} ${m.slug ?? "(no channel)"}/${m.video} ` + + `${m.from.toFixed(2)}–${m.to.toFixed(2)}`) + .join("\n") + ); +} + +// The timeline's vocabulary is OPEN; the build loop treats everything it has +// no other branch for as a clip, and so does this. +const NON_CLIP_TYPES = new Set(["card", "teaser", "image", "scroll", "chart", "ledger"]); +const isClipEntry = (e) => !NON_CLIP_TYPES.has(e?.type); + +async function fetchClip(entry, meta, render, local, opts) { + const { rawDir } = local; + const { from, to } = fetchSpan(entry, render, opts); // Shared across variants, and deliberately so: this is the only expensive // thing in a build, and the two cuts overlap almost entirely. - const name = `${entry.video}_${from.toFixed(2)}-${to.toFixed(2)}.mp4`; + const name = rawWindowName(entry.video, from, to); const dest = path.join(rawDir, name); - if (await exists(dest)) { - EMIT("fetch", { id: entry.id, video: entry.video, from, to, cached: true }); - return { path: dest, fetchStart: from, cached: true }; - } - if (!opts.noReuse) { - const hit = await findContainingWindow(rawDir, entry.video, from, to); - if (hit) { - EMIT("fetch", { - id: entry.id, video: entry.video, from, to, cached: true, reuse: hit.name, - }); - // fetchStart is the CACHED file's start, not the requested one -- every cut - // downstream is expressed relative to it, so reuse is transparent. - return { path: hit.path, fetchStart: hit.from, cached: true }; - } + + // ON DISK FIRST. The raw cache, then the editor's corpus windows, then a + // saved whole source (sources.mjs). `--no-reuse` takes only the raw file + // named for exactly this span. fetchStart is the SOURCE's start, not the + // requested one -- every cut downstream is relative to it, so where the + // bytes came from is transparent. + const hit = await local.resolve(entry, { from, to }, { exact: !!opts.noReuse }); + if (hit) { + EMIT("fetch", { + id: entry.id, video: entry.video, from, to, cached: true, source: hit.kind, + ...(hit.kind !== "raw-cache" || hit.name !== name ? { reuse: hit.name } : {}), + local: hit.path, + window: [hit.windowStart, hit.windowEnd], + ...(hit.height ? { height: hit.height } : {}), + }); + return { + path: hit.path, + fetchStart: hit.windowStart, + cached: true, + source: hit.kind, + // A whole container is hours long and silence detection decodes what it + // is given: give it the span a fetch would have produced, no more. + ...(hit.kind === "raw-cache" ? {} : { scan: { from: from - hit.windowStart, to: to - hit.windowStart } }), + }; } + if (opts.noNetwork) throw new Error(`--no-network set and no local source covers ${name}`); if (opts.skipFetch) throw new Error(`--skip-fetch set and no cached window covers ${name}`); const maxH = render.maxHeightSource; @@ -633,7 +687,7 @@ async function fetchClip(entry, meta, render, rawDir, opts) { } }; - EMIT("fetch", { id: entry.id, video: entry.video, from, to, cached: false }); + EMIT("fetch", { id: entry.id, video: entry.video, from, to, cached: false, source: "network" }); let err = await attempt([]); // Rumble delivers HLS whose segments are named `.tar`, and ffmpeg 8's picky @@ -662,13 +716,21 @@ async function fetchClip(entry, meta, render, rawDir, opts) { if (!stray) throw new Error(`yt-dlp reported success but produced no file for ${entry.id}`); await rename(path.join(dir, stray), dest); } - return { path: dest, fetchStart: from, cached: false }; + return { path: dest, fetchStart: from, cached: false, source: "network" }; } // Parse ffmpeg's silencedetect output into [{s, e}] intervals, in seconds // relative to the start of the given file. -async function detectSilence(file, render) { +async function detectSilence(file, render, scan = null) { const minDur = render.silenceMinDur ?? 0.09; + // A source wider than the fetch would have been (a corpus window, a whole + // saved container) is measured over just that span: seeked, audio only, and + // the silences shifted back onto the file's clock. A raw-cache window is + // measured whole, as it always was, so no cached cut moves. + const input = scan + ? ["-ss", scan.from.toFixed(3), "-to", scan.to.toFixed(3), "-i", file, "-vn"] + : ["-i", file]; + const shift = scan ? scan.from : 0; // The threshold has to be RELATIVE to the clip, not absolute. These are game // streams: the gaps between words are full of game audio and music, so they @@ -678,7 +740,7 @@ async function detectSilence(file, render) { // and cut a few dB under its own mean instead. const { stderr: volLog } = await execFileP( FFMPEG, - ["-nostdin", "-i", file, "-af", "volumedetect", "-f", "null", "-"], + ["-nostdin", ...input, "-af", "volumedetect", "-f", "null", "-"], { maxBuffer: 1 << 26 }, ).catch((e) => ({ stderr: e.stderr ?? "" })); const meanMatch = (volLog ?? "").match(/mean_volume:\s*(-?[\d.]+) dB/); @@ -688,7 +750,7 @@ async function detectSilence(file, render) { // ffmpeg exits 0 here, so stderr comes back on the resolved result. const { stderr } = await execFileP( FFMPEG, - ["-nostdin", "-i", file, "-af", `silencedetect=noise=${noise.toFixed(1)}dB:d=${minDur}`, "-f", "null", "-"], + ["-nostdin", ...input, "-af", `silencedetect=noise=${noise.toFixed(1)}dB:d=${minDur}`, "-f", "null", "-"], { maxBuffer: 1 << 26 }, ).catch((e) => ({ stderr: e.stderr ?? "" })); const log = stderr ?? ""; @@ -697,10 +759,10 @@ async function detectSilence(file, render) { let open = null; for (const line of log.split("\n")) { const s = line.match(/silence_start:\s*(-?[\d.]+)/); - if (s) open = Number(s[1]); + if (s) open = Number(s[1]) + shift; const e = line.match(/silence_end:\s*(-?[\d.]+)/); if (e && open !== null) { - out.push({ s: open, e: Number(e[1]) }); + out.push({ s: open, e: Number(e[1]) + shift }); open = null; } } @@ -755,7 +817,7 @@ const encodeArgsVideoOnly = (render) => [ async function buildClipSegment(entry, meta, render, dirs, opts, chrome, nodes, provenance, framing = null) { const outDir = dirs.dir; - const { path: raw, fetchStart } = await fetchClip(entry, meta, render, dirs.rawDir, opts); + const { path: raw, fetchStart, scan } = await fetchClip(entry, meta, render, dirs.local, opts); const seg = path.join(outDir, "segments", `${entry.id}.mp4`); const pal = render.palette; const { width, height } = render; @@ -799,7 +861,7 @@ async function buildClipSegment(entry, meta, render, dirs, opts, chrome, nodes, const wantB = playTo - fetchStart; const win = render.snapWindow ?? 1.6; - const sil = await detectSilence(raw, render); + const sil = await detectSilence(raw, render, scan); const a = snap(wantA, sil, "start", win); const b = snap(wantB, sil, "end", win); // Never let snapping invert or collapse the window. @@ -3421,6 +3483,17 @@ export async function buildVideo({ manifestPath, opts = {}, out, only, fetchOnly const outRoot = out ?? path.join(path.dirname(path.resolve(manifestPath)), "out"); const dirs = variantPaths(outRoot, manifest.slug, variant); const outDir = dirs.dir; + // Where media may already be on disk. The channels tree is the one the clip + // bench reads for this project (sources.mjs channelsDirFor): the manifest's + // own `provenance.channelsDir`, a `.shadow-channels/` beside it, else the + // corpus the cue reader defaults to. + const channelsDir = opts.channelsDir ?? channelsDirFor(manifestDir, whole, { + shadowExists: await stat(path.join(manifestDir, SHADOW_CHANNELS)).then((st) => st.isDirectory(), () => false), + fallback: DEFAULT_CHANNELS_DIR, + }); + dirs.local = localSources({ + rawDir: dirs.rawDir, channelsDir, channelSlug: provenance?.channelSlug ?? null, probe: opts.probe, + }); // A project's out/ first, through ensureOutDir: with UMTOOL_MEDIA_DIR set it // is a link to the media root, and the recursive mkdirs below would @@ -3477,8 +3550,8 @@ export async function buildVideo({ manifestPath, opts = {}, out, only, fetchOnly }; } const meta = await videoMeta(entry.video, entry.channel ?? provenance.channelSlug, { siteChannel: entry.siteChannel, siteVideo: entry.siteVideo }); - const r = await fetchClip(entry, meta, render, dirs.rawDir, opts); - EMIT("done", { out: r.path, fetchStart: r.fetchStart, cached: r.cached }); + const r = await fetchClip(entry, meta, render, dirs.local, opts); + EMIT("done", { out: r.path, fetchStart: r.fetchStart, cached: r.cached, source: r.source }); return { out: r.path, failures: [] }; } @@ -3670,6 +3743,14 @@ export async function buildVideo({ manifestPath, opts = {}, out, only, fetchOnly return { out: dirs.final, failures: [] }; } + // `--no-network`: every clip's source is found on disk BEFORE anything is + // rendered, and a build that would have to fetch even one is refused here, + // naming each -- not twenty minutes in, at the first one. + if (opts.noNetwork) { + const missing = await clipsNeedingFetch(entries, whole.timeline ?? [], render, opts, dirs.local); + if (missing.length) throw new Error(needsFetchMessage(missing)); + } + EMIT("start", { title: manifest.title, entries: entries.length, out: outDir }); for (let i = 0; i < entries.length; i += 1) { const entry = entries[i]; @@ -3941,7 +4022,7 @@ async function main() { console.error( "usage: build-video.mjs <manifest.json> [--out <dir>] [--variant sourced|full]\n" + " [--only <id>] [--fetch-only <id>]\n" + - " [--pad <s>] [--pad-before <s>] [--pad-after <s>] [--skip-fetch] [--no-xfade] [--no-chapters] [--chapters-only]\n" + + " [--pad <s>] [--pad-before <s>] [--pad-after <s>] [--skip-fetch] [--no-network] [--no-xfade] [--no-chapters] [--chapters-only]\n" + " [--progress ndjson] [--continue-on-error] [--no-reuse]\n" + " [--no-rail] [--rail-only] [--preview <start> <dur>]\n" + " [--chrome-only] [--no-chrome] [--chrome-preview <at> <dur>] (render.chrome, the deck)\n" + @@ -3962,6 +4043,7 @@ async function main() { const opts = { variant: flag("--variant") ?? "sourced", skipFetch: argv.includes("--skip-fetch"), + noNetwork: argv.includes("--no-network"), continueOnError: argv.includes("--continue-on-error"), noXfade: argv.includes("--no-xfade"), noChapters: argv.includes("--no-chapters"), diff --git a/umtool/report-to-video/package.json b/umtool/report-to-video/package.json @@ -32,6 +32,7 @@ "./render-cards": "./render-cards.mjs", "./resolve-windows": "./resolve-windows.mjs", "./shoot-page": "./shoot-page.mjs", + "./sources": "./sources.mjs", "./verify-build": "./verify-build.mjs" } } diff --git a/umtool/report-to-video/sources.mjs b/umtool/report-to-video/sources.mjs @@ -0,0 +1,359 @@ +// sources.mjs — where a clip's media comes from when it is ALREADY ON DISK. +// +// A render needs a few seconds of a source around each clip. Those seconds are +// usually somewhere local already, in one of three places, and fetching them +// again is the one expensive thing a build does. So before anything asks the +// network, the build asks this module, in this order: +// +// 1. raw-cache the build's own `out/clips-raw/<video>_<from>-<to>.mp4` +// 2. corpus-window the editor's window cache, +// `channels/<slug>/data/<id>/clips/<from>-<to>.<ext>` +// 3. 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]. +// +// A window qualifies only if it holds the REQUESTED span whole -- the clip plus +// the build's fetch pad -- to WIN_EPS. Within a tier the tightest wins (the +// build decodes what it is handed); across tiers the order above wins, so a +// file this project already cut from keeps being the one it cuts from. +// +// The tiers are a list, not a chain of ifs: each is `{kind, windows(ctx)}`, and +// the next kind of local source is one more entry. 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 clip nothing nearer could serve. +// +// 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 build falls through to the next tier. +// +// 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); + +/** The source kinds, in the order they are consulted. */ +export const SOURCE_KINDS = ["raw-cache", "corpus-window", "saved-video"]; + +// 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 +// numbers and nothing else. + +/** The raw-cache file name for a window of `video`. The build's cache key. */ +export function rawWindowName(video, from, to) { + return `${video}_${from.toFixed(2)}-${to.toFixed(2)}.mp4`; +} + +/** A directory listing, or [] for a directory that is not there. */ +export async function listRawNames(rawDir) { + try { + return await readdir(rawDir); + } catch { + return []; + } +} + +/** The windows THIS video's files hold, parsed out of a clips-raw listing. */ +export function windowsFromNames(names, rawDir, video) { + const prefix = `${video}_`; + const out = []; + for (const name of names) { + if (!name.startsWith(prefix) || !name.endsWith(".mp4")) continue; + // The remainder must be exactly `a-b`, which is what stops a video id that + // is a prefix of another (or one containing `_`) from claiming its files. + const m = WINDOW_RE.exec(name.slice(prefix.length, -4)); + if (!m) continue; + out.push({ name, path: path.join(/* turbopackIgnore: true */ rawDir, name), from: Number(m[1]), to: Number(m[2]) }); + } + 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); +} + +/** The tightest cached file containing [from, to], or null. */ +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; +} + +/** + * 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 +// apply (no rawDir, no channelsDir or slug) lists nothing. + +async function rawCacheWindows({ video, rawDir }) { + if (!rawDir) return []; + 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; +} + +export const TIERS = [ + { kind: "raw-cache", windows: rawCacheWindows }, + { kind: "corpus-window", windows: corpusWindows }, + { kind: "saved-video", windows: savedVideoWindows }, +]; + +const asSource = (kind, w) => ({ + kind, + path: w.path, + name: w.name, + windowStart: w.from, + windowEnd: w.to, + ...(w.height ? { height: w.height } : {}), +}); + +/** + * The local source for one span of one video, or null when nothing on disk + * holds it and only a fetch would. + * + * @param {{ video: string, slug?: string|null, from: number, to: number }} want + * `from`/`to` are the PADDED span -- what the build would otherwise fetch. + * @param {{ rawDir?: string|null, channelsDir?: string|null, exact?: boolean, + * probe?: (file: string) => Promise<{duration: number, height?: number}|null>, + * kinds?: string[] }} [config] + * `exact` (`--no-reuse`) accepts only the raw-cache file named for exactly + * this span. `kinds` narrows the tiers consulted (default: all, in order). + * @returns {Promise<null | { kind: string, path: string, name: string, + * windowStart: number, windowEnd: number, height?: number }>} + */ +export async function resolveLocalSource(want, config = {}) { + const { video, slug = null, from, to } = want; + const { rawDir = null, channelsDir = null, exact = false, probe, kinds = null } = config; + if (!video || !Number.isFinite(from) || !Number.isFinite(to)) return null; + + if (exact) { + if (!rawDir) return null; + const name = rawWindowName(video, from, to); + const p = path.join(/* turbopackIgnore: true */ rawDir, name); + return (await present(p)) ? asSource("raw-cache", { name, path: p, from, to }) : null; + } + + const ctx = { video, slug, rawDir, channelsDir, probe }; + for (const tier of TIERS) { + if (kinds && !kinds.includes(tier.kind)) 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; +} + +/** + * Every window of a video OUTSIDE the project -- the corpus tiers -- each + * tagged with its `kind` and checked to be there. What the clip bench folds + * into its view of the raw cache, so the bench's "fetched" and the render's + * "local" are the same set. + * + * `probe` decides a whole container's span. The bench passes a cheap one (the + * cue doc's duration) and is asked only for a video that has a container. + */ +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") continue; + for (const w of await tier.windows(ctx)) { + if (await present(w.path)) out.push({ ...w, kind: tier.kind }); + } + } + return out; +} + +// ---- which channels tree ------------------------------------------------------- + +/** The conventional name make-shadow-channels.sh builds. */ +export const SHADOW_CHANNELS = ".shadow-channels"; + +/** + * Which channels directory a project reads: `provenance.channelsDir` (resolved + * against the project dir), else a `.shadow-channels/` the project built, else + * `fallback` (the global corpus). The bench and the render both ask this, so + * they look for local media in the same tree. + */ +export function channelsDirFor(dir, manifest, { shadowExists = false, fallback = null } = {}) { + const declared = manifest?.provenance?.channelsDir; + if (declared) return path.resolve(/* turbopackIgnore: true */ dir, declared); + if (shadowExists) return path.join(/* turbopackIgnore: true */ dir, SHADOW_CHANNELS); + return fallback; +}