// 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")); });