T-HUB/apps/media-worker/src/producer/command-builder.test.ts
glass beadian d9e8cdf789 feat: produce worker deployment + web producer/collective/branding UI + mobile CSS (wave-2c)
Deployment (t4g2): media-worker gains font-dejavu (drawtext lower-thirds)
and a dedicated produce-worker compose service running src/produce.ts —
claims video_processing_jobs compose|mix by lane, renders assembled/
overlaid/voiced cuts, registers derived library assets. Deliberately its
own process so a remote-lane box needs only Postgres + S3 + ffmpeg.

Web: compose media attachments UI (t2), Producer panel in the video
drawer (t5), collective creation + branding settings + shell branding
(brandweb), mobile CSS (mcss: touch targets, icon sizing, safe areas).
2026-10-14 20:40:49 +00:00

273 lines
15 KiB
TypeScript

/**
* 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> = {}): 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<ProducePlan> = {}): 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);
});