commit f9b78f01f3aa14feaca744d6befe0685c9839374
parent 7e814d3bce49b921958265d2695c4b1126d27f92
Author: I Mean I'm Just Saying <imeanimjustsaying@kiwifarms.st>
Date: Sun, 4 Oct 2026 18:43:01 -0400
Merge render-no-network-strict (--no-network refuses a clip whose picture is missing even when its sound is on disk; --audio-fallback plays it under a poster)
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Diffstat:
6 files changed, 294 insertions(+), 82 deletions(-)
diff --git a/editor/CHANGELOG.md b/editor/CHANGELOG.md
@@ -1,7 +1,7 @@
# Changelog
## [Unreleased]
-- **A report video can play a clip that has only sound, and a clip can be a file beside the manifest.** When a clip's source has no picture, `build-video.mjs` plays it under a poster: a card with the clip's channel, title and date, the size of the picture area, with the sound's waveform moving along its foot (`render.audioPoster.waveform: false` keeps it still). The segment matches every other one in size, frame rate and sound, and the header, footer and on-screen deck are drawn over it as over footage. A video's saved sound (`audio.mp3` and the like in its folder) is now a source the build can cut from, after every saved picture: before any download when the clip has no picture to fetch (`"audioOnly": true` on the clip, `"preferLocalAudio": true` in `render`, or a podcast or feed record), and otherwise only when nothing can be downloaded (`--no-network`, `--skip-fetch`, or a record with no page); `--no-network` lists such clips as playing from audio only instead of refusing them. A clip may also give `"src"` (a video or audio file) and `"cues"` (its transcript, either a `transcript.cues.json` or a `parakeet-stitch` transcript), both relative to the manifest, instead of a channel and video: it plays the whole file unless `start`/`end` cut inside it, `resolve-windows.mjs` widens it with those cues, it gets no QR unless it has a `citeUrl`, and a path that leaves the manifest's folder (or an absolute one, without `"allowAbsoluteSrc": true` in `render`), a missing file or an unreadable transcript stops the build before anything runs, naming the clip.
+- **A report video can play a clip that has only sound, and a clip can be a file beside the manifest.** When a clip's source has no picture, `build-video.mjs` plays it under a poster: a card with the clip's channel, title and date, the size of the picture area, with the sound's waveform moving along its foot (`render.audioPoster.waveform: false` keeps it still). The segment matches every other one in size, frame rate and sound, and the header, footer and on-screen deck are drawn over it as over footage. A video's saved sound (`audio.mp3` and the like in its folder) is now a source the build can cut from, after every saved picture: before any download when the clip has no picture to fetch (`"audioOnly": true` on the clip, `"preferLocalAudio": true` in `render`, a podcast or feed record, or a record with no page). A clip that should have a picture is not quietly played from its sound: with `--no-network` or `--skip-fetch`, one whose picture is missing still stops the build, listed as needing a download with a note that its sound is on disk, so `--no-network` still proves every picture is there. Add `--audio-fallback` to play such clips from their sound under the poster instead; they are listed apart (and logged as `audio-fallback`), and clips that have no picture to fetch are listed as playing from audio only rather than refused. A clip may also give `"src"` (a video or audio file) and `"cues"` (its transcript, either a `transcript.cues.json` or a `parakeet-stitch` transcript), both relative to the manifest, instead of a channel and video: it plays the whole file unless `start`/`end` cut inside it, `resolve-windows.mjs` widens it with those cues, it gets no QR unless it has a `citeUrl`, and a path that leaves the manifest's folder (or an absolute one, without `"allowAbsoluteSrc": true` in `render`), a missing file or an unreadable transcript stops the build before anything runs, naming the clip.
- **A report build cuts from media already on disk before it downloads anything, and `--no-network` makes sure it never does.** For each clip, `build-video.mjs` now looks, in order, in the project's own `out/clips-raw`, in the clip windows the editor fetched into the channel (`channels/<slug>/data/<id>/clips/`), and in a saved whole source video (through the saved-video store's pointer, or a `source-media` file still in the video's folder), and cuts from the first that holds the clip plus its fetch pad; only when none does is the window downloaded. A file that is a link to a drive that is not mounted counts as not there, and the next place is tried. The build prints one line per clip naming where its source came from (`raw-cache`, `corpus-window`, `saved-video`, or a network fetch). With `--no-network`, every clip's source is found before anything is rendered, and if any clip would need a download the build stops at once and lists each one (its position in the timeline, channel, video and the span it needs). umtool's clip bench reads the same three places, so a clip it shows as fetched is one the build cuts from without downloading.
- **umtool's report videos can show a highlighted sentence from a saved article.** `node umtool/report-to-video/shoot-page.mjs --page <saved page.html> --quote "<sentence>" --out <shot.png>` opens a web page saved to disk, finds the sentence in its text, highlights it and saves a PNG of the paragraph that holds it, ready to be a report manifest's `image` entry. `--batch <items.json> --out <dir>` does a list of `{ id, page, quote, context? }` at once and writes `<id>.png` for each plus a `results.json` recording each shot's crop, the matched text and the block it shot. The page is opened offline: nothing is fetched except files saved beside it, and its own scripts do not run unless `--js` is given. The sentence is found whether its quotes and apostrophes are curly or straight, across links and emphasis, and through non-breaking spaces, soft hyphens and line breaks in the page's source. A sentence that is not on the page is listed in `results.json` and on the terminal, and the run ends with an error rather than leaving it out. `--color` sets the highlight; `context` picks one occurrence of a sentence that appears more than once. On a page where a whole post is one block of paragraphs separated by line breaks, `--crop mark` (or an item's `"crop": "mark"`) shoots only the sentence's own lines and one whole line above and below (`--context-lines` sets how many) instead of the whole post; `results.json` records which crop each shot used.
- **A clip or whole-recording fetch can name the tallest source video it wants.** The MCP's `fetch_clip` takes `maxHeight`, `fetch-via-editor.mjs` takes `--max-height`, and the editor's fetch endpoint takes `maxHeight`: a whole number of pixels from 144 to 2160; anything else is refused before anything is fetched. A window is fetched at or under that height (720 when none is given, as before). A whole recording asked for at 720 or less is saved as the **Video 720p** quality, and above 720 at the original quality; with no height it follows the channel's, else the global, source video quality, as before. umtool's whole-source fetch from the clip bench now asks at the report's `render.maxHeightSource`. A file already on disk is returned as it is and never fetched again for a different height; the answer now gives its height (a window's is read from the file, a whole recording's from what its persist recorded) and says when it is taller than the height asked for.
diff --git a/umtool/report-to-video/README.md b/umtool/report-to-video/README.md
@@ -163,17 +163,27 @@ order, and the first place holding the clip's **padded** span (its extent plus
4. **`audio`** — the recording's sound alone, `data/<id>/audio.<ext>` (`mp3`,
then `m4a`, then `opus`, then any other: `aac`, `ogg`, `wav`, `flac`, an
audio `webm` or `mp4`), the window `[0, duration]`. Only when allowed, by one
- rule (`sources.mjs` `audioAllowed`):
- - **before the network** when the clip has no picture to fetch: the entry
- says `audioOnly: true`, the manifest says `render.preferLocalAudio: true`,
- or the record's `platform` is a feed (`podcast`, `feed`, `rss`);
- - **otherwise only when nothing could be fetched anyway**: `--no-network`,
- `--skip-fetch`, or a record with no page (`webpageUrl`) to fetch from.
+ rule (`sources.mjs` `audioUse`), which also names the use:
+ - **`audio-only`, before the network**, when the clip has no picture to
+ fetch: the entry says `audioOnly: true`, the manifest says
+ `render.preferLocalAudio: true`, the record's `platform` is a feed
+ (`podcast`, `feed`, `rss`), or the record has no page (`webpageUrl`) to
+ fetch from;
+ - **`audio-fallback`, only when asked**: a clip that SHOULD have a picture,
+ in a build that may not fetch one (`--no-network` or `--skip-fetch`),
+ with `--audio-fallback` given.
Being last, it never beats a picture already on disk. Without the rule a cut
would quietly lose its pictures to it: many transcribed videos keep an
- `audio.mp3`. Under `--no-network` an audio-only clip is satisfied — listed up
- front as playing from audio only, not as a miss.
+ `audio.mp3`. **`--no-network` still proves every picture is on disk**: a clip
+ that should have one and has only its sound is listed as needing a fetch,
+ with a note that its sound is there (`--audio-fallback` would play it). An
+ audio-only clip is satisfied — listed up front as playing from audio only,
+ not as a miss — and with `--audio-fallback` the fallbacks are listed
+ separately, and their `fetch` event's `source` is `"audio-fallback"`, not
+ `"audio"`. `planLocalSources` returns the three as `{missing, audio,
+ audioFallback}`; it reads a clip's record only when its sound is all that is
+ on disk.
Only a miss in all of them fetches. The channels tree is the one the clip bench
reads for the project: `provenance.channelsDir`, else a `.shadow-channels/`
diff --git a/umtool/report-to-video/audio-fallback.test.mjs b/umtool/report-to-video/audio-fallback.test.mjs
@@ -0,0 +1,97 @@
+// A corpus clip that SHOULD have a picture, with only its sound on disk:
+// `--no-network` refuses it, and `--no-network --audio-fallback` builds it
+// from the sound under a poster, logged as `audio-fallback`.
+//
+// A real encode, kept tiny (a three-second lavfi mp3, a 320x180 cut). Its own
+// file because the build's cue reader takes its corpus from CHANNELS_DIR when
+// the module loads, so the variable is set before the import.
+//
+// Run with: pnpm test:scripts
+import assert from "node:assert/strict";
+import { spawnSync } from "node:child_process";
+import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
+import os from "node:os";
+import path from "node:path";
+import test from "node:test";
+
+const have = ["ffmpeg", "ffprobe", "magick"].every((b) => spawnSync(b, ["-version"]).status === 0);
+const SLUG = "demo-channel";
+const VIDEO = "abc123";
+
+const root = await mkdtemp(path.join(os.tmpdir(), "rtv-audio-fallback-"));
+const channelsDir = path.join(root, "channels");
+process.env.CHANNELS_DIR = channelsDir;
+const { buildVideo, setProgressMode } = await import("./build-video.mjs");
+const { MONO_FONT_FILE } = await import("./brand.mjs");
+
+const PALETTE = { bg: "#15121c", fg: "#ece8f4", muted: "#9a93ad", accent: "#7c5cff", amber: "#f2b84b" };
+
+async function project() {
+ const videoDir = path.join(channelsDir, SLUG, "data", VIDEO);
+ await mkdir(videoDir, { recursive: true });
+ const r = spawnSync("ffmpeg", [
+ "-v", "error", "-y", "-f", "lavfi", "-i", "sine=f=220:r=44100:d=3",
+ "-c:a", "libmp3lame", "-b:a", "64k", path.join(videoDir, "audio.mp3"),
+ ]);
+ assert.equal(r.status, 0, String(r.stderr));
+ // A record WITH a page: this recording has a picture somewhere.
+ await writeFile(path.join(videoDir, "transcript.cues.json"), JSON.stringify({
+ id: VIDEO, title: "A recording with a picture", channel: "Demo Channel", uploadDate: "20260102",
+ duration: 3, platform: "youtube", webpageUrl: "https://example.invalid/watch?v=abc123",
+ cues: [{ start: 0.2, end: 2.8, text: "A tone, held." }],
+ }));
+ const manifestPath = path.join(root, "video.manifest.json");
+ await writeFile(manifestPath, JSON.stringify({
+ slug: "demo",
+ title: "Demo",
+ provenance: { channelSlug: SLUG },
+ render: {
+ width: 320, height: 180, fps: 30, audioRate: 48000, audioChannels: 2, preset: "ultrafast",
+ transition: 0, headerHeight: 24, palette: PALETTE, fontRegular: MONO_FONT_FILE, fetchPad: 0.2, qr: false,
+ },
+ timeline: [{ id: "c1", type: "clip", video: VIDEO, start: 0.5, end: 2.5 }],
+ }));
+ return manifestPath;
+}
+
+test("a video clip with only its sound: refused by --no-network, a poster with --audio-fallback", { skip: !have && "needs ffmpeg, ffprobe and magick" }, async (t) => {
+ t.after(() => rm(root, { recursive: true, force: true }));
+ const manifestPath = await project();
+
+ await assert.rejects(
+ buildVideo({ manifestPath, only: "c1", opts: { noNetwork: true } }),
+ /timeline\[0\] c1 .*only its sound, audio\.mp3, is on disk: --audio-fallback plays it/,
+ );
+
+ // The events, as umtool reads them.
+ const events = [];
+ const write = process.stdout.write.bind(process.stdout);
+ setProgressMode("ndjson");
+ process.stdout.write = (chunk, ...rest) => {
+ for (const line of String(chunk).split("\n")) {
+ if (line.startsWith("{")) events.push(JSON.parse(line));
+ }
+ return true;
+ };
+ let r;
+ try {
+ r = await buildVideo({ manifestPath, only: "c1", opts: { noNetwork: true, audioFallback: true } });
+ } finally {
+ process.stdout.write = write;
+ setProgressMode("human");
+ }
+ assert.equal(r.failures.length, 0);
+ const fetch = events.find((e) => e.ev === "fetch" && e.id === "c1");
+ assert.equal(fetch.source, "audio-fallback");
+ assert.equal(fetch.reuse, "audio.mp3");
+ assert.ok(events.some((e) => e.ev === "note" && /--audio-fallback: 1 clip\(s\)/.test(e.message)));
+
+ const p = spawnSync("ffprobe", ["-v", "error", "-show_streams", "-of", "json", r.out]);
+ const streams = JSON.parse(String(p.stdout)).streams;
+ const v = streams.find((s) => s.codec_type === "video");
+ assert.ok(v, "the segment has a picture: the poster");
+ assert.equal(v.width, 320);
+ assert.equal(v.height, 180);
+ assert.equal(v.r_frame_rate, "30/1");
+ assert.ok(streams.some((s) => s.codec_type === "audio"));
+});
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 });
}