commit 49932bd2ee3f581d06a012ead7fc043359098ce2
parent f0a3486d47ed0795be4c0a79a1a638d63ecf8547
Author: I Mean I'm Just Saying <imeanimjustsaying@kiwifarms.st>
Date: Sun, 4 Oct 2026 16:32:55 -0400
report-to-video: tests for the local-source tiers, --no-network and the bench's agreement with them
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Diffstat:
3 files changed, 470 insertions(+), 9 deletions(-)
diff --git a/umtool/lib/report/raw-cache.test.mjs b/umtool/lib/report/raw-cache.test.mjs
@@ -0,0 +1,101 @@
+// The clip bench's view of what is cached: out/clips-raw plus the corpus's
+// windows, listed by clipWindowDirs through the render's own sources.mjs --
+// so a window the bench calls fetched is one the build cuts from.
+//
+// Run with: pnpm test:scripts
+import assert from "node:assert/strict";
+import test from "node:test";
+import path from "node:path";
+import os from "node:os";
+import { mkdir, mkdtemp, rm, symlink, writeFile } from "node:fs/promises";
+
+import { rawCacheOf } from "./raw-cache.mjs";
+import { clipWindowDirs } from "../projects/report.mjs";
+import { resolveLocalSource } from "umtool-report-to-video/sources";
+
+const SLUG = "demo-channel";
+
+async function fixture() {
+ const root = await mkdtemp(path.join(os.tmpdir(), "raw-cache-"));
+ const projectDir = path.join(root, "project");
+ const channelsDir = path.join(root, "channels");
+ const rawDir = path.join(projectDir, "out", "clips-raw");
+ await mkdir(rawDir, { recursive: true });
+ const video = async (id) => {
+ const dir = path.join(channelsDir, SLUG, "data", id);
+ await mkdir(path.join(dir, "clips"), { recursive: true });
+ return dir;
+ };
+ return { root, projectDir, channelsDir, rawDir, video };
+}
+
+const touch = (p) => writeFile(p, Buffer.alloc(16, 1));
+
+test("rawCacheOf with no corpus windows reads clips-raw alone", async () => {
+ const f = await fixture();
+ try {
+ await touch(path.join(f.rawDir, "abc123_0.00-30.00.mp4"));
+ const c = await rawCacheOf(f.projectDir);
+ assert.equal(c.containing("abc123", 5, 10).name, "abc123_0.00-30.00.mp4");
+ assert.equal(c.isFetched({ video: "abc123", start: 25, end: 31 }), false);
+ } finally {
+ await rm(f.root, { recursive: true, force: true });
+ }
+});
+
+test("clipWindowDirs: corpus windows and a saved source, and the render resolves the same files", async () => {
+ const f = await fixture();
+ try {
+ const a = await f.video("abc123");
+ await touch(path.join(a, "clips", "100.00-160.00.mp4"));
+ await touch(path.join(a, "clips", "100.00-160.00.json"));
+ // A window on a drive that is not there is no window.
+ await symlink(path.join(f.root, "gone.mp4"), path.join(a, "clips", "300.00-400.00.mp4"));
+
+ // A second video with its whole source in the store; its span is the cue
+ // doc's duration.
+ const b = await f.video("def456");
+ const store = path.join(f.root, "store", SLUG, "def456");
+ await mkdir(store, { recursive: true });
+ await touch(path.join(store, "source-media.mp4"));
+ await writeFile(path.join(b, "saved-video.json"), JSON.stringify({ dir: store, file: "source-media.mp4", bytes: 16 }));
+ await writeFile(path.join(b, "transcript.cues.json"), JSON.stringify({ duration: 1800, cues: [] }));
+
+ const m = {
+ provenance: { channelSlug: SLUG },
+ timeline: [
+ { id: "c1", type: "clip", video: "abc123", start: 110, end: 120 },
+ { id: "c2", type: "clip", video: "abc123", start: 320, end: 330 },
+ { id: "c3", type: "clip", video: "def456", start: 1000, end: 1010 },
+ { id: "c4", type: "clip", video: "nope99", start: 1, end: 2 },
+ ],
+ };
+ const extra = await clipWindowDirs(m, f.channelsDir);
+ assert.deepEqual(
+ extra.map((e) => [e.video, e.windows.map((w) => [w.kind, w.name, w.from, w.to])]),
+ [
+ ["abc123", [["corpus-window", "100.00-160.00.mp4", 100, 160]]],
+ ["def456", [["saved-video", "source-media.mp4", 0, 1800]]],
+ ],
+ );
+
+ const c = await rawCacheOf(f.projectDir, { extraWindows: extra });
+ assert.equal(c.isFetched(m.timeline[0]), true);
+ assert.equal(c.isFetched(m.timeline[1]), false, "the dangling window is not fetched");
+ assert.equal(c.isFetched(m.timeline[2]), true);
+ assert.equal(c.isFetched(m.timeline[3]), false);
+
+ // The render, asked the same question, finds the same files.
+ const probe = async () => ({ duration: 1800 });
+ for (const e of m.timeline.slice(0, 3)) {
+ const hit = await resolveLocalSource(
+ { video: e.video, slug: SLUG, from: e.start, to: e.end },
+ { channelsDir: f.channelsDir, probe },
+ );
+ const bench = c.containing(e.video, e.start, e.end);
+ assert.equal(hit?.path ?? null, bench?.path ?? null, e.id);
+ }
+ } finally {
+ await rm(f.root, { recursive: true, force: true });
+ }
+});
diff --git a/umtool/report-to-video/build-video.mjs b/umtool/report-to-video/build-video.mjs
@@ -603,7 +603,8 @@ export async function clipsNeedingFetch(entries, timeline, render, opts, local)
const span = fetchSpan(entry, render, opts);
if (await local.resolve(entry, span, { exact: !!opts.noReuse })) continue;
out.push({
- index: timeline.indexOf(entry),
+ // 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)),
id: entry.id,
slug: local.slugOf(entry),
video: entry.video,
@@ -3562,6 +3563,16 @@ export async function buildVideo({ manifestPath, opts = {}, out, only, fetchOnly
return { out: r.out, failures: [] };
}
+ // `--no-network`: every clip's source is found on disk BEFORE anything is
+ // rendered, and a build that would have to fetch even one is refused here,
+ // naming each -- not twenty minutes in, at the first one. (The runs that
+ // 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 = await clipsNeedingFetch(want, whole.timeline ?? [], render, opts, dirs.local);
+ if (missing.length) throw new Error(needsFetchMessage(missing));
+ }
+
// Footer chrome is shared by every clip, so build it once up front. The deck
// has none: it is the chrome. (`hyper` is the chart band's legacy switch,
// which assertChrome already refuses beside a deck; the `!deck` says so here
@@ -3743,14 +3754,6 @@ export async function buildVideo({ manifestPath, opts = {}, out, only, fetchOnly
return { out: dirs.final, failures: [] };
}
- // `--no-network`: every clip's source is found on disk BEFORE anything is
- // rendered, and a build that would have to fetch even one is refused here,
- // naming each -- not twenty minutes in, at the first one.
- if (opts.noNetwork) {
- const missing = await clipsNeedingFetch(entries, whole.timeline ?? [], render, opts, dirs.local);
- if (missing.length) throw new Error(needsFetchMessage(missing));
- }
-
EMIT("start", { title: manifest.title, entries: entries.length, out: outDir });
for (let i = 0; i < entries.length; i += 1) {
const entry = entries[i];
diff --git a/umtool/report-to-video/sources.test.mjs b/umtool/report-to-video/sources.test.mjs
@@ -0,0 +1,357 @@
+// Tests for sources.mjs: where a clip's media is found on disk before the
+// build fetches it, and the build's use of it (fetchSpan, --no-network).
+//
+// Run with: pnpm test:scripts
+import assert from "node:assert/strict";
+import test from "node:test";
+import path from "node:path";
+import os from "node:os";
+import { mkdir, mkdtemp, rm, symlink, writeFile } from "node:fs/promises";
+
+import {
+ SOURCE_KINDS,
+ TIERS,
+ channelsDirFor,
+ corpusWindowsOf,
+ rawWindowName,
+ readSavedVideoPointer,
+ resolveLocalSource,
+ windowsFromBareNames,
+} from "./sources.mjs";
+import {
+ buildVideo,
+ clipsNeedingFetch,
+ fetchSpan,
+ findContainingWindow,
+ localSources,
+ needsFetchMessage,
+} from "./build-video.mjs";
+
+const SLUG = "demo-channel";
+const VIDEO = "abc123";
+
+async function tree() {
+ const root = await mkdtemp(path.join(os.tmpdir(), "rtv-sources-"));
+ const rawDir = path.join(root, "out", "clips-raw");
+ const channelsDir = path.join(root, "channels");
+ const videoDir = path.join(channelsDir, SLUG, "data", VIDEO);
+ const clipsDir = path.join(videoDir, "clips");
+ const storeDir = path.join(root, "store", SLUG, VIDEO);
+ for (const d of [rawDir, clipsDir, storeDir]) await mkdir(d, { recursive: true });
+ return { root, rawDir, channelsDir, videoDir, clipsDir, storeDir };
+}
+
+const touch = (p) => writeFile(p, Buffer.alloc(16, 1));
+
+// A probe that says how long a container is, and counts how often it is asked.
+function fakeProbe(duration = 600, extra = {}) {
+ const calls = [];
+ const probe = async (file) => {
+ calls.push(file);
+ return { duration, ...extra };
+ };
+ return { probe, calls };
+}
+
+async function pointTo(videoDir, storeDir, file, extra = {}) {
+ await writeFile(
+ path.join(videoDir, "saved-video.json"),
+ JSON.stringify({ storedAt: "2026-01-01T00:00:00Z", dir: storeDir, file, bytes: 16, ...extra }),
+ );
+}
+
+test("the tiers are listed in the order they are consulted", () => {
+ assert.deepEqual(TIERS.map((t) => t.kind), SOURCE_KINDS);
+ assert.deepEqual(SOURCE_KINDS, ["raw-cache", "corpus-window", "saved-video"]);
+});
+
+test("rawWindowName is the build's cache key, two decimals always", () => {
+ assert.equal(rawWindowName(VIDEO, 7, 21.5), "abc123_7.00-21.50.mp4");
+});
+
+test("raw-cache: the exact name first, else the tightest containing window", async () => {
+ const t = await tree();
+ try {
+ await touch(path.join(t.rawDir, rawWindowName(VIDEO, 10, 20)));
+ await touch(path.join(t.rawDir, rawWindowName(VIDEO, 0, 60)));
+ await touch(path.join(t.rawDir, rawWindowName(VIDEO, 9, 25)));
+ // A video id that is a prefix of this one must not claim its files.
+ await touch(path.join(t.rawDir, rawWindowName(`${VIDEO}x`, 9, 21)));
+ const exact = await resolveLocalSource({ video: VIDEO, slug: SLUG, from: 10, to: 20 }, { rawDir: t.rawDir });
+ assert.equal(exact.kind, "raw-cache");
+ assert.equal(exact.name, "abc123_10.00-20.00.mp4");
+ const tight = await resolveLocalSource({ video: VIDEO, slug: SLUG, from: 11, to: 22 }, { rawDir: t.rawDir });
+ assert.equal(tight.name, "abc123_9.00-25.00.mp4");
+ assert.equal(tight.windowStart, 9);
+ assert.equal(tight.windowEnd, 25);
+ // The re-export build-video has always offered answers the same.
+ assert.equal((await findContainingWindow(t.rawDir, VIDEO, 11, 22)).name, tight.name);
+ } finally {
+ await rm(t.root, { recursive: true, force: true });
+ }
+});
+
+test("coverage is of the PADDED span, to WIN_EPS", async () => {
+ const t = await tree();
+ try {
+ await touch(path.join(t.clipsDir, "10.00-20.00.mp4"));
+ const ask = (from, to) =>
+ resolveLocalSource({ video: VIDEO, slug: SLUG, from, to }, { channelsDir: t.channelsDir });
+ // The clip alone fits; the clip with a 3 s pad either side does not.
+ assert.ok(await ask(13, 17));
+ assert.equal(await ask(10 - 3 + 0.5, 17 + 3), null, "the after-pad runs past 20");
+ assert.equal(await ask(7, 17), null, "the before-pad starts before 10");
+ // A 2 dp name read back a hair outside the request still counts.
+ assert.ok(await ask(9.99, 20.01));
+ assert.equal(await ask(9.9, 20), null);
+ } finally {
+ await rm(t.root, { recursive: true, force: true });
+ }
+});
+
+test("corpus-window: bare names, the editor's three extensions, never a sidecar or a partial", () => {
+ const got = windowsFromBareNames(
+ ["1.00-2.00.mp4", "1.00-3.00.mkv", "1.00-4.00.webm", "1.00-5.00.json", "1.00-6.00.part.mp4", "6.00-5.00.mp4", "x.mp4"],
+ "/c",
+ );
+ assert.deepEqual(got.map((w) => w.name), ["1.00-2.00.mp4", "1.00-3.00.mkv", "1.00-4.00.webm"]);
+ assert.equal(got[0].path, path.join("/c", "1.00-2.00.mp4"));
+});
+
+test("precedence: raw-cache over corpus-window over saved-video, and no probe when a nearer tier hits", async () => {
+ const t = await tree();
+ try {
+ await touch(path.join(t.storeDir, "source-media.mp4"));
+ await pointTo(t.videoDir, t.storeDir, "source-media.mp4");
+ const { probe, calls } = fakeProbe(600);
+ const cfg = { rawDir: t.rawDir, channelsDir: t.channelsDir, probe };
+ const want = { video: VIDEO, slug: SLUG, from: 100, to: 110 };
+
+ let hit = await resolveLocalSource(want, cfg);
+ assert.equal(hit.kind, "saved-video");
+ assert.equal(calls.length, 1);
+
+ await touch(path.join(t.clipsDir, "90.00-120.00.mp4"));
+ calls.length = 0;
+ hit = await resolveLocalSource(want, cfg);
+ assert.equal(hit.kind, "corpus-window");
+ assert.equal(hit.windowStart, 90);
+ assert.equal(calls.length, 0, "the saved container is not probed when a window serves");
+
+ // Wider than the corpus window, and still preferred: it is this build's own.
+ await touch(path.join(t.rawDir, rawWindowName(VIDEO, 50, 200)));
+ hit = await resolveLocalSource(want, cfg);
+ assert.equal(hit.kind, "raw-cache");
+ assert.equal(hit.windowStart, 50);
+
+ // `kinds` narrows the tiers consulted.
+ hit = await resolveLocalSource(want, { ...cfg, kinds: ["corpus-window"] });
+ assert.equal(hit.kind, "corpus-window");
+ } finally {
+ await rm(t.root, { recursive: true, force: true });
+ }
+});
+
+test("exact (--no-reuse): only the raw file named for exactly this span", async () => {
+ const t = await tree();
+ try {
+ await touch(path.join(t.rawDir, rawWindowName(VIDEO, 0, 60)));
+ await touch(path.join(t.clipsDir, "0.00-60.00.mp4"));
+ const want = { video: VIDEO, slug: SLUG, from: 10, to: 20 };
+ const cfg = { rawDir: t.rawDir, channelsDir: t.channelsDir, exact: true };
+ assert.equal(await resolveLocalSource(want, cfg), null);
+ await touch(path.join(t.rawDir, rawWindowName(VIDEO, 10, 20)));
+ const hit = await resolveLocalSource(want, cfg);
+ assert.equal(hit.kind, "raw-cache");
+ assert.equal(hit.name, "abc123_10.00-20.00.mp4");
+ } finally {
+ await rm(t.root, { recursive: true, force: true });
+ }
+});
+
+test("saved-video: the pointer's container is the window [0, probed duration], height from the pointer", async () => {
+ const t = await tree();
+ try {
+ await touch(path.join(t.storeDir, "source-media.webm"));
+ await pointTo(t.videoDir, t.storeDir, "source-media.webm", { format: { preset: "video_720", height: 720 } });
+ const p = await readSavedVideoPointer(t.videoDir);
+ assert.equal(p.path, path.join(t.storeDir, "source-media.webm"));
+ assert.equal(p.height, 720);
+
+ const { probe } = fakeProbe(300.5, { height: 1080 });
+ const cfg = { channelsDir: t.channelsDir, probe };
+ const hit = await resolveLocalSource({ video: VIDEO, slug: SLUG, from: 250, to: 300 }, cfg);
+ assert.deepEqual(hit, {
+ kind: "saved-video",
+ path: path.join(t.storeDir, "source-media.webm"),
+ name: "source-media.webm",
+ windowStart: 0,
+ windowEnd: 300.5,
+ height: 720,
+ });
+ // Past the container's end is not covered.
+ assert.equal(await resolveLocalSource({ video: VIDEO, slug: SLUG, from: 290, to: 310 }, cfg), null);
+ } finally {
+ await rm(t.root, { recursive: true, force: true });
+ }
+});
+
+test("saved-video: a source-media container still in the video dir, through a relative link into media/", async () => {
+ const t = await tree();
+ try {
+ const mediaDir = path.join(t.channelsDir, SLUG, "media", VIDEO);
+ await mkdir(mediaDir, { recursive: true });
+ await touch(path.join(mediaDir, "source-media.mp4"));
+ await symlink(path.join("..", "..", "media", VIDEO, "source-media.mp4"), path.join(t.videoDir, "source-media.mp4"));
+ // Not a source: an audio-only container, and yt-dlp's scratch.
+ await touch(path.join(t.videoDir, "source-media.m4a"));
+ await touch(path.join(t.videoDir, "source-media.temp.mp4"));
+ const { probe, calls } = fakeProbe(120, { height: 480 });
+ const hit = await resolveLocalSource({ video: VIDEO, slug: SLUG, from: 5, to: 15 }, { channelsDir: t.channelsDir, probe });
+ assert.equal(hit.kind, "saved-video");
+ assert.equal(hit.name, "source-media.mp4");
+ assert.equal(hit.height, 480, "no pointer format, so the probe's height");
+ assert.deepEqual(calls, [path.join(t.videoDir, "source-media.mp4")]);
+ } finally {
+ await rm(t.root, { recursive: true, force: true });
+ }
+});
+
+test("a dangling link is not found -- never an error -- and the next candidate or tier answers", async () => {
+ const t = await tree();
+ try {
+ // The tightest corpus window is a link to a drive that is not there.
+ await symlink(path.join(t.root, "unmounted", "w.mp4"), path.join(t.clipsDir, "10.00-20.00.mp4"));
+ await touch(path.join(t.clipsDir, "0.00-40.00.mp4"));
+ let hit = await resolveLocalSource({ video: VIDEO, slug: SLUG, from: 11, to: 19 }, { channelsDir: t.channelsDir });
+ assert.equal(hit.name, "0.00-40.00.mp4");
+
+ // A pointer into an unmounted store and a dangling in-dir link: no source,
+ // and the probe is never asked about either.
+ await pointTo(t.videoDir, path.join(t.root, "unmounted-store"), "source-media.mp4");
+ await symlink(path.join("..", "..", "media", VIDEO, "source-media.mkv"), path.join(t.videoDir, "source-media.mkv"));
+ const { probe, calls } = fakeProbe(9999);
+ hit = await resolveLocalSource({ video: VIDEO, slug: SLUG, from: 100, to: 110 }, { channelsDir: t.channelsDir, probe });
+ assert.equal(hit, null);
+ assert.equal(calls.length, 0);
+ } finally {
+ await rm(t.root, { recursive: true, force: true });
+ }
+});
+
+test("a container the probe cannot measure is no source rather than a guess", async () => {
+ const t = await tree();
+ try {
+ await touch(path.join(t.storeDir, "source-media.mp4"));
+ await pointTo(t.videoDir, t.storeDir, "source-media.mp4");
+ const hit = await resolveLocalSource(
+ { video: VIDEO, slug: SLUG, from: 0, to: 1 },
+ { channelsDir: t.channelsDir, probe: async () => null },
+ );
+ assert.equal(hit, null);
+ } finally {
+ await rm(t.root, { recursive: true, force: true });
+ }
+});
+
+test("no channel slug or no channels tree: only the raw cache can answer", async () => {
+ const t = await tree();
+ try {
+ await touch(path.join(t.clipsDir, "0.00-40.00.mp4"));
+ assert.equal(await resolveLocalSource({ video: VIDEO, slug: null, from: 1, to: 2 }, { channelsDir: t.channelsDir }), null);
+ assert.equal(await resolveLocalSource({ video: VIDEO, slug: SLUG, from: 1, to: 2 }, { rawDir: t.rawDir }), null);
+ } finally {
+ await rm(t.root, { recursive: true, force: true });
+ }
+});
+
+test("corpusWindowsOf: the corpus tiers' windows, tagged, present ones only", async () => {
+ const t = await tree();
+ try {
+ await touch(path.join(t.rawDir, rawWindowName(VIDEO, 0, 10)));
+ await touch(path.join(t.clipsDir, "5.00-15.00.mp4"));
+ await symlink(path.join(t.root, "gone.mp4"), path.join(t.clipsDir, "1.00-2.00.mp4"));
+ await touch(path.join(t.storeDir, "source-media.mp4"));
+ await pointTo(t.videoDir, t.storeDir, "source-media.mp4");
+ const got = await corpusWindowsOf({ video: VIDEO, slug: SLUG, channelsDir: t.channelsDir, probe: fakeProbe(90).probe });
+ assert.deepEqual(
+ got.map((w) => [w.kind, w.name, w.from, w.to]),
+ [
+ ["corpus-window", "5.00-15.00.mp4", 5, 15],
+ ["saved-video", "source-media.mp4", 0, 90],
+ ],
+ );
+ } finally {
+ await rm(t.root, { recursive: true, force: true });
+ }
+});
+
+test("channelsDirFor: declared, then the shadow tree, then the fallback", () => {
+ assert.equal(channelsDirFor("/p", { provenance: { channelsDir: "../c" } }, { fallback: "/g" }), path.resolve("/p", "../c"));
+ assert.equal(channelsDirFor("/p", {}, { shadowExists: true, fallback: "/g" }), "/p/.shadow-channels");
+ assert.equal(channelsDirFor("/p", {}, { fallback: "/g" }), "/g");
+});
+
+// ---- the build's side --------------------------------------------------------
+
+test("fetchSpan: the extent widened by the pad, one edge at a time, clamped at 0", () => {
+ const e = { start: 10, end: 20 };
+ assert.deepEqual(fetchSpan(e, {}), { from: 7, to: 23 });
+ assert.deepEqual(fetchSpan(e, { fetchPad: 1 }), { from: 9, to: 21 });
+ assert.deepEqual(fetchSpan(e, { fetchPad: 1 }, { pad: 5, padAfter: 2 }), { from: 5, to: 22 });
+ assert.deepEqual(fetchSpan({ start: 1, end: 2 }, {}), { from: 0, to: 5 });
+});
+
+test("clipsNeedingFetch: the clips no tier serves, with their timeline index", async () => {
+ const t = await tree();
+ try {
+ await touch(path.join(t.clipsDir, "0.00-40.00.mp4"));
+ const timeline = [
+ { id: "t0", type: "card" },
+ { id: "c1", type: "clip", video: VIDEO, start: 10, end: 20 },
+ { id: "c2", type: "clip", video: "def456", start: 10, end: 20 },
+ { id: "c3", type: "clip", video: VIDEO, start: 38, end: 45 },
+ ];
+ const local = localSources({ rawDir: t.rawDir, channelsDir: t.channelsDir, channelSlug: SLUG });
+ const missing = await clipsNeedingFetch(timeline, timeline, { fetchPad: 3 }, {}, local);
+ assert.deepEqual(missing.map((m) => [m.index, m.id, m.slug, m.video, m.from, m.to]), [
+ [2, "c2", SLUG, "def456", 7, 23],
+ [3, "c3", SLUG, VIDEO, 35, 48],
+ ]);
+ const msg = needsFetchMessage(missing);
+ assert.match(msg, /--no-network: 2 clip\(s\)/);
+ assert.match(msg, /timeline\[2\] c2 {2}demo-channel\/def456 {2}7\.00–23\.00/);
+ assert.match(msg, /timeline\[3\] c3 {2}demo-channel\/abc123 {2}35\.00–48\.00/);
+ } finally {
+ await rm(t.root, { recursive: true, force: true });
+ }
+});
+
+test("buildVideo --no-network refuses before rendering, naming every clip that would need a fetch", async () => {
+ const t = await tree();
+ try {
+ await touch(path.join(t.clipsDir, "0.00-40.00.mp4"));
+ const manifestPath = path.join(t.root, "video.manifest.json");
+ await writeFile(manifestPath, JSON.stringify({
+ slug: "demo",
+ title: "Demo",
+ provenance: { channelSlug: SLUG, channelsDir: "channels", siteOrigin: "https://example.invalid" },
+ 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 },
+ ],
+ }));
+ await assert.rejects(
+ buildVideo({ manifestPath, opts: { noNetwork: true } }),
+ (err) => {
+ assert.match(err.message, /1 clip\(s\) have no local source/);
+ assert.match(err.message, /timeline\[1\] c2 {2}demo-channel\/def456 {2}0\.00–7\.00/);
+ assert.doesNotMatch(err.message, /c1/);
+ return true;
+ },
+ );
+ } finally {
+ await rm(t.root, { recursive: true, force: true });
+ }
+});