/** * Produce command builder: pure-function tests for the filter chain and argv — * no ffmpeg run (that is the soak/e2e layer's job), exactly like the argv-shape * half of compositor/command-builder.test.ts. * * The contract under test is the one the API persists into input_json * (apps/api/src/video/produce-request.ts): overlay kinds lower_third / logo / * clock, positions as {x,y} percent or a named bottom preset, logos by * content-addressed storage key, an optional voiceover with optional gain, and * a `requestedBy` audit field the worker accepts but never renders. * * Covers: base scale chain, position resolution, drawtext quoting/escaping, logo * input ordering, clock expansion, amix vs sidechain ducking, and the rejection * paths that keep operator text out of the graph (§4.3). */ import assert from "node:assert/strict"; import test from "node:test"; import { buildProduceArgs, buildProduceFilter, drawtextQuoted, xExpression, yExpression, ProduceBuildError, parseProduceRequest, scratchAssetName, CLOCK_TEXT, resolvePosition, type OverlayPosition, type ProducePlan, } from "./command-builder"; const SCRATCH = "/var/tmp/media-worker/scratch"; const HASH = "a".repeat(64); const LOGO_HASH = "b".repeat(64); const VO_HASH = "c".repeat(64); const FONT = `${SCRATCH}/fonts/Inter-SemiBold.ttf`; function plan(overrides: Partial = {}): ProducePlan { return { input: { path: `${SCRATCH}/${HASH}.mp4`, durationSec: 12.5, audioBed: "source" }, logos: [], voiceover: null, overlays: [], fontFile: FONT, outputPath: `${SCRATCH}/output.mp4`, ...overrides, }; } function graph(overrides: Partial = {}): string { const p = plan(overrides); return buildProduceFilter(p, { logos: p.logos.map((_, i) => i + 1), voiceover: p.voiceover === null ? null : p.logos.length + 1, }); } test("base chain scales and pads to 720p30 with no overlays", () => { const g = graph(); assert.equal(g, `[0:v]scale=1280:720:force_original_aspect_ratio=decrease:force_divisible_by=2,pad=1280:720:(ow-iw)/2:(oh-ih)/2:color=black,setsar=1,fps=30[base];[base]format=yuv420p[vout];[0:a]aformat=sample_fmts=fltp:sample_rates=48000:channel_layouts=stereo[aout]`); }); test("percent positions become clamped px expressions, single-quoted", () => { assert.equal(xExpression({ x: 5, y: 88 }), "min(64,W-tw)"); assert.equal(yExpression({ x: 5, y: 88 }), "min(634,H-th)"); assert.equal(xExpression({ x: 50, y: 0 }), "min(640,W-tw)"); const g = graph({ overlays: [{ kind: "lower_third", text: "Ada Lovelace", logoStorageKey: null, position: { x: 5, y: 88 } }] }); assert.ok(g.includes(`x='min(64,W-tw)':y='min(634,H-th)'`)); }); test("named presets resolve to the same percent grid as the web's defaults", () => { const bottomLeft = resolvePosition("lower_third", "bottom-left", "p"); assert.equal(bottomLeft.x, 5); assert.equal(bottomLeft.y, 88); const bottomRight = resolvePosition("lower_third", "bottom-right", "p"); assert.equal(bottomRight.x, 95); const omitted = resolvePosition("clock", undefined, "p"); assert.deepEqual(omitted, { x: 95, y: 5 }); assert.throws(() => resolvePosition("clock", "top-left", "p"), ProduceBuildError); }); test("lower thirds draw in request order, after the base chain", () => { const g = graph({ overlays: [ { kind: "lower_third", text: "Ada Lovelace", logoStorageKey: null, position: { x: 5, y: 88 } }, { kind: "lower_third", text: "Counting Engine", logoStorageKey: null, position: { x: 50, y: 88 } }, ] }); const chains = g.split(";"); assert.match(chains[1]!, /^\[base\]drawtext=/); assert.match(chains[2]!, /^\[ov0\]drawtext=/); assert.match(chains[3]!, /^\[ov1\]format=yuv420p\[vout\]$/); assert.ok(chains[1]!.includes(`text='Ada Lovelace'`)); // The second text is drawn on the first's output, not on the base again. assert.ok(!chains[2]!.includes("[base]")); }); test("drawtext quoting escapes backslash and quote, and expansion is disabled", () => { assert.equal(drawtextQuoted("plain"), "'plain'"); assert.equal(drawtextQuoted("it's"), "'it'\\''s'"); assert.equal(drawtextQuoted("a\\b"), "'a\\\\b'"); // A caption containing filtergraph metacharacters is rejected outright (the // API blocks the same set on intake); the ones that survive are inert literals. const g = graph({ overlays: [{ kind: "lower_third", text: "50% louder", logoStorageKey: null, position: { x: 5, y: 88 } }] }); assert.ok(g.includes("expansion=none")); assert.ok(g.includes(`text='50% louder'`)); }); test("a clock overlay renders the builder's own constant with ffmpeg expansion", () => { const g = graph({ overlays: [{ kind: "clock", text: null, logoStorageKey: null, position: { x: 95, y: 5 } }] }); assert.ok(g.includes(`text='${CLOCK_TEXT}'`)); assert.ok(g.includes("expansion=normal")); // The clock payload carries no operator text at all. assert.ok(!g.includes("expansion=none")); }); test("logo overlays consume one downloaded input each, in request order", () => { const g = graph({ overlays: [ { kind: "lower_third", text: "x", logoStorageKey: null, position: { x: 5, y: 88 } }, { kind: "logo", text: null, logoStorageKey: `sha256/${LOGO_HASH}.png`, position: { x: 95, y: 5 } }, { kind: "logo", text: null, logoStorageKey: `sha256/${"d".repeat(64)}.png`, position: { x: 5, y: 5 } }, ], logos: [{ path: `${SCRATCH}/${LOGO_HASH}.png` }, { path: `${SCRATCH}/${"d".repeat(64)}.png` }], }); const chains = g.split(";"); // chains: [base], drawtext(lt), overlay(logo1), overlay(logo2), format assert.match(chains[2]!, /^\[ov0\]\[1:v\]overlay=x='min\(1216,W-w\)':y='min\(36,H-h\)':eof_action=repeat\[ov1\]$/); assert.match(chains[3]!, /^\[ov1\]\[2:v\]overlay=x='min\(64,W-w\)':y='min\(36,H-h\)':eof_action=repeat\[ov2\]$/); assert.match(chains[4]!, /^\[ov2\]format=yuv420p\[vout\]$/); // No -loop 1: the overlay is one decoded frame held by eof_action=repeat. assert.ok(!buildProduceArgs(plan({ overlays: [{ kind: "logo", text: null, logoStorageKey: `sha256/${LOGO_HASH}.png`, position: { x: 95, y: 5 } }], logos: [{ path: `${SCRATCH}/${LOGO_HASH}.png` }], })).includes("-loop")); }); test("a logo overlay without its downloaded logo is refused", () => { assert.throws(() => graph({ overlays: [{ kind: "logo", text: null, logoStorageKey: `sha256/${LOGO_HASH}.png`, position: { x: 5, y: 5 } }], logos: [], }), ProduceBuildError); }); test("a voiceover without ducking is amix'd, unnormalized, gain applied to the voiceover", () => { const g = graph({ voiceover: { path: `${SCRATCH}/${VO_HASH}.m4a`, gainDb: -6, duck: false } }); const chains = g.split(";"); // chains: [base], format, bedFmt, voGain, amix assert.match(chains[1]!, /^\[base\]format=yuv420p\[vout\]$/); assert.match(chains[2]!, /^\[0:a\]aformat=[^;]*\[bedFmt\]$/); assert.match(chains[3]!, /^\[1:a\]aformat=[^;]*,volume=-6dB\[voGain\]$/); assert.match(chains[4]!, /^\[bedFmt\]\[voGain\]amix=inputs=2:duration=first:dropout_transition=2:normalize=0\[aout\]$/); assert.ok(!g.includes("sidechaincompress")); }); test("an unspecified gain emits no volume filter at all", () => { const g = graph({ voiceover: { path: `${SCRATCH}/${VO_HASH}.m4a`, gainDb: null, duck: false } }); assert.ok(!g.includes("volume=")); // chains: [base], format, bedFmt, voGain(no volume), amix assert.match(g.split(";")[3]!, /^\[1:a\]aformat=[^;]*\[voGain\]$/); }); test("ducking runs the voiceover as a sidechain key on the source bed", () => { const g = graph({ voiceover: { path: `${SCRATCH}/${VO_HASH}.m4a`, gainDb: -9, duck: true } }); const chains = g.split(";"); // chains: [base], format, vo asplit, bedRaw, sidechaincompress, voGain, amix assert.match(chains[2]!, /^\[1:a\]aformat=[^;]*,asplit=2\[voMix\]\[voKey\]$/); assert.match(chains[3]!, /^\[0:a\]aformat=[^;]*\[bedRaw\]$/); assert.match(chains[4]!, /^\[bedRaw\]\[voKey\]sidechaincompress=threshold=0\.03:ratio=8:attack=20:release=300:makeup=1:link=average\[duck\]$/); assert.match(chains[5]!, /^\[voMix\]volume=-9dB\[voGain\]$/); assert.match(chains[6]!, /^\[duck\]\[voGain\]amix=inputs=2:duration=first:dropout_transition=2:normalize=0\[aout\]$/); }); test("a source without audio mixes the voiceover over a synthetic silence bed", () => { const g = graph({ input: { path: `${SCRATCH}/${HASH}.mp4`, durationSec: 10, audioBed: "none" }, voiceover: { path: `${SCRATCH}/${VO_HASH}.m4a`, gainDb: 0, duck: false } }); assert.ok(g.includes("anullsrc=r=48000:cl=stereo")); // The synthetic bed is unbounded, so the mix runs to longest and -t bounds it. assert.ok(g.includes("duration=longest")); }); test("a synthetic bed must be bounded by the source duration", () => { assert.throws(() => graph({ input: { path: `${SCRATCH}/${HASH}.mp4`, durationSec: null, audioBed: "none" }, voiceover: { path: `${SCRATCH}/${VO_HASH}.m4a`, gainDb: 0, duck: false }, }), ProduceBuildError); }); test("argv: inputs in filter order, one filter_complex, h264+aac mp4 out", () => { const args = buildProduceArgs(plan({ overlays: [ { kind: "lower_third", text: "Hello", logoStorageKey: null, position: { x: 50, y: 88 } }, { kind: "logo", text: null, logoStorageKey: `sha256/${LOGO_HASH}.png`, position: { x: 5, y: 5 } }, ], logos: [{ path: `${SCRATCH}/${LOGO_HASH}.png` }], voiceover: { path: `${SCRATCH}/${VO_HASH}.m4a`, gainDb: -3, duck: true }, })); const inputIndexes = args.flatMap((a, i) => (a === "-i" ? [args[i + 1]] : [])); assert.deepEqual(inputIndexes, [ `file:${SCRATCH}/${HASH}.mp4`, `file:${SCRATCH}/${LOGO_HASH}.png`, `file:${SCRATCH}/${VO_HASH}.m4a`, ]); const fc = args.indexOf("-filter_complex"); assert.ok(fc > 0 && args[fc + 1]!.includes("[2:a]")); assert.ok(args.includes("-t") && args[args.indexOf("-t") + 1] === "12.500"); for (const token of ["-map", "[vout]", "[aout]", "-c:v", "libx264", "-pix_fmt", "yuv420p", "-b:v", "3000k", "-c:a", "aac", "-movflags", "+faststart", "-f", "mp4"]) { assert.ok(args.includes(token), `argv must include ${token}`); } const out = args[args.length - 1]!; assert.ok(out.startsWith("file:") && out.endsWith("output.mp4")); assert.ok(!args.some((a) => a.includes("$(") || a.startsWith(";"))); }); test("validation: paths, fonts, and plan shape are rejected before any spawn", () => { assert.throws(() => buildProduceArgs(plan({ input: { ...plan().input, path: `${SCRATCH}/../../etc/passwd` } })), ProduceBuildError); assert.throws(() => buildProduceArgs(plan({ outputPath: plan().input.path })), ProduceBuildError); assert.throws(() => buildProduceArgs(plan({ fontFile: "/font.ttf" })), ProduceBuildError); assert.throws(() => buildProduceArgs(plan({ fontFile: "DejaVu Sans" })), ProduceBuildError); assert.throws(() => buildProduceArgs(plan({ input: { ...plan().input, durationSec: -1 } })), ProduceBuildError); assert.throws(() => buildProduceArgs(plan({ overlays: [{ kind: "logo", text: null, logoStorageKey: `sha256/${LOGO_HASH}.png`, position: { x: 5, y: 5 } }], })), ProduceBuildError); assert.throws(() => buildProduceFilter(plan({ voiceover: { path: `${SCRATCH}/${VO_HASH}.m4a`, gainDb: 0, duck: false } }), { logos: [], voiceover: null }), ProduceBuildError); }); test("storage keys name their scratch copy by content hash", () => { assert.equal(scratchAssetName(`sha256/${HASH}.mp4`), `${HASH}.mp4`); assert.equal(scratchAssetName(`sha256/${LOGO_HASH}.png`), `${LOGO_HASH}.png`); assert.match(scratchAssetName("branding/logos/acme.png"), /^[0-9a-f]{64}\.bin$/); assert.throws(() => scratchAssetName("../../etc/passwd"), ProduceBuildError); assert.throws(() => scratchAssetName("sha256/nope.mp4"), ProduceBuildError); }); test("input_json is re-validated against the API's persisted shape", () => { const request = parseProduceRequest({ overlays: [{ kind: "lower_third", text: "Ada", position: { x: 0, y: 100 } }], lane: "remote", outputLabel: "promo v2", requestedBy: "00000000-0000-4000-8000-000000000009", }, "compose"); assert.equal(request.overlays.length, 1); assert.equal(request.overlays[0]!.kind, "lower_third"); assert.deepEqual(request.overlays[0]!.position, { x: 0, y: 100 }); assert.equal(request.lane, "remote"); assert.equal(request.voiceover, null); assert.equal(request.requestedBy, "00000000-0000-4000-8000-000000000009"); // The exact payload the API persists for a mix request: optional gain/duck // omitted, voiceover present → kind mix, requestedBy appended by the API. const mix = parseProduceRequest({ voiceover: { assetId: "00000000-0000-4000-8000-000000000002" }, requestedBy: "00000000-0000-4000-8000-000000000009", }, "mix"); assert.deepEqual(mix.voiceover, { assetId: "00000000-0000-4000-8000-000000000002", gainDb: null, duckOriginal: false }); assert.equal(mix.lane, null); assert.equal(mix.overlays.length, 0); // A logo overlay names a pre-rendered PNG by storage key; a clock has no payload. const branded = parseProduceRequest({ overlays: [ { kind: "logo", logoStorageKey: `sha256/${LOGO_HASH}.png`, position: { x: 5, y: 5 } }, { kind: "clock", position: { x: 95, y: 5 } }, ], }, "compose"); assert.equal(branded.overlays[0]!.logoStorageKey, `sha256/${LOGO_HASH}.png`); assert.equal(branded.overlays[1]!.kind, "clock"); // A voiceover on a compose row is refused (the API derives mix from the voiceover). assert.throws(() => parseProduceRequest({ voiceover: { assetId: "00000000-0000-4000-8000-000000000002", gainDb: -6, duckOriginal: true } }, "compose"), ProduceBuildError); // So are unknown fields, bad lanes, out-of-range positions and free-form text. assert.throws(() => parseProduceRequest({ extra: 1 }, "compose"), ProduceBuildError); assert.throws(() => parseProduceRequest({ lane: "gpu" }, "compose"), ProduceBuildError); assert.throws(() => parseProduceRequest({ overlays: [{ kind: "lower_third", text: "x", position: { x: 101, y: 0 } }] }, "compose"), ProduceBuildError); assert.throws(() => parseProduceRequest({ overlays: [{ kind: "chyron", text: "x", position: { x: 0, y: 0 } }] }, "compose"), ProduceBuildError); assert.throws(() => parseProduceRequest({ voiceover: { assetId: "not-a-uuid", gainDb: -6, duckOriginal: true } }, "mix"), ProduceBuildError); assert.throws(() => parseProduceRequest({ voiceover: { assetId: "00000000-0000-4000-8000-000000000002", gainDb: -49, duckOriginal: true } }, "mix"), ProduceBuildError); assert.throws(() => parseProduceRequest({ voiceover: { assetId: "00000000-0000-4000-8000-000000000002", gainDb: 13, duckOriginal: true } }, "mix"), ProduceBuildError); assert.throws(() => parseProduceRequest({ outputLabel: "bad label!" }, "compose"), ProduceBuildError); assert.throws(() => parseProduceRequest({ overlays: [{ kind: "lower_third", text: "a;b", position: { x: 0, y: 0 } }] }, "compose"), ProduceBuildError); assert.throws(() => parseProduceRequest({ overlays: [{ kind: "logo", logoStorageKey: "../../etc/passwd", position: { x: 0, y: 0 } }] }, "compose"), ProduceBuildError); });