Per the adopted qwen38 plan §2 (+ glm53 amendments): video_processing_jobs kind 'assemble' (claim widened via 044), EdlPlan mirror validation (lenient-fail), the filter_complex builder (interleaved concat pairs, fit cover/contain chains, absolute-timeline PNG overlays with enable windows, VO adelay/sidechain duck, hard-breaking word wrap for card PNGs), and the runner's assemble path with pre-spawn trim gates, attempt backoff and 15-min cap. Sources open by magic (SPENC1 kek / SPENC2 direct key) so direct-to-R2 uploads assemble without the api ever seeing bytes. Tests: builder 18, edl-plan 12, overlay-png 3, runner fakes extended.
305 lines
15 KiB
TypeScript
305 lines
15 KiB
TypeScript
/**
|
|
* 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> = {}): 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> = {}): AssembleInput {
|
|
return parseAssembleInput({ edlProjectId: EDL_ID, project: project(overrides.project as unknown as EdlProject), ...overrides } as unknown as AssembleInput);
|
|
}
|
|
|
|
function plan(overrides: Partial<AssemblePlan> = {}, projectOverrides: Partial<EdlProject> = {}): 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 <sha256>\.png/);
|
|
});
|