commit 13998fd903f461d9b4fab6063297af6fcaa28f3e
parent 320eda0583d00580cbde2e13d52fb20d50777ac4
Author: I Mean I'm Just Saying <imeanimjustsaying@kiwifarms.st>
Date: Sun, 4 Oct 2026 17:13:33 -0400
report-to-video: tests for src/cues and the audio poster; the poster's waveform is bounded and drawn at full colour
The waveform overlay passes the still through at its end (eof_action=pass):
the default repeat handed the cut's fps filter a frame stamped far ahead,
and the segment ran for an hour behind two seconds of sound.
local-media.test.mjs: both cue shapes normalise identically, the path rules,
validation naming each entry, the reader's metadata, a missing src refused
before the build runs. audio-poster.test.mjs: a lavfi mp3 as a src clip
builds a 320x180 segment whose stream matches a card's, with the poster at
the picture box's size and a moving waveform; under the deck the poster is
the footage box's size.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Diffstat:
3 files changed, 334 insertions(+), 2 deletions(-)
diff --git a/umtool/report-to-video/audio-poster.test.mjs b/umtool/report-to-video/audio-poster.test.mjs
@@ -0,0 +1,159 @@
+// A clip whose source is SOUND ALONE -- here a `src` mp3 beside the manifest --
+// builds a segment with a picture: a poster at the picture box's size, the
+// waveform moving along its foot, and a stream the concat cannot tell from any
+// other segment's (size, fps, pixel format, SAR, audio layout).
+//
+// Real encodes, kept tiny: a three-second lavfi mp3 and a 320x180 cut.
+//
+// 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";
+
+import { audioPosterParts, buildVideo, deckFraming, posterWaveGeometry } from "./build-video.mjs";
+import { MONO_FONT_FILE } from "./brand.mjs";
+
+const have = ["ffmpeg", "ffprobe", "magick"].every((b) => spawnSync(b, ["-version"]).status === 0);
+const PALETTE = { bg: "#15121c", fg: "#ece8f4", muted: "#9a93ad", accent: "#7c5cff", amber: "#f2b84b" };
+const RENDER = {
+ width: 320, height: 180, fps: 30, audioRate: 48000, audioChannels: 2, preset: "ultrafast",
+ transition: 0, headerHeight: 24, palette: PALETTE, fontRegular: MONO_FONT_FILE,
+};
+
+function probe(file) {
+ const r = spawnSync("ffprobe", ["-v", "error", "-show_streams", "-show_format", "-of", "json", file]);
+ assert.equal(r.status, 0, String(r.stderr));
+ const doc = JSON.parse(String(r.stdout));
+ const v = doc.streams.find((s) => s.codec_type === "video");
+ const a = doc.streams.find((s) => s.codec_type === "audio");
+ return { v, a, duration: Number(doc.format.duration) };
+}
+
+/** What a stream-copy concat needs to be equal between two segments. */
+const concatKey = ({ v, a }) => ({
+ vcodec: v.codec_name, width: v.width, height: v.height, pix_fmt: v.pix_fmt,
+ fps: v.r_frame_rate, sar: v.sample_aspect_ratio ?? "1:1", tb: v.time_base,
+ acodec: a.codec_name, rate: a.sample_rate, channels: a.channels, layout: a.channel_layout,
+});
+
+async function project(render = RENDER) {
+ const root = await mkdtemp(path.join(os.tmpdir(), "rtv-audio-poster-"));
+ await mkdir(path.join(root, "media"), { recursive: true });
+ // Three seconds of a tone whose loudness swells: a waveform that moves.
+ const mp3 = path.join(root, "media", "episode.mp3");
+ const r = spawnSync("ffmpeg", [
+ "-v", "error", "-y", "-f", "lavfi", "-i", "sine=f=220:r=44100:d=3",
+ "-af", "volume='0.2+0.8*t/3':eval=frame", "-c:a", "libmp3lame", "-b:a", "64k", mp3,
+ ]);
+ assert.equal(r.status, 0, String(r.stderr));
+ await writeFile(path.join(root, "media", "episode.json"), JSON.stringify({
+ duration_seconds: 3,
+ chunk_data: [{ start_time: 0.2, end_time: 2.8, text: "A tone, held." }],
+ }));
+ const manifestPath = path.join(root, "video.manifest.json");
+ await writeFile(manifestPath, JSON.stringify({
+ slug: "demo",
+ title: "Demo",
+ render,
+ timeline: [
+ { id: "t0", type: "card", style: "chapter", heading: "A card", seconds: 1 },
+ {
+ id: "p1", type: "clip", src: "media/episode.mp3", cues: "media/episode.json",
+ start: 0.5, end: 2.5, title: "Episode twelve", channelTitle: "Demo Feed", date: "2026-01-02",
+ },
+ ],
+ }));
+ return { root, manifestPath };
+}
+
+test("posterWaveGeometry: even, inside the card's margins, along the foot", () => {
+ const g = posterWaveGeometry(1574, 886);
+ assert.equal(g.width % 2, 0);
+ assert.equal(g.height % 2, 0);
+ assert.ok(g.x > 0 && g.x + g.width <= 1574);
+ assert.ok(g.y > 886 / 2 && g.y + g.height < 886);
+});
+
+test("audioPosterParts: the still alone, or the sound's waveform over it", () => {
+ const box = { width: 320, height: 156 };
+ assert.deepEqual(
+ audioPosterParts({ posterIdx: 3, box, render: { ...RENDER, audioPoster: { waveform: false } } }),
+ ["[3:v]null[pic]"],
+ );
+ const parts = audioPosterParts({ posterIdx: 1, box, render: RENDER });
+ const g = posterWaveGeometry(320, 156);
+ assert.equal(parts.length, 2);
+ assert.match(parts[0], /^\[0:a\]aformat=channel_layouts=mono,showwaves=s=\d+x\d+:mode=cline:draw=full:rate=30:colors=#7c5cff\[wave\]$/);
+ assert.equal(parts[1], `[1:v][wave]overlay=x=${g.x}:y=${g.y}:eof_action=pass[pic]`);
+});
+
+test("an audio-only src clip builds a poster segment the concat cannot tell from a card's", { skip: !have && "needs ffmpeg, ffprobe and magick" }, async () => {
+ const { root, manifestPath } = await project();
+ try {
+ const clip = await buildVideo({ manifestPath, only: "p1", opts: { noNetwork: true } });
+ assert.equal(clip.failures.length, 0);
+ const card = await buildVideo({ manifestPath, only: "t0", opts: { noNetwork: true } });
+
+ const c = probe(clip.out);
+ assert.ok(c.v, "the segment has a picture");
+ assert.equal(c.v.width, 320);
+ assert.equal(c.v.height, 180);
+ assert.equal(c.v.r_frame_rate, "30/1");
+ assert.equal(c.v.pix_fmt, "yuv420p");
+ assert.equal(c.a.channels, 2);
+ assert.equal(c.a.sample_rate, "48000");
+ assert.deepEqual(concatKey(c), concatKey(probe(card.out)));
+ // About the played window, snapped (start 0.5 → 2.5 of a 3 s file), and the
+ // picture is no longer than the sound.
+ assert.ok(c.duration > 1 && c.duration <= 3.1, `duration ${c.duration}`);
+ assert.ok(Number(c.v.duration) <= Number(c.a.duration) + 1 / 30 + 0.03, `${c.v.duration} vs ${c.a.duration}`);
+
+ // The poster was drawn at the picture box's size: the frame less the header.
+ const still = probe(path.join(root, "out", "sourced", "cards", "p1.poster.png"));
+ assert.equal(still.v.width, 320);
+ assert.equal(still.v.height, 180 - 24);
+
+ // The waveform moves: the frames are not one still.
+ const md5 = spawnSync("ffmpeg", ["-v", "error", "-i", clip.out, "-map", "0:v", "-f", "framemd5", "-"]);
+ assert.equal(md5.status, 0, String(md5.stderr));
+ const hashes = new Set(String(md5.stdout).split("\n").filter((l) => l && !l.startsWith("#")).map((l) => l.split(",").pop().trim()));
+ assert.ok(hashes.size > 10, `${hashes.size} distinct frames`);
+ } finally {
+ await rm(root, { recursive: true, force: true });
+ }
+});
+
+test("under the deck, the poster is framed into the footage box like any footage", { skip: !have && "needs ffmpeg, ffprobe and magick" }, async () => {
+ const { root, manifestPath } = await project({
+ ...RENDER, width: 1920, height: 1080, headerHeight: undefined,
+ chrome: { engine: "hyperframes", layout: "deck", deck: {} },
+ audioPoster: { waveform: false },
+ });
+ try {
+ const clip = await buildVideo({ manifestPath, only: "p1", opts: { noNetwork: true } });
+ const c = probe(clip.out);
+ assert.equal(c.v.width, 1920);
+ assert.equal(c.v.height, 1080);
+ assert.equal(c.v.r_frame_rate, "30/1");
+ const still = probe(path.join(root, "out", "sourced", "cards", "p1.poster.png"));
+ const box = deckFraming({ ...RENDER, width: 1920, height: 1080, chrome: { engine: "hyperframes", layout: "deck", deck: {} } }).box;
+ assert.equal(still.v.width, box.width);
+ assert.equal(still.v.height, box.height);
+ } finally {
+ await rm(root, { recursive: true, force: true });
+ }
+});
+
+test("--fetch-only on a src clip has nothing to fetch", async () => {
+ if (!have) return;
+ const { root, manifestPath } = await project();
+ try {
+ const r = await buildVideo({ manifestPath, fetchOnly: "p1", opts: {} });
+ assert.equal(r.out, null);
+ } finally {
+ await rm(root, { recursive: true, force: true });
+ }
+});
diff --git a/umtool/report-to-video/build-video.mjs b/umtool/report-to-video/build-video.mjs
@@ -546,8 +546,11 @@ export function audioPosterParts({ posterIdx, box, render }) {
const g = posterWaveGeometry(box.width, box.height);
return [
`[0:a]aformat=channel_layouts=mono,` +
- `showwaves=s=${g.width}x${g.height}:mode=cline:rate=${render.fps}:colors=${render.palette.accent}[wave]`,
- `[${posterIdx}:v][wave]overlay=x=${g.x}:y=${g.y}[pic]`,
+ `showwaves=s=${g.width}x${g.height}:mode=cline:draw=full:rate=${render.fps}:colors=${render.palette.accent}[wave]`,
+ // `eof_action=pass`: the still runs on bare if the waveform ends a frame
+ // early. The default (repeat) hands the cut's `fps` a frame stamped far in
+ // the future, and it fills the gap -- an hour of poster behind 2 s of sound.
+ `[${posterIdx}:v][wave]overlay=x=${g.x}:y=${g.y}:eof_action=pass[pic]`,
];
}
diff --git a/umtool/report-to-video/local-media.test.mjs b/umtool/report-to-video/local-media.test.mjs
@@ -0,0 +1,170 @@
+// Tests for local-media.mjs: a clip whose media and cues are files beside the
+// manifest (`src`, `cues`) -- the two cue shapes, the path rules, validation,
+// and the reader the build and resolve-windows hold.
+//
+// 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, writeFile } from "node:fs/promises";
+
+import {
+ clipLabel, createLocalMedia, hasLocalMedia, normaliseCueDoc, resolveManifestPath, validateLocalMedia,
+} from "./local-media.mjs";
+import { unresolvable } from "./resolve-windows.mjs";
+import { buildVideo } from "./build-video.mjs";
+
+// The same three cues, in each shape.
+const CORPUS = {
+ title: "Episode 12", channel: "Demo Feed", uploadDate: "20260102", duration: 12.5, platform: "podcast",
+ cues: [
+ { start: 4, end: 7.25, text: "and then the second." },
+ { start: 0.5, end: 3.9, text: "First thing." },
+ { start: 7.5, end: 12, text: "The third one?" },
+ ],
+};
+const PARAKEET = {
+ duration_seconds: 12.5,
+ chunks: 3,
+ text: "First thing. and then the second. The third one?",
+ chunk_data: [
+ { start_time: 0.5, end_time: 3.9, text: "First thing." },
+ { start_time: 4, end_time: 7.25, text: "and then the second." },
+ { start_time: 7.5, end_time: 12, text: "The third one?" },
+ ],
+};
+
+async function dir() {
+ const root = await mkdtemp(path.join(os.tmpdir(), "rtv-local-media-"));
+ await mkdir(path.join(root, "media"), { recursive: true });
+ return root;
+}
+
+test("both cue shapes normalise to the same cues, in time order", () => {
+ const a = normaliseCueDoc(CORPUS);
+ const b = normaliseCueDoc(PARAKEET);
+ assert.deepEqual(a.cues, b.cues);
+ assert.deepEqual(a.cues.map((c) => c.start), [0.5, 4, 7.5]);
+ assert.equal(a.duration, 12.5);
+ assert.equal(b.duration, 12.5);
+ // The corpus shape carries the record's metadata; the parakeet one has none.
+ assert.equal(a.title, "Episode 12");
+ assert.equal(a.platform, "podcast");
+ assert.equal(b.title, undefined);
+});
+
+test("a cue with no finite start or end is dropped; neither shape is an error", () => {
+ const got = normaliseCueDoc({ cues: [{ start: 1, end: 2, text: "a" }, { start: "x", end: 3, text: "b" }, { start: 4 }] });
+ assert.deepEqual(got.cues, [{ start: 1, end: 2, text: "a" }]);
+ assert.throws(() => normaliseCueDoc({ segments: [] }, "m/ep.json"), /m\/ep\.json: neither a transcript\.cues\.json/);
+ assert.throws(() => normaliseCueDoc(null), /not a cue document/);
+});
+
+test("resolveManifestPath: inside the manifest's directory, or absolute only with leave", () => {
+ const base = path.resolve("/srv/proj");
+ assert.deepEqual(resolveManifestPath(base, "media/ep.mp3"), { file: path.join(base, "media", "ep.mp3") });
+ assert.deepEqual(resolveManifestPath(base, "media/../ep.mp3"), { file: path.join(base, "ep.mp3") });
+ assert.match(resolveManifestPath(base, "../other/ep.mp3").error, /climbs out of the manifest's directory/);
+ assert.match(resolveManifestPath(base, "media/../../ep.mp3").error, /climbs out/);
+ assert.match(resolveManifestPath(base, "..").error, /climbs out/);
+ // A name that merely starts with two dots is inside.
+ assert.deepEqual(resolveManifestPath(base, "..ep.mp3"), { file: path.join(base, "..ep.mp3") });
+ assert.match(resolveManifestPath(base, "/mnt/feed/ep.mp3").error, /absolute; set render\.allowAbsoluteSrc/);
+ assert.deepEqual(resolveManifestPath(base, "/mnt/feed/ep.mp3", { allowAbsolute: true }), { file: "/mnt/feed/ep.mp3" });
+ assert.match(resolveManifestPath(base, "").error, /non-empty/);
+});
+
+test("validateLocalMedia: every problem, each naming its entry", async () => {
+ const root = await dir();
+ try {
+ await writeFile(path.join(root, "media", "ep.mp3"), "x");
+ await writeFile(path.join(root, "media", "ep.json"), JSON.stringify(PARAKEET));
+ await writeFile(path.join(root, "media", "bad.json"), JSON.stringify({ words: [] }));
+ const timeline = [
+ { id: "ok", type: "clip", src: "media/ep.mp3", cues: "media/ep.json" },
+ { id: "nofile", type: "clip", src: "media/missing.mp3" },
+ { id: "up", type: "clip", src: "../ep.mp3" },
+ { id: "abs", type: "clip", src: path.join(root, "media", "ep.mp3") },
+ { id: "both", type: "clip", src: "media/ep.mp3", video: "abc123" },
+ { id: "nocues", type: "clip", src: "media/ep.mp3", cues: "media/none.json" },
+ { id: "badcues", type: "clip", src: "media/ep.mp3", cues: "media/bad.json" },
+ { id: "orphan", type: "clip", video: "abc123", cues: "media/ep.json" },
+ // Not this field: an image's `src` is a picture, and a card is no clip.
+ { id: "img", type: "image", src: "media/missing.png" },
+ { id: "card", type: "card", src: "whatever" },
+ ];
+ const errors = await validateLocalMedia(timeline, root);
+ assert.equal(errors.length, 7, errors.join("\n"));
+ assert.match(errors[0], /^timeline\[1\] nofile: no media at .*missing\.mp3 \(src: media\/missing\.mp3\)$/);
+ assert.match(errors[1], /^timeline\[2\] up: src \.\.\/ep\.mp3 climbs out/);
+ assert.match(errors[2], /^timeline\[3\] abs: src .* is absolute/);
+ assert.match(errors[3], /^timeline\[4\] both: a clip takes `src` or `video`, not both$/);
+ assert.match(errors[4], /^timeline\[5\] nocues: no cue file at .*none\.json/);
+ assert.match(errors[5], /^timeline\[6\] badcues: media\/bad\.json: neither/);
+ assert.match(errors[6], /^timeline\[7\] orphan: `cues` belongs to a clip with `src`$/);
+ // With leave, the absolute one passes.
+ const abs = await validateLocalMedia([timeline[3]], root, { allowAbsolute: true });
+ assert.deepEqual(abs, []);
+ } finally {
+ await rm(root, { recursive: true, force: true });
+ }
+});
+
+test("createLocalMedia: the file, the cues and the metadata a record would have given", async () => {
+ const root = await dir();
+ try {
+ await writeFile(path.join(root, "media", "a.json"), JSON.stringify(CORPUS));
+ await writeFile(path.join(root, "media", "b.json"), JSON.stringify(PARAKEET));
+ const local = createLocalMedia({ baseDir: root });
+ const a = { id: "a", type: "clip", src: "media/ep.mp3", cues: "media/a.json" };
+ const b = { id: "b", type: "clip", src: "media/ep.mp3", cues: "media/b.json" };
+ const bare = { id: "c", type: "clip", src: "media/episode-12.mp3" };
+ assert.equal(local.file(a), path.join(root, "media", "ep.mp3"));
+ assert.deepEqual(await local.cues(a), await local.cues(b));
+ assert.deepEqual(await local.cues(bare), []);
+ assert.deepEqual(await local.meta(a), {
+ title: "Episode 12", uploadDate: "20260102", webpageUrl: null, duration: 12.5,
+ channel: "Demo Feed", platform: "podcast",
+ });
+ // No record: the file's own name is the title, and there is no page.
+ const m = await local.meta(bare);
+ assert.equal(m.title, "episode-12");
+ assert.equal(m.webpageUrl, null);
+ assert.equal(hasLocalMedia(bare), true);
+ assert.equal(hasLocalMedia({ id: "v", type: "clip", video: "abc123" }), false);
+ assert.equal(clipLabel(bare), "media/episode-12.mp3");
+ assert.equal(clipLabel({ video: "abc123", src: "x" }), "abc123");
+ } finally {
+ await rm(root, { recursive: true, force: true });
+ }
+});
+
+test("resolve-windows leaves a src clip alone without cues or a window", () => {
+ assert.equal(unresolvable({ type: "clip", video: "abc123", start: 1, end: 2 }), null);
+ assert.equal(unresolvable({ type: "clip", src: "m.mp3", start: 1, end: 2 }), "no cues file");
+ assert.equal(unresolvable({ type: "clip", src: "m.mp3", cues: "m.json" }), "no start/end (the whole file)");
+ assert.equal(unresolvable({ type: "clip", src: "m.mp3", cues: "m.json", start: 1, end: 2 }), null);
+});
+
+test("buildVideo refuses a missing src before anything runs, naming the entry", async () => {
+ const root = await dir();
+ try {
+ const manifestPath = path.join(root, "video.manifest.json");
+ await writeFile(manifestPath, JSON.stringify({
+ slug: "demo",
+ title: "Demo",
+ render: { width: 320, height: 180, fps: 30 },
+ timeline: [
+ { id: "t0", type: "card", style: "chapter", heading: "Hi" },
+ { id: "p1", type: "clip", src: "media/gone.mp3" },
+ ],
+ }));
+ await assert.rejects(
+ buildVideo({ manifestPath, opts: { noNetwork: true } }),
+ /^Error: manifest: timeline\[1\] p1: no media at .*gone\.mp3 \(src: media\/gone\.mp3\)$/,
+ );
+ } finally {
+ await rm(root, { recursive: true, force: true });
+ }
+});