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:
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 });
}