/** * Assemble filter-graph builder (editor-v2 L1): pure-function tests for the * adopted plan §2 graph — per-clip chains per fit/aspect, card segments, the * concat, overlay timing windows, the VO duck path, and the gates — exactly the * argv-shape discipline of produce/command-builder.test.ts. No ffmpeg run here * (the runner suite owns the encode); every assertion is on built strings. */ import assert from "node:assert/strict"; import test from "node:test"; import { AssembleBuildError, ASSEMBLE_PRESET, buildAssembleArgs, buildAssembleFilter, clipVideoChain, itemDurationMs, itemStartMs, secondsOf, timelineTotalMs, validateTrims, type AssemblePlan, type AssembleClipSource, type AssembleInputIndex, } from "./assemble-builder"; import { EDL_ASPECTS, parseAssembleInput, type AssembleInput, type EdlProject } from "./edl-plan"; const SCRATCH = "/var/tmp/media-worker/scratch/assemble"; const H1 = "1".repeat(64); const H2 = "2".repeat(64); const CARD1 = "3".repeat(64); const CARD2 = "4".repeat(64); const OVL1 = "5".repeat(64); const OVL2 = "6".repeat(64); const VO = "7".repeat(64); const ASSET = "00000000-0000-4000-8000-000000000001"; const ASSET2 = "00000000-0000-4000-8000-000000000002"; const VOICEOVER_ASSET = "00000000-0000-4000-8000-000000000005"; const EDL_ID = "00000000-0000-4000-8000-000000000010"; /** The canonical fixture project: two clips, a card between, one overlay, a VO. */ function project(overrides: Partial = {}): EdlProject { return { version: 1, aspect: "16:9", fit: "cover", fps: 30, items: [ { kind: "clip", assetId: ASSET, trimStartMs: 1_000, trimEndMs: 4_000, rotate: 0, volumeDb: 0 }, { kind: "card", text: "Chapter One", durationMs: 2_000, style: "title" }, { kind: "clip", assetId: ASSET2, trimStartMs: 0, trimEndMs: 3_000, rotate: 90, volumeDb: -6 }, ], overlays: [ { overlayType: "lower_third", fields: { title: "Ada Lovelace" }, startMs: 1_000, endMs: 3_500 }, ], voiceover: { audioAssetId: VOICEOVER_ASSET, gainDb: -6, duckOriginal: true, offsetMs: 500 }, outputLabel: "assembly v1", ...overrides, }; } function input(overrides: Partial = {}): AssembleInput { return parseAssembleInput({ edlProjectId: EDL_ID, project: project(overrides.project as unknown as EdlProject), ...overrides } as unknown as AssembleInput); } function plan(overrides: Partial = {}, projectOverrides: Partial = {}): AssemblePlan { const p = project(projectOverrides); const built = input({ project: p }); const clips: AssembleClipSource[] = []; for (const entry of p.items) { if (entry.kind !== "clip") continue; clips.push({ clip: entry, path: `${SCRATCH}/${entry.assetId === ASSET ? H1 : H2}.mp4`, durationMs: 30_000, hasAudio: true }); } const cards = p.items.flatMap((entry, i) => entry.kind === "card" ? [{ itemIndex: i, path: `${SCRATCH}/${entry === p.items[1] ? CARD1 : CARD2}.png`, durationMs: entry.durationMs }] : []); const overlays = p.overlays.map((overlay, j) => ({ overlay, path: `${SCRATCH}/${j === 0 ? OVL1 : OVL2}.png` })); return { input: built, clips, cards, overlays, voiceover: p.voiceover === null ? null : { path: `${SCRATCH}/${VO}.m4a`, gainDb: p.voiceover.gainDb, duckOriginal: p.voiceover.duckOriginal, offsetMs: p.voiceover.offsetMs }, outputPath: `${SCRATCH}/output.mp4`, ...overrides, }; } function index(p: AssemblePlan): AssembleInputIndex { return { timeline: p.input.project.items.map((_, i) => i), overlays: p.overlays.map((_, j) => p.input.project.items.length + j), voiceover: p.voiceover === null ? null : p.input.project.items.length + p.overlays.length }; } // --------------------------------------------------------------------------- // Timeline math: absolute overlay windows depend on it. // --------------------------------------------------------------------------- test("timeline math: item durations accumulate into absolute starts", () => { const p = project(); assert.equal(itemDurationMs(p.items[0]!), 3_000); assert.equal(itemDurationMs(p.items[1]!), 2_000); assert.equal(itemStartMs(p.items, 0), 0); assert.equal(itemStartMs(p.items, 1), 3_000); assert.equal(itemStartMs(p.items, 2), 5_000); assert.equal(timelineTotalMs(p.items), 8_000); assert.equal(secondsOf(1_234), "1.234"); }); // --------------------------------------------------------------------------- // Per-clip chains: fit and rotation. // --------------------------------------------------------------------------- test("cover fit center-crops: scale increase + crop to the canvas", () => { const chain = clipVideoChain(0, "cover", "16:9"); const { w, h } = EDL_ASPECTS["16:9"]; assert.equal(chain, `scale=${w}:${h}:force_original_aspect_ratio=increase:force_divisible_by=2,crop=${w}:${h},setsar=1,fps=30,format=yuv420p`); assert.ok(!chain.includes("pad="), "cover must never letterbox"); }); test("contain fit letterboxes: scale decrease + centered pad", () => { const chain = clipVideoChain(0, "contain", "16:9"); const { w, h } = EDL_ASPECTS["16:9"]; assert.equal(chain, `scale=${w}:${h}:force_original_aspect_ratio=decrease:force_divisible_by=2,pad=${w}:${h}:(ow-iw)/2:(oh-ih)/2:color=black,setsar=1,fps=30,format=yuv420p`); assert.ok(!chain.includes("crop="), "contain must never crop"); }); test("rotation is applied BEFORE the fit chain, per closed enum", () => { assert.ok(clipVideoChain(90, "contain", "16:9").startsWith("transpose=1,scale=")); assert.ok(clipVideoChain(270, "cover", "9:16").startsWith("transpose=2,scale=")); assert.ok(clipVideoChain(180, "contain", "16:9").startsWith("transpose=1,transpose=1,scale=")); assert.ok(!clipVideoChain(0, "contain", "16:9").includes("transpose"), "0° adds no transpose"); }); test("portrait canvas geometry comes from the aspect table, not the produce preset", () => { const chain = clipVideoChain(0, "cover", "9:16"); const { w, h } = EDL_ASPECTS["9:16"]; assert.ok(chain.includes(`scale=${w}:${h}`) && chain.includes(`crop=${w}:${h}`)); assert.equal(w, 1080); assert.equal(h, 1920); }); // --------------------------------------------------------------------------- // The graph: clips, cards, concat, overlays, VO. // --------------------------------------------------------------------------- test("per-item chains feed one concat v=1:a=1 in timeline order", () => { const p = plan(); const g = buildAssembleFilter(p, index(p)); const chains = g.split(";"); // clip0 video, clip0 audio, card video, card audio, clip1 video, clip1 audio, concat assert.match(chains[0]!, /^\[0:v\]scale=1280:720[^;]*\[v0\]$/); assert.match(chains[1]!, /^\[0:a\]aformat=sample_fmts=fltp:sample_rates=48000:channel_layouts=stereo\[a0\]$/); assert.match(chains[2]!, /^\[1:v\]scale=1280:720[^;]*\[v1\]$/); assert.equal(chains[3]!, `anullsrc=r=${ASSEMBLE_PRESET.sampleRate}:cl=stereo,aformat=sample_fmts=fltp:sample_rates=${ASSEMBLE_PRESET.sampleRate}:channel_layouts=stereo[a1]`); // The rotated clip transposes before its fit chain and applies its volume. assert.ok(chains[4]!.startsWith("[2:v]transpose=1,scale=")); assert.ok(chains[5]!.includes("volume=-6dB")); // concat consumes every item label, video then audio, in order. assert.equal(chains[6]!, "[v0][a0][v1][a1][v2][a2]concat=n=3:v=1:a=1[vcat][acat]"); }); test("a card segment is the looped PNG + anullsrc, never drawtext", () => { const p = plan(); const g = buildAssembleFilter(p, index(p)); assert.ok(g.includes("anullsrc="), "card audio is silence"); const args = buildAssembleArgs(p); const loopAt = args.indexOf("-loop"); assert.ok(loopAt > 0 && args[loopAt + 1] === "1", "the card input loops one PNG"); const tAt = args.indexOf("-t", loopAt); assert.equal(args[tAt + 1], "2.000", "the card input is bounded by its duration"); assert.ok(!g.includes("drawtext"), "NO drawtext anywhere in an assembly (injection-safe by construction)"); assert.ok(!args.some((a) => a.includes("drawtext"))); }); test("overlays gate on ABSOLUTE timeline seconds via enable='between(t,s,e)'", () => { const p = plan(); const g = buildAssembleFilter(p, index(p)); // The overlay window 1000..3500 is timeline-absolute: the card starts at 3000, // so a source-relative time would be wrong by the first clip's 3000 ms. const overlayChain = g.split(";").find((c) => c.includes("overlay="))!; assert.ok(overlayChain.includes(`enable='between(t,1.000,3.500)'`), overlayChain); assert.ok(overlayChain.startsWith("[vcat][3:v]overlay="), "the overlay chain reads the concatenated base"); assert.ok(overlayChain.includes("eof_action=repeat"), "a single-frame PNG is held by eof_action=repeat"); }); test("multiple overlays stack in request order (z-order), the last one closing the chain", () => { const p = plan({}, { overlays: [ { overlayType: "lower_third", fields: { title: "one" }, startMs: 0, endMs: 2_000 }, { overlayType: "chyron", fields: { title: "two" }, startMs: 1_000, endMs: 3_000 }, ], }); const g = buildAssembleFilter(p, index(p)); const chains = g.split(";"); const first = chains.find((c) => c.startsWith("[vcat]"))!; const second = chains.find((c) => c.startsWith("[ov0]"))!; assert.ok(first.endsWith("[ov0]")); assert.ok(second.endsWith("[vout]"), "the last overlay emits the final video label"); assert.ok(g.includes("between(t,0.000,2.000)") && g.includes("between(t,1.000,3.000)")); }); test("voiceover with duckOriginal runs adelay + sidechaincompress keyed by the delayed VO", () => { const p = plan(); const g = buildAssembleFilter(p, index(p)); const chains = g.split(";"); const voChain = chains.find((c) => c.startsWith("[4:a]"))!; assert.ok(voChain.includes("adelay=500:all=1"), "offsetMs becomes an adelay on all channels"); assert.ok(voChain.includes("volume=-6dB"), "gainDb applies to the VO"); assert.ok(voChain.includes("asplit=2[voMix][voKey]"), "the delayed VO feeds both the key and the mix"); const sidechain = chains.find((c) => c.includes("sidechaincompress"))!; assert.ok(sidechain.startsWith("[acat][voKey]"), "the compressor ducks the assembled bed"); assert.ok(g.includes("[duck][voMix]amix=inputs=2:duration=first:dropout_transition=2:normalize=0[aout]")); }); test("voiceover without ducking is a plain amix over the bed", () => { const p = plan({}, { voiceover: { audioAssetId: VOICEOVER_ASSET, gainDb: 0, duckOriginal: false, offsetMs: 0 } }); const g = buildAssembleFilter(p, index(p)); assert.ok(!g.includes("sidechaincompress")); const voFeed = g.split(";").find((c) => c.includes("[voGain]"))!; assert.ok(voFeed.startsWith("[4:a]"), "the VO chain reads its input pad"); assert.ok(!voFeed.includes("adelay"), "a zero offset emits no adelay"); assert.ok(!voFeed.includes("volume="), "a zero gain emits no volume filter"); assert.ok(g.includes("[acat][voGain]amix=inputs=2:duration=first:dropout_transition=2:normalize=0[aout]")); }); test("no voiceover: the concatenated bed passes through as the audio out", () => { const p = plan({}, { voiceover: null, overlays: [] }); const g = buildAssembleFilter(p, index(p)); assert.ok(g.endsWith("[vcat]null[vout];[acat]anull[aout]"), "a passthrough keeps both labels well-formed"); assert.ok(!g.includes("amix") && !g.includes("sidechaincompress")); }); test("an audio-less clip contributes an anullsrc audio segment, never [i:a]", () => { const p = plan(); p.clips[1]!.hasAudio = false; const g = buildAssembleFilter(p, index(p)); const chains = g.split(";"); assert.ok(!chains[5]!.includes("[2:a]"), "the audio-less clip must not reference its input pad"); assert.ok(chains[5]!.startsWith("anullsrc=")); // The audio-bearing clip keeps its input pad. assert.ok(chains[1]!.startsWith("[0:a]")); }); // --------------------------------------------------------------------------- // Pre-spawn trim gate. // --------------------------------------------------------------------------- test("the trim gate rejects an out-point beyond the probe duration BEFORE any spawn", () => { const p = plan(); p.clips[0]!.durationMs = 3_500; // trimEnd 4000 > 3500 assert.throws(() => validateTrims(p.input.project.items, p.clips), /exceeds the source's probed 3500ms/); assert.throws(() => buildAssembleArgs(p), /exceeds the source's probed 3500ms/); }); test("the trim gate rejects inverted trims and demands a probe duration", () => { const p = plan(); const inverted = { ...p.clips[0]!.clip, trimStartMs: 4_000, trimEndMs: 4_000 }; p.input.project.items[0] = inverted; p.clips[0]!.clip = inverted; assert.throws(() => validateTrims(p.input.project.items, p.clips), /trimStartMs must be before trimEndMs/); const noProbe = plan(); noProbe.clips[0]!.durationMs = 0; assert.throws(() => validateTrims(noProbe.input.project.items, noProbe.clips), /exceeds the source's probed 0ms/); }); // --------------------------------------------------------------------------- // argv shape. // --------------------------------------------------------------------------- test("argv: input seeks before each -i, encoder is medium/crf 20/aac 192k/faststart", () => { const p = plan(); const args = buildAssembleArgs(p); // clip 0: -ss 1.000 -t 3.000 before its input const ss0 = args.indexOf("-ss"); assert.equal(args[ss0 + 1], "1.000"); assert.equal(args[args.indexOf("-t", ss0) + 1], "3.000"); const inputs = args.flatMap((a, i) => (a === "-i" ? [args[i + 1]] : [])); assert.deepEqual(inputs, [ `file:${SCRATCH}/${H1}.mp4`, `file:${SCRATCH}/${CARD1}.png`, `file:${SCRATCH}/${H2}.mp4`, `file:${SCRATCH}/${OVL1}.png`, `file:${SCRATCH}/${VO}.m4a`, ]); for (const [token, value] of [ ["-c:v", "libx264"], ["-preset", "medium"], ["-crf", "20"], ["-c:a", "aac"], ["-b:a", "192k"], ["-movflags", "+faststart"], ["-f", "mp4"], ] as const) { const at = args.indexOf(token); assert.ok(at > 0 && args[at + 1] === value, `argv must carry ${token} ${value}`); } const maps = args.flatMap((a, i) => (a === "-map" ? [args[i + 1]] : [])); assert.deepEqual(maps, ["[vout]", "[aout]"], "argv maps the final video and audio labels"); // A global -t bounds the encode at the timeline length (the looping card input cannot run away). const globalT = args.lastIndexOf("-t"); assert.equal(args[globalT + 1], "8.000"); assert.ok(!args.some((a) => a.includes("$(") || a.startsWith(";") || a.includes("`"))); }); test("a portrait project renders 9:16 geometry end to end", () => { const p = plan({}, { aspect: "9:16", fit: "contain" }); const args = buildAssembleArgs(p); assert.ok(args.join(" ").includes("scale=1080:1920")); assert.ok(args.join(" ").includes("pad=1080:1920")); }); test("plans with unsafe paths or missing sources are refused before any spawn", () => { const bad = plan(); bad.outputPath = `${SCRATCH}/../escape.mp4`; assert.throws(() => buildAssembleArgs(bad), AssembleBuildError); const missing = plan(); missing.clips = missing.clips.slice(0, 1); // two clips in the timeline, one downloaded assert.throws(() => buildAssembleArgs(missing), /no downloaded source for the clip/); const unsynced = plan({}, { voiceover: null }); unsynced.voiceover = { path: `${SCRATCH}/${VO}.m4a`, gainDb: 0, duckOriginal: false, offsetMs: 0 }; assert.throws(() => buildAssembleArgs(unsynced), /voiceover the project does not name/); const noPng = plan(); noPng.cards[0]!.path = `${SCRATCH}/card.png`; assert.throws(() => buildAssembleArgs(noPng), /rendered \.png/); });