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).
273 lines
15 KiB
TypeScript
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);
|
|
});
|