Archilyzer · Source

archilyzer

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

commit 1ffdaeb52ae1b2907bf540707f9128c1982fa341
parent 7e814d3bce49b921958265d2695c4b1126d27f92
Author: I Mean I'm Just Saying <imeanimjustsaying@kiwifarms.st>
Date:   Sun,  4 Oct 2026 18:34:03 -0400

report-to-video: --no-network refuses a clip whose picture is missing even when its sound is on disk; --audio-fallback plays it under a poster

sources.mjs `audioUse` names why the audio tier may serve a clip:
"audio-only" (audioOnly, render.preferLocalAudio, a feed platform, a record
with no page) or "audio-fallback" (a clip that should have a picture, no
network, and --audio-fallback). Without the flag, --no-network and
--skip-fetch list such a clip as needing a fetch, with a note that its sound
is on disk, so --no-network still proves every picture is local.
planLocalSources returns {missing, audio, audioFallback} and reads a clip's
record only when its sound is all that is here; the build and the log name
audio-fallback apart from audio-only.

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

Diffstat:
Mumtool/report-to-video/build-video.mjs | 99+++++++++++++++++++++++++++++++++++++++++++++++++++++++++----------------------
Mumtool/report-to-video/sources.mjs | 47++++++++++++++++++++++++++++++-----------------
Mumtool/report-to-video/sources.test.mjs | 105+++++++++++++++++++++++++++++++++++++++++++++++++++++++++----------------------
3 files changed, 178 insertions(+), 73 deletions(-)

diff --git a/umtool/report-to-video/build-video.mjs b/umtool/report-to-video/build-video.mjs @@ -29,7 +29,7 @@ // 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 -- and, by -// sources.mjs's audioAllowed rule, the recording's sound alone, which plays +// sources.mjs's audioUse rule, the recording's sound alone, which plays // under a poster. Only a miss in all of them goes to the network, and // --no-network refuses the build up front if any clip would. A clip with its // own `src` (a file beside the manifest, local-media.mjs) is its own source. @@ -43,7 +43,11 @@ // --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 +// refuse the build, listing each clip, if any needs a fetch. +// A clip that should have a picture and has only its sound +// on disk is one that needs a fetch. +// --audio-fallback With --no-network or --skip-fetch: play such a clip from +// its sound under a poster instead (logged audio-fallback) // --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) @@ -91,7 +95,7 @@ import { DEFAULT_CHANNELS_DIR, createCueSource, siteOriginFromManifest } from ". // 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, WIN_EPS as SRC_EPS, audioAllowed, channelsDirFor, findContainingWindow, rawWindowName, + SHADOW_CHANNELS, WIN_EPS as SRC_EPS, audioUse, channelsDirFor, findContainingWindow, rawWindowName, resolveLocalSource, } from "./sources.mjs"; // A clip whose media is a file beside the manifest (`src`, `cues`). @@ -367,7 +371,8 @@ const HUMAN = { 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)` : "") + - (e.source === "audio" ? " — audio only, under a poster" : "") + (e.source === "audio" ? " — audio only, under a poster" : "") + + (e.source === "audio-fallback" ? " — AUDIO-FALLBACK: the picture is not on disk; a poster plays" : "") : ` 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–"} ` + @@ -679,7 +684,7 @@ export function localSources({ rawDir, channelsDir, channelSlug = null, probe } rawDir, channelsDir, slugOf, - // `audio` admits the audio tier: sources.mjs `audioAllowed` decides it. + // `audio` admits the audio tier: sources.mjs `audioUse` decides it. async resolve(entry, span, { exact = false, audio = false } = {}) { const slug = slugOf(entry); const key = `${slug}/${entry.video}/${span.from}/${span.to}/${exact}/${audio}`; @@ -702,20 +707,28 @@ const networkOn = (opts) => !opts.noNetwork && !opts.skipFetch; /** * Where each clip of `entries` would come from without the network: `missing` * are the ones no local source serves -- `--no-network` refuses the build on - * any of them before a frame is rendered -- and `audio` the ones only their - * sound serves, which play under a poster. `index` is the entry's position in - * the manifest's timeline. A `src` clip is its own source and is in neither. + * any of them before a frame is rendered -- `audio` the ones with no picture + * to fetch that their sound serves, and `audioFallback` the ones that SHOULD + * have a picture and play from their sound only because `--audio-fallback` + * said so. Both of the last two play under a poster. `index` is the entry's + * position in the manifest's timeline. A `src` clip is its own source and is + * in none of them. + * + * `metaOf(entry)` is the clip's record, or null (the platform and page decide + * audio-only; without it, only the entry and render can). */ -export async function planLocalSources(entries, timeline, render, opts, local) { +export async function planLocalSources(entries, timeline, render, opts, local, metaOf = null) { const missing = []; const audio = []; + const audioFallback = []; for (const entry of entries) { if (!isClipEntry(entry) || hasLocalMedia(entry)) continue; const span = fetchSpan(entry, render, opts); - const hit = await local.resolve(entry, span, { - exact: !!opts.noReuse, - audio: audioAllowed({ entry, render, network: networkOn(opts) }), - }); + const exact = !!opts.noReuse; + const ask = (meta) => + audioUse({ entry, meta, render, network: networkOn(opts), fallback: !!opts.audioFallback }); + let use = ask(null); + let hit = await local.resolve(entry, span, { exact, audio: use !== null }); const row = { // By id: a variant's view may be a copy of the manifest's entry. index: timeline.findIndex((e) => e === entry || (entry.id != null && e?.id === entry.id)), @@ -725,10 +738,31 @@ export async function planLocalSources(entries, timeline, render, opts, local) { from: span.from, to: span.to, }; - if (!hit) missing.push(row); - else if (hit.kind === "audio") audio.push({ ...row, local: hit.name }); + // Only the sound is here, and nothing has called the clip audio-only yet: + // its record may (a feed, or no page to fetch from). Read only for such a + // clip -- a lookup per clip would be a lookup per clip for nothing. + if (metaOf && use !== "audio-only" && (!hit || hit.kind === "audio")) { + const sound = hit ?? (await local.resolve(entry, span, { exact, audio: true })); + if (sound?.kind === "audio") { + if (ask(await metaOf(entry)) === "audio-only") { + use = "audio-only"; + hit = sound; + } else if (!hit && !networkOn(opts)) { + // Its picture is not here but its sound is: --audio-fallback would play it. + row.soundOnDisk = sound.name; + } + } + } else if (!metaOf && !hit && !networkOn(opts)) { + const sound = await local.resolve(entry, span, { exact, audio: true }); + if (sound?.kind === "audio") row.soundOnDisk = sound.name; + } + if (!hit) { + missing.push(row); + } else if (hit.kind === "audio") { + (use === "audio-fallback" ? audioFallback : audio).push({ ...row, local: hit.name }); + } } - return { missing, audio }; + return { missing, audio, audioFallback }; } /** planLocalSources' `missing`: the clips that would need a fetch. */ @@ -737,9 +771,11 @@ export async function clipsNeedingFetch(entries, timeline, render, opts, local) } /** One line per clip that plays from its sound alone, for the log. */ -export function audioOnlyMessage(audio) { +export function audioOnlyMessage(audio, { fallback = false } = {}) { return ( - `${audio.length} clip(s) play from audio only (a poster where the picture would be):\n` + + (fallback + ? `--audio-fallback: ${audio.length} clip(s) whose picture is not on disk play from their sound (audio-fallback, a poster):\n` + : `${audio.length} clip(s) play from audio only (a poster where the picture would be):\n`) + audio .map((m) => ` timeline[${m.index}] ${m.id} ${m.slug ?? "(no channel)"}/${m.video} ${m.local}`) .join("\n") @@ -753,7 +789,8 @@ export function needsFetchMessage(missing) { missing .map((m) => ` timeline[${m.index}] ${m.id} ${m.slug ?? "(no channel)"}/${m.video} ` + - `${m.from.toFixed(2)}–${m.to.toFixed(2)}`) + `${m.from.toFixed(2)}–${m.to.toFixed(2)}` + + (m.soundOnDisk ? ` (only its sound, ${m.soundOnDisk}, is on disk: --audio-fallback plays it under a poster)` : "")) .join("\n") ); } @@ -793,13 +830,16 @@ async function fetchClip(entry, meta, render, local, opts) { // 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. - // The audio tier only by audioAllowed's rule: before the network for a clip - // with no picture to fetch, otherwise only when there is no network to ask. - const audio = audioAllowed({ entry, meta, render, network: networkOn(opts) }); - const hit = await local.resolve(entry, { from, to }, { exact: !!opts.noReuse, audio }); + // The audio tier only by audioUse's rule: before the network for a clip + // with no picture to fetch; for one that should have a picture, only when + // there is no network to ask AND --audio-fallback said so. + const use = audioUse({ entry, meta, render, network: networkOn(opts), fallback: !!opts.audioFallback }); + const hit = await local.resolve(entry, { from, to }, { exact: !!opts.noReuse, audio: use !== null }); + // What the log calls it: a fallback is not the same fact as an audio-only clip. + const kind = hit?.kind === "audio" && use === "audio-fallback" ? "audio-fallback" : hit?.kind; if (hit) { EMIT("fetch", { - id: entry.id, video: entry.video, from, to, cached: true, source: hit.kind, + id: entry.id, video: entry.video, from, to, cached: true, source: kind, ...(hit.kind !== "raw-cache" || hit.name !== name ? { reuse: hit.name } : {}), local: hit.path, window: [hit.windowStart, hit.windowEnd], @@ -809,7 +849,7 @@ async function fetchClip(entry, meta, render, local, opts) { path: hit.path, fetchStart: hit.windowStart, cached: true, - source: hit.kind, + source: 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 } }), @@ -3775,11 +3815,15 @@ export async function buildVideo({ manifestPath, opts = {}, out, only, fetchOnly // rebuild no segment fetch nothing, so they are not asked.) if (opts.noNetwork && !opts.chaptersOnly && !opts.railOnly && !opts.chromeOnly && !opts.chromePreview) { const want = manifest.timeline.filter((e) => !only || e.id === only); - const { missing, audio } = await planLocalSources(want, whole.timeline ?? [], render, opts, dirs.local); + const metaOf = (e) => clipMeta(e, provenance).catch(() => null); + const { missing, audio, audioFallback } = await planLocalSources( + want, whole.timeline ?? [], render, opts, dirs.local, metaOf, + ); if (missing.length) throw new Error(needsFetchMessage(missing)); // Satisfied, not missed: said once, up front, so a cut that will show a // poster instead of a face is never a surprise at the end. if (audio.length) EMIT("note", { message: audioOnlyMessage(audio) }); + if (audioFallback.length) EMIT("note", { message: audioOnlyMessage(audioFallback, { fallback: true }) }); } // Footer chrome is shared by every clip, so build it once up front. The deck @@ -4234,7 +4278,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-network] [--no-xfade] [--no-chapters] [--chapters-only]\n" + + " [--pad <s>] [--pad-before <s>] [--pad-after <s>] [--skip-fetch] [--no-network] [--audio-fallback] [--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" + @@ -4256,6 +4300,7 @@ async function main() { variant: flag("--variant") ?? "sourced", skipFetch: argv.includes("--skip-fetch"), noNetwork: argv.includes("--no-network"), + audioFallback: argv.includes("--audio-fallback"), continueOnError: argv.includes("--continue-on-error"), noXfade: argv.includes("--no-xfade"), noChapters: argv.includes("--no-chapters"), diff --git a/umtool/report-to-video/sources.mjs b/umtool/report-to-video/sources.mjs @@ -17,13 +17,19 @@ // where the picture would be. // // THE AUDIO TIER IS ASKED ONLY WHEN THE CALLER SAYS SO (`audio: true`), and -// `audioAllowed` below is the one rule for when: a clip with no picture to -// fetch (`audioOnly: true`, `render.preferLocalAudio`, or a record whose -// platform carries none), or a build that could not fetch one anyway -// (`--no-network`, `--skip-fetch`, a record with no page to fetch from). Being -// last, it never beats a picture already on disk; asked before the network, it -// is what plays a podcast. Many transcribed videos keep an `audio.mp3`, so -// without the rule a cut would quietly lose its pictures to it. +// `audioUse` below is the one rule for when, and under which name: +// "audio-only" the clip has no picture to fetch -- `audioOnly: true`, +// `render.preferLocalAudio`, a record whose platform +// carries none, or a record with no page to fetch from. +// Asked before the network: it is what plays a podcast. +// "audio-fallback" the clip SHOULD have a picture, the build may not fetch +// one (`--no-network`, `--skip-fetch`), and the operator +// said `--audio-fallback`. Without that flag such a clip is +// a miss, so `--no-network` still proves every picture is +// on disk. +// Being last, it never beats a picture already on disk. Many transcribed +// videos keep an `audio.mp3`, so without the rule a cut would quietly lose its +// pictures to it. // // 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 @@ -63,21 +69,28 @@ export const SOURCE_KINDS = ["raw-cache", "corpus-window", "saved-video", "audio export const AUDIO_ONLY_PLATFORMS = new Set(["podcast", "feed", "rss"]); /** - * Should the audio tier be consulted for this clip? (See the note at the top.) + * May the audio tier serve this clip, and as what? (See the note at the top.) * * @param {{ entry?: any, meta?: { platform?: string|null, webpageUrl?: string|null }|null, - * render?: any, network?: boolean }} args - * `network` is whether this build may fetch at all; `meta` is the record, - * when it has been read (without it, only the entry and render can say). + * render?: any, network?: boolean, fallback?: boolean }} args + * `network` is whether this build may fetch at all; `fallback` is + * `--audio-fallback`; `meta` is the record, when it has been read (without + * it, only the entry and render can say). + * @returns {"audio-only" | "audio-fallback" | null} */ -export function audioAllowed({ entry = null, meta = null, render = null, network = true } = {}) { - if (entry?.audioOnly === true || render?.preferLocalAudio === true) return true; - if (AUDIO_ONLY_PLATFORMS.has(String(meta?.platform ?? "").toLowerCase())) return true; - if (!network) return true; +export function audioUse({ entry = null, meta = null, render = null, network = true, fallback = false } = {}) { + if (entry?.audioOnly === true || render?.preferLocalAudio === true) return "audio-only"; + if (AUDIO_ONLY_PLATFORMS.has(String(meta?.platform ?? "").toLowerCase())) return "audio-only"; // A record with no page has nothing a fetch could get. - return meta != null && !meta.webpageUrl; + if (meta != null && !meta.webpageUrl) return "audio-only"; + // A picture exists somewhere: its sound stands in only when asked to. + if (!network && fallback) return "audio-fallback"; + return null; } +/** 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. @@ -356,7 +369,7 @@ const asSource = (kind, w) => ({ * 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). - * `audio` admits the audio tier (default false; see `audioAllowed`). + * `audio` admits the audio tier (default false; see `audioUse`). * @returns {Promise<null | { kind: string, path: string, name: string, * windowStart: number, windowEnd: number, height?: number }>} */ diff --git a/umtool/report-to-video/sources.test.mjs b/umtool/report-to-video/sources.test.mjs @@ -12,6 +12,7 @@ import { SOURCE_KINDS, TIERS, audioAllowed, + audioUse, channelsDirFor, corpusWindowsOf, rawWindowName, @@ -435,22 +436,30 @@ test("audio: the bench's corpus windows leave the sound out", async () => { } }); -test("audioAllowed: no picture to fetch, or no fetch to make", () => { +test("audioUse: audio-only when there is no picture to fetch, audio-fallback only when asked", () => { const web = { platform: "youtube", webpageUrl: "https://example.invalid/v" }; // A clip with a picture somewhere, and a network to get it: never the sound. - assert.equal(audioAllowed({ entry: {}, meta: web, render: {} }), false); - assert.equal(audioAllowed({ entry: {}, render: {} }), false, "no record read yet"); - // No picture to fetch. - assert.equal(audioAllowed({ entry: { audioOnly: true }, meta: web }), true); - assert.equal(audioAllowed({ entry: {}, meta: web, render: { preferLocalAudio: true } }), true); - assert.equal(audioAllowed({ entry: {}, meta: { ...web, platform: "podcast" } }), true); - assert.equal(audioAllowed({ entry: {}, meta: { ...web, platform: "RSS" } }), true); - // No fetch to make: --no-network / --skip-fetch, or a record with no page. - assert.equal(audioAllowed({ entry: {}, meta: web, network: false }), true); - assert.equal(audioAllowed({ entry: {}, meta: { platform: "youtube", webpageUrl: null } }), true); + assert.equal(audioUse({ entry: {}, meta: web, render: {} }), null); + assert.equal(audioUse({ entry: {}, render: {} }), null, "no record read yet"); + // No picture to fetch: audio-only, network or not. + assert.equal(audioUse({ entry: { audioOnly: true }, meta: web }), "audio-only"); + assert.equal(audioUse({ entry: {}, meta: web, render: { preferLocalAudio: true } }), "audio-only"); + assert.equal(audioUse({ entry: {}, meta: { ...web, platform: "podcast" } }), "audio-only"); + assert.equal(audioUse({ entry: {}, meta: { ...web, platform: "RSS" } }), "audio-only"); + // A record with no page has nothing a fetch could get. + assert.equal(audioUse({ entry: {}, meta: { platform: "youtube", webpageUrl: null } }), "audio-only"); + // A clip that SHOULD have a picture, with no network: still a miss... + assert.equal(audioUse({ entry: {}, meta: web, network: false }), null); + // ...unless --audio-fallback says to play its sound -- and only without a network. + assert.equal(audioUse({ entry: {}, meta: web, network: false, fallback: true }), "audio-fallback"); + assert.equal(audioUse({ entry: {}, meta: web, network: true, fallback: true }), null); + // An audio-only clip stays audio-only under the flag. + assert.equal(audioUse({ entry: { audioOnly: true }, network: false, fallback: true }), "audio-only"); + assert.equal(audioAllowed({ entry: {}, meta: web, network: false }), false); + assert.equal(audioAllowed({ entry: {}, meta: web, network: false, fallback: true }), true); }); -test("planLocalSources: an audio-only clip is satisfied, and named as such, never a miss", async () => { +test("planLocalSources: audio-only is satisfied; a video clip with only its sound is a miss unless --audio-fallback", async () => { const t = await tree(); try { await touch(path.join(t.videoDir, "audio.mp3")); @@ -462,51 +471,89 @@ test("planLocalSources: an audio-only clip is satisfied, and named as such, neve const local = localSources({ rawDir: t.rawDir, channelsDir: t.channelsDir, channelSlug: SLUG, probe: fakeProbe(600).probe }); const render = { fetchPad: 3 }; - // --no-network: the sound serves c1 and c3; c2 has nothing. + // --no-network: c3 (audioOnly) plays from its sound. c1 SHOULD have a + // picture and only its sound is here: a miss, with a hint. c2 has nothing. let plan = await planLocalSources(timeline, timeline, render, { noNetwork: true }, local); - assert.deepEqual(plan.missing.map((m) => m.id), ["c2"]); - assert.deepEqual(plan.audio.map((m) => [m.index, m.id, m.local]), [[0, "c1", "audio.mp3"], [2, "c3", "audio.mp3"]]); + assert.deepEqual(plan.missing.map((m) => [m.id, m.soundOnDisk]), [["c1", "audio.mp3"], ["c2", undefined]]); + assert.deepEqual(plan.audio.map((m) => [m.index, m.id, m.local]), [[2, "c3", "audio.mp3"]]); + assert.deepEqual(plan.audioFallback, []); assert.deepEqual(await clipsNeedingFetch(timeline, timeline, render, { noNetwork: true }, local), plan.missing); - const msg = audioOnlyMessage(plan.audio); - assert.match(msg, /2 clip\(s\) play from audio only/); + let msg = needsFetchMessage(plan.missing); + assert.match(msg, /timeline\[0\] c1 .*only its sound, audio\.mp3, is on disk: --audio-fallback plays it/); + assert.doesNotMatch(msg.split("\n").find((l) => / c2 /.test(l)), /--audio-fallback/); + // --skip-fetch is the same question. + plan = await planLocalSources(timeline, timeline, render, { skipFetch: true }, local); + assert.deepEqual(plan.missing.map((m) => m.id), ["c1", "c2"]); + + // --no-network --audio-fallback: c1 plays from its sound, named as a fallback. + plan = await planLocalSources(timeline, timeline, render, { noNetwork: true, audioFallback: true }, local); + assert.deepEqual(plan.missing.map((m) => m.id), ["c2"]); + assert.deepEqual(plan.audio.map((m) => m.id), ["c3"]); + assert.deepEqual(plan.audioFallback.map((m) => [m.index, m.id, m.local]), [[0, "c1", "audio.mp3"]]); + msg = audioOnlyMessage(plan.audioFallback, { fallback: true }); + assert.match(msg, /--audio-fallback: 1 clip\(s\) whose picture is not on disk play from their sound \(audio-fallback/); assert.match(msg, /timeline\[0\] c1 {2}demo-channel\/abc123 {2}audio\.mp3/); + assert.match(audioOnlyMessage(plan.audio), /1 clip\(s\) play from audio only/); + + // A record that says there is no picture (a feed, or no page) makes c1 + // audio-only, flag or not; it is read only for a clip whose sound is all there is. + const asked = []; + const metaOf = async (e) => { + asked.push(e.id); + return { platform: "podcast", webpageUrl: null }; + }; + plan = await planLocalSources(timeline, timeline, render, { noNetwork: true }, local, metaOf); + assert.deepEqual(plan.audio.map((m) => m.id), ["c1", "c3"]); + assert.deepEqual(plan.missing.map((m) => m.id), ["c2"]); + assert.deepEqual(asked, ["c1"]); // With the network: only the clip that asked for its sound gets it before a fetch. plan = await planLocalSources(timeline, timeline, render, {}, local); assert.deepEqual(plan.missing.map((m) => m.id), ["c1", "c2"]); assert.deepEqual(plan.audio.map((m) => m.id), ["c3"]); - // ...and every clip of a cut that prefers local sound. + // ...and every clip of a cut that prefers local sound, network or not. plan = await planLocalSources(timeline, timeline, { ...render, preferLocalAudio: true }, {}, local); assert.deepEqual(plan.audio.map((m) => m.id), ["c1", "c3"]); + plan = await planLocalSources(timeline, timeline, { ...render, preferLocalAudio: true }, { noNetwork: true }, local); + assert.deepEqual(plan.audio.map((m) => m.id), ["c1", "c3"]); + assert.deepEqual(plan.audioFallback, []); } finally { await rm(t.root, { recursive: true, force: true }); } }); -test("buildVideo --no-network: an audio-only clip is not in the refusal", async () => { +test("buildVideo --no-network: a video clip with only its sound is refused, unless --audio-fallback", async () => { const t = await tree(); try { await touch(path.join(t.videoDir, "audio.mp3")); const manifestPath = path.join(t.root, "video.manifest.json"); + // No siteOrigin: the record lookup the plan makes for a sound-only clip + // stays on this machine (and finds nothing, which is "no record"). await writeFile(manifestPath, JSON.stringify({ slug: "demo", title: "Demo", - provenance: { channelSlug: SLUG, channelsDir: "channels", siteOrigin: "https://example.invalid" }, + provenance: { channelSlug: SLUG, channelsDir: "channels" }, render: { width: 1280, height: 720, fps: 30, fetchPad: 3 }, timeline: [ { id: "c1", type: "clip", video: VIDEO, start: 10, end: 20 }, { id: "c2", type: "clip", video: "def456", start: 1, end: 4 }, + { id: "c3", type: "clip", video: VIDEO, start: 30, end: 40, audioOnly: true }, ], })); - await assert.rejects( - buildVideo({ manifestPath, opts: { noNetwork: true, probe: fakeProbe(600).probe } }), - (err) => { - assert.match(err.message, /1 clip\(s\) have no local source/); - assert.match(err.message, /c2/); - assert.doesNotMatch(err.message, /c1/); - return true; - }, - ); + const probe = fakeProbe(600).probe; + await assert.rejects(buildVideo({ manifestPath, opts: { noNetwork: true, probe } }), (err) => { + assert.match(err.message, /2 clip\(s\) have no local source/); + assert.match(err.message, /timeline\[0\] c1 .*only its sound, audio\.mp3, is on disk/); + assert.match(err.message, /timeline\[1\] c2/); + assert.doesNotMatch(err.message, /c3/); + return true; + }); + await assert.rejects(buildVideo({ manifestPath, opts: { noNetwork: true, audioFallback: true, probe } }), (err) => { + assert.match(err.message, /1 clip\(s\) have no local source/); + assert.match(err.message, /c2/); + assert.doesNotMatch(err.message, /c1|c3/); + return true; + }); } finally { await rm(t.root, { recursive: true, force: true }); }