feat: editor-v2 assemble lane — multi-clip/card timeline rendering in the worker
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.
This commit is contained in:
parent
df62aa88d4
commit
10dfda3b7c
12 changed files with 1749 additions and 8 deletions
305
apps/media-worker/src/producer/assemble-builder.test.ts
Normal file
305
apps/media-worker/src/producer/assemble-builder.test.ts
Normal file
|
|
@ -0,0 +1,305 @@
|
|||
/**
|
||||
* 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/);
|
||||
});
|
||||
383
apps/media-worker/src/producer/assemble-builder.ts
Normal file
383
apps/media-worker/src/producer/assemble-builder.ts
Normal file
|
|
@ -0,0 +1,383 @@
|
|||
/**
|
||||
* Assemble command builder (editor-v2 L1; adopted plan §2 graph, BINDING).
|
||||
*
|
||||
* Typed plan in, argv out — the same discipline as produce/command-builder.ts:
|
||||
* nothing here takes a free-form filter string, and NO operator text ever
|
||||
* reaches a filter. That is the plan's injection stance made concrete:
|
||||
*
|
||||
* clips → the asset's scratch path (named by content hash) + numeric -ss/-t
|
||||
* cards → a PNG whose pixels are the api-side renderer's (rasterized off the
|
||||
* filtergraph entirely — "no drawtext at all" per the verdict)
|
||||
* overlays → PNGs too: an EdlOverlay names the api's overlay templates, and the
|
||||
* SAME mechanism the compose path uses for card/overlay pixels —
|
||||
* production/render.ts's overlaySvg → sharp worker → PNG — produces
|
||||
* them (see overlay-png.ts); ffmpeg only ever composites the PNG
|
||||
* text → never a filter argument anywhere in this module
|
||||
*
|
||||
* Graph (one process, one filter_complex, one encode):
|
||||
* per clip i [i:v] transpose → scale/crop-or-pad (fit) → setsar=1 → fps=30 → format [vK]
|
||||
* [i:a] aformat… , volume=dB [aK]
|
||||
* per card k (-loop 1 -t dur PNG) [c:v] fps=30,format [vK] + anullsrc → [aK]
|
||||
* concat n=N:v=1:a=1 → [vcat][acat]
|
||||
* per overlay [vcat][oJ] overlay=enable='between(t,s,e)' [vJ] (absolute timeline ms)
|
||||
* voiceover adelay=offsetMs → volume=gain; duckOriginal → sidechaincompress
|
||||
* keyed by the VO (the produce path's duck chain), else amix duration=first
|
||||
* out libx264 preset medium crf 20, aac 192k, +faststart
|
||||
*/
|
||||
import { EDL_ASPECTS, type AssembleInput, type EdlClip, type EdlItem, type EdlOverlay, type EdlRotate } from "./edl-plan";
|
||||
import { PRODUCE_PRESET } from "./command-builder";
|
||||
|
||||
export class AssembleBuildError extends Error {
|
||||
constructor(message: string) { super(message); this.name = "AssembleBuildError"; }
|
||||
}
|
||||
function fail(message: string): never { throw new AssembleBuildError(message); }
|
||||
|
||||
/** Output encoder settings: plan §2 (medium/CRF 20/aac 192k), sample rate from the produce preset. */
|
||||
export const ASSEMBLE_PRESET = {
|
||||
videoCodec: "libx264", preset: "medium", crf: 20,
|
||||
audioCodec: "aac", audioBitrateKbps: 192,
|
||||
sampleRate: PRODUCE_PRESET.sampleRate, fps: PRODUCE_PRESET.fps,
|
||||
/** 15-min hard cap on one assemble (the gate the runner enforces too). */
|
||||
maxDurationMs: 15 * 60_000,
|
||||
} as const;
|
||||
|
||||
/** Scratch paths are absolute, depth-bounded, and every component is name-safe. */
|
||||
const SAFE_NAME = /^[0-9a-zA-Z][0-9a-zA-Z._-]{0,127}$/;
|
||||
function scratchPath(path: string, field: string): string {
|
||||
if (!path.startsWith("/") || path.includes("..")) fail(`${field} must be an absolute scratch path`);
|
||||
const parts = path.slice(1).split("/");
|
||||
if (parts.length > 8) fail(`${field} is too deep`);
|
||||
for (const part of parts) if (!SAFE_NAME.test(part)) fail(`${field} has an unsafe component`);
|
||||
return path;
|
||||
}
|
||||
|
||||
/** A downloaded source/overlay/VO file: a bare 64-hex name + known extension. */
|
||||
const SCRATCH_FILE = /^[0-9a-f]{64}\.(mp4|png|webm|m4a|aac|mp3|wav)$/;
|
||||
|
||||
export interface AssembleClipSource {
|
||||
/** The EdlClip this input renders (its trim/rotate/volume ride along). */
|
||||
clip: EdlClip;
|
||||
/** Scratch path of the downloaded asset bytes. */
|
||||
path: string;
|
||||
/** The probe row's durationMs — the pre-spawn trim gate is checked against it. */
|
||||
durationMs: number;
|
||||
/** hasAudio===false → the clip's audio is a matching anullsrc segment, never [i:a]. */
|
||||
hasAudio: boolean;
|
||||
}
|
||||
|
||||
export interface AssembleCardSource {
|
||||
itemIndex: number;
|
||||
/** Scratch path of the card's full-frame PNG (renderer output, the target canvas size). */
|
||||
path: string;
|
||||
durationMs: number;
|
||||
}
|
||||
|
||||
export interface AssembleOverlaySource {
|
||||
overlay: EdlOverlay;
|
||||
/** Scratch path of the rendered overlay PNG. */
|
||||
path: string;
|
||||
}
|
||||
|
||||
export interface AssembleVoiceoverSource {
|
||||
path: string;
|
||||
gainDb: number;
|
||||
duckOriginal: boolean;
|
||||
offsetMs: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* The validated, downloaded, pre-gated plan. Built by the runner once every
|
||||
* input exists on disk and every trim has been checked against its probe
|
||||
* duration; the builder only re-validates shapes from here on.
|
||||
*/
|
||||
export interface AssemblePlan {
|
||||
input: AssembleInput;
|
||||
/** Timeline inputs in ORDER (concat order), each already carrying its scratch path. */
|
||||
clips: AssembleClipSource[];
|
||||
cards: AssembleCardSource[];
|
||||
overlays: AssembleOverlaySource[];
|
||||
voiceover: AssembleVoiceoverSource | null;
|
||||
/** Scratch path of the mp4 written. */
|
||||
outputPath: string;
|
||||
}
|
||||
|
||||
/** Where each downloaded file landed in the argv input order (0-based `-i` indexes). */
|
||||
export interface AssembleInputIndex {
|
||||
/** One index per timeline item, in timeline order. */
|
||||
timeline: number[];
|
||||
/** One index per overlay PNG, in overlay order. */
|
||||
overlays: number[];
|
||||
voiceover: number | null;
|
||||
}
|
||||
|
||||
/** Transpose filter per closed rotation enum (0 → passthrough keeps the chain uniform). */
|
||||
export function transposeFor(rotate: EdlRotate): string {
|
||||
switch (rotate) {
|
||||
case 0: return "transpose=nosuch"; // replaced by the caller; unreachable
|
||||
case 90: return "transpose=1";
|
||||
case 180: return "transpose=1,transpose=1";
|
||||
case 270: return "transpose=2";
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The per-clip video fit chain, EXPORTED for the unit tests and for reasoning
|
||||
* about the two fit modes:
|
||||
*
|
||||
* cover = center-crop: scale so the canvas is fully covered, then crop the excess
|
||||
* contain = letterbox: scale to fit INSIDE the canvas, then pad the remainder with
|
||||
* the identity black (#060403 lives in the renderer's pixels, not ffmpeg's
|
||||
* pad color, so ffmpeg pads plain black)
|
||||
*
|
||||
* Rotation is applied BEFORE fitting (a 90° phone clip of a vertical action shot
|
||||
* must fit the portrait canvas, not the pre-rotation rectangle).
|
||||
*/
|
||||
export function clipVideoChain(rotate: EdlRotate, fit: "cover" | "contain", aspect: keyof typeof EDL_ASPECTS): string {
|
||||
const { w: W, h: H } = EDL_ASPECTS[aspect];
|
||||
const rotated = rotate !== 0 ? `${transposeFor(rotate)},` : "";
|
||||
if (fit === "cover") {
|
||||
return `${rotated}scale=${W}:${H}:force_original_aspect_ratio=increase:force_divisible_by=2,crop=${W}:${H},setsar=1,fps=${ASSEMBLE_PRESET.fps},format=yuv420p`;
|
||||
}
|
||||
return `${rotated}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=${ASSEMBLE_PRESET.fps},format=yuv420p`;
|
||||
}
|
||||
|
||||
function clipAudioChain(volumeDb: number, hasAudio: boolean, inputIndex: number): string {
|
||||
const aformat = `aformat=sample_fmts=fltp:sample_rates=${ASSEMBLE_PRESET.sampleRate}:channel_layouts=stereo`;
|
||||
if (!hasAudio) return `anullsrc=r=${ASSEMBLE_PRESET.sampleRate}:cl=stereo,aformat=sample_fmts=fltp:sample_rates=${ASSEMBLE_PRESET.sampleRate}:channel_layouts=stereo`;
|
||||
return `[${inputIndex}:a]${aformat}${volumeDb === 0 ? "" : `,volume=${volumeDb}dB`}`;
|
||||
}
|
||||
|
||||
/** Absolute timeline ms → seconds for `between(t,…)`; 3 decimals keeps ms honest. */
|
||||
export function secondsOf(ms: number): string { return (ms / 1000).toFixed(3); }
|
||||
|
||||
/** An item's timeline length in ms: a clip's trim span, a card's duration. */
|
||||
export function itemDurationMs(item: EdlItem): number {
|
||||
return item.kind === "clip" ? item.trimEndMs - item.trimStartMs : item.durationMs;
|
||||
}
|
||||
|
||||
/** Timeline-relative start of item i (item durations accumulate, in order). */
|
||||
export function itemStartMs(items: readonly EdlItem[], index: number): number {
|
||||
let start = 0;
|
||||
for (let i = 0; i < index; i += 1) start += itemDurationMs(items[i]!);
|
||||
return start;
|
||||
}
|
||||
|
||||
/** Timeline length in ms (concat order is items order). */
|
||||
export function timelineTotalMs(items: readonly EdlItem[]): number {
|
||||
return items.reduce((total, item) => total + itemDurationMs(item), 0);
|
||||
}
|
||||
|
||||
/**
|
||||
* The whole `-filter_complex` string. Ordering is load-bearing:
|
||||
* 1. per-item chains (timeline order) — clip chains consume their input pads,
|
||||
* card video chains consume the looped PNG pads, card audio is anullsrc
|
||||
* 2. concat v=1:a=1 over every item in timeline order
|
||||
* 3. overlay chain over [vcat], each gated by enable='between(t,s,e)' with
|
||||
* ABSOLUTE timeline seconds (the overlays track is timeline-absolute per plan d3)
|
||||
* 4. audio: [acat] alone, + adelay'd VO mixed with amix duration=first, or
|
||||
* ducked through sidechaincompress keyed by the (delayed) VO
|
||||
*/
|
||||
export function buildAssembleFilter(plan: AssemblePlan, index: AssembleInputIndex): string {
|
||||
const project = plan.input.project;
|
||||
const items = project.items;
|
||||
if (index.timeline.length !== items.length) fail("one input index per timeline item is required");
|
||||
if (index.overlays.length !== plan.overlays.length) fail("one input index per overlay is required");
|
||||
if (plan.clips.length + plan.cards.length !== items.length) fail("clips + cards must cover every timeline item");
|
||||
if (project.voiceover !== null && plan.voiceover === null) fail("the project names a voiceover the plan did not download");
|
||||
if (project.voiceover === null && plan.voiceover !== null) fail("the plan carries a voiceover the project does not name");
|
||||
|
||||
const chains: string[] = [];
|
||||
let clipCursor = 0;
|
||||
let cardCursor = 0;
|
||||
|
||||
items.forEach((entry, i) => {
|
||||
const v = `v${i}`;
|
||||
const a = `a${i}`;
|
||||
if (entry.kind === "clip") {
|
||||
// Match by VALUE (assetId + trim + rotate + volume): the runner builds the
|
||||
// plan from parsed items, and a re-parse produces equal-but-distinct objects.
|
||||
const source = plan.clips[clipCursor];
|
||||
if (source === undefined || clipEquals(source.clip, entry) === false) fail(`clip ${i} is missing its downloaded source`);
|
||||
const at = index.timeline[i];
|
||||
if (at === undefined) fail(`clip ${i} has no input index`);
|
||||
chains.push(`[${at}:v]${clipVideoChain(entry.rotate, project.fit, project.aspect)}[${v}]`);
|
||||
chains.push(`${clipAudioChain(entry.volumeDb, source.hasAudio, at)}[${a}]`);
|
||||
clipCursor += 1;
|
||||
} else {
|
||||
const source = plan.cards[cardCursor];
|
||||
if (source === undefined || source.itemIndex !== i) fail(`card ${i} is missing its rendered PNG`);
|
||||
const at = index.timeline[i];
|
||||
if (at === undefined) fail(`card ${i} has no input index`);
|
||||
// -loop 1 -t {dur} supplies the frames; the chain only normalizes to the canvas.
|
||||
chains.push(`[${at}:v]scale=${EDL_ASPECTS[project.aspect].w}:${EDL_ASPECTS[project.aspect].h},setsar=1,fps=${ASSEMBLE_PRESET.fps},format=yuv420p[${v}]`);
|
||||
chains.push(`anullsrc=r=${ASSEMBLE_PRESET.sampleRate}:cl=stereo,aformat=sample_fmts=fltp:sample_rates=${ASSEMBLE_PRESET.sampleRate}:channel_layouts=stereo[${a}]`);
|
||||
cardCursor += 1;
|
||||
}
|
||||
});
|
||||
if (clipCursor !== plan.clips.length || cardCursor !== plan.cards.length) fail("clips/cards disagree with the timeline");
|
||||
|
||||
// concat v=1:a=1 consumes SEGMENT-INTERLEAVED pairs ([v0][a0][v1][a1]…) —
|
||||
// grouped v-then-a order is a runtime ffmpeg failure.
|
||||
const concatIn = items.map((_, i) => `[v${i}][a${i}]`).join("");
|
||||
chains.push(`${concatIn}concat=n=${items.length}:v=1:a=1[vcat][acat]`);
|
||||
|
||||
// Overlays: absolute-timeline windows over the concatenated base. PNG inputs are
|
||||
// single-frame loops held by eof_action=repeat (the compositor's asset rule).
|
||||
let cur = "vcat";
|
||||
plan.overlays.forEach((source, j) => {
|
||||
const next = j === plan.overlays.length - 1 ? "vout" : `ov${j}`;
|
||||
const at = index.overlays[j];
|
||||
if (at === undefined) fail(`overlay ${j} has no input index`);
|
||||
const s = secondsOf(source.overlay.startMs);
|
||||
const e = secondsOf(source.overlay.endMs);
|
||||
chains.push(`[${cur}][${at}:v]overlay=x=0:y=0:eof_action=repeat:enable='between(t,${s},${e})'[${next}]`);
|
||||
cur = next;
|
||||
});
|
||||
// With no overlays the concatenated base IS the video out; a null passthrough
|
||||
// keeps [vout] a real, mapped label.
|
||||
if (plan.overlays.length === 0) chains.push(`[vcat]null[vout]`);
|
||||
|
||||
// Audio: the concatenated bed, plus the voiceover.
|
||||
const vo = plan.voiceover;
|
||||
if (vo === null) {
|
||||
chains.push(`[acat]anull[aout]`);
|
||||
return chains.join(";");
|
||||
}
|
||||
const voIndex = index.voiceover ?? fail("voiceover input index required");
|
||||
const aformat = `aformat=sample_fmts=fltp:sample_rates=${ASSEMBLE_PRESET.sampleRate}:channel_layouts=stereo`;
|
||||
const delay = vo.offsetMs > 0 ? `adelay=${vo.offsetMs}:all=1` : "";
|
||||
const gain = vo.gainDb !== 0 ? `volume=${vo.gainDb}dB` : "";
|
||||
const voChain = `[${voIndex}:a]${aformat}${delay ? `,${delay}` : ""}${gain ? `,${gain}` : ""}`;
|
||||
if (vo.duckOriginal) {
|
||||
// The duck path: the delayed VO keys a compressor on the bed, and the same
|
||||
// delayed VO is what gets mixed in — the sidechain "hears" the VO exactly
|
||||
// where the audience does, so the duck opens in time with the offset.
|
||||
chains.push(`${voChain},asplit=2[voMix][voKey]`);
|
||||
chains.push(`[acat][voKey]sidechaincompress=threshold=0.03:ratio=8:attack=20:release=300:makeup=1:link=average[duck]`);
|
||||
chains.push(`[duck][voMix]amix=inputs=2:duration=first:dropout_transition=2:normalize=0[aout]`);
|
||||
} else {
|
||||
chains.push(`${voChain}[voGain]`);
|
||||
chains.push(`[acat][voGain]amix=inputs=2:duration=first:dropout_transition=2:normalize=0[aout]`);
|
||||
}
|
||||
return chains.join(";");
|
||||
}
|
||||
|
||||
/** Value equality for a clip (parse identity is not stable across a re-parse). */
|
||||
function clipEquals(a: EdlClip, b: EdlClip): boolean {
|
||||
return a.assetId === b.assetId && a.trimStartMs === b.trimStartMs && a.trimEndMs === b.trimEndMs
|
||||
&& a.rotate === b.rotate && a.volumeDb === b.volumeDb;
|
||||
}
|
||||
|
||||
/** Pre-spawn trim gate: every clip's out-point must sit inside the probed source. */
|
||||
export function validateTrims(items: readonly EdlItem[], sources: readonly AssembleClipSource[]): void {
|
||||
items.forEach((entry, i) => {
|
||||
if (entry.kind !== "clip") return;
|
||||
const source = sources.find((candidate) => clipEquals(candidate.clip, entry));
|
||||
if (source === undefined) fail(`items[${i}]: no downloaded source for the clip`);
|
||||
if (entry.trimStartMs >= entry.trimEndMs) fail(`items[${i}]: trimStartMs must be before trimEndMs`);
|
||||
if (entry.trimEndMs > source.durationMs) {
|
||||
// The plan's late-failure guard: an out-point past the real duration only
|
||||
// surfaces as ffmpeg's "Invalid data found while processing input" — fail
|
||||
// BEFORE the spawn, with a message a crew can act on.
|
||||
fail(`items[${i}]: trim ${entry.trimEndMs}ms exceeds the source's probed ${source.durationMs}ms`);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function validatePlan(plan: AssemblePlan): void {
|
||||
scratchPath(plan.outputPath, "outputPath");
|
||||
for (const source of plan.clips) {
|
||||
scratchPath(source.path, "clip.path");
|
||||
if (!SCRATCH_FILE.test(source.path.split("/").at(-1) ?? "")) fail("clip.path must be a downloaded <sha256>.<ext> scratch file");
|
||||
if (!(source.durationMs > 0)) fail("clip.durationMs must be positive");
|
||||
}
|
||||
for (const card of plan.cards) {
|
||||
scratchPath(card.path, "card.path");
|
||||
if (!SCRATCH_FILE.test(card.path.split("/").at(-1) ?? "")) fail("card.path must be a rendered <sha256>.png scratch file");
|
||||
}
|
||||
for (const source of plan.overlays) {
|
||||
scratchPath(source.path, "overlay.path");
|
||||
if (!SCRATCH_FILE.test(source.path.split("/").at(-1) ?? "")) fail("overlay.path must be a rendered <sha256>.png scratch file");
|
||||
}
|
||||
if (plan.voiceover !== null) {
|
||||
scratchPath(plan.voiceover.path, "voiceover.path");
|
||||
if (!SCRATCH_FILE.test(plan.voiceover.path.split("/").at(-1) ?? "")) fail("voiceover.path must be a downloaded scratch file");
|
||||
}
|
||||
validateTrims(plan.input.project.items, plan.clips);
|
||||
}
|
||||
|
||||
/**
|
||||
* Full assemble argv. Inputs in strict order: one per timeline item, then one
|
||||
* per overlay PNG, then the voiceover; every timeline clip carries its own
|
||||
* `-ss/-t` BEFORE its `-i` (input seek: accurate under re-encode), cards carry
|
||||
* `-loop 1 -t dur`. A global `-t` bounds the encode at the timeline length so a
|
||||
* looping card input can never run away.
|
||||
*/
|
||||
export function buildAssembleArgs(plan: AssemblePlan): string[] {
|
||||
validatePlan(plan);
|
||||
const project = plan.input.project;
|
||||
const { w: W, h: H } = EDL_ASPECTS[project.aspect];
|
||||
const args = ["-hide_banner", "-nostdin", "-loglevel", "error", "-y"];
|
||||
const index: AssembleInputIndex = { timeline: [], overlays: [], voiceover: null };
|
||||
let next = 0;
|
||||
|
||||
project.items.forEach((entry) => {
|
||||
if (entry.kind === "clip") {
|
||||
const source = plan.clips.find((source) => clipEquals(source.clip, entry));
|
||||
if (source === undefined) fail("clip source missing");
|
||||
const durationSec = (entry.trimEndMs - entry.trimStartMs) / 1000;
|
||||
args.push("-ss", (entry.trimStartMs / 1000).toFixed(3), "-t", durationSec.toFixed(3), "-i", `file:${source.path}`);
|
||||
} else {
|
||||
const source = plan.cards.find((card) => card.itemIndex === project.items.indexOf(entry));
|
||||
if (source === undefined) fail("card source missing");
|
||||
args.push("-loop", "1", "-t", (entry.durationMs / 1000).toFixed(3), "-i", `file:${source.path}`);
|
||||
}
|
||||
index.timeline.push(next);
|
||||
next += 1;
|
||||
});
|
||||
|
||||
for (const source of plan.overlays) {
|
||||
args.push("-i", `file:${source.path}`);
|
||||
index.overlays.push(next);
|
||||
next += 1;
|
||||
}
|
||||
|
||||
if (plan.voiceover !== null) {
|
||||
args.push("-i", `file:${plan.voiceover.path}`);
|
||||
index.voiceover = next;
|
||||
next += 1;
|
||||
}
|
||||
|
||||
args.push("-filter_complex", buildAssembleFilter(plan, index));
|
||||
const totalSec = timelineTotalMs(project.items) / 1000;
|
||||
args.push("-t", totalSec.toFixed(3));
|
||||
args.push(
|
||||
"-map", "[vout]", "-map", "[aout]",
|
||||
"-c:v", ASSEMBLE_PRESET.videoCodec, "-preset", ASSEMBLE_PRESET.preset, "-crf", String(ASSEMBLE_PRESET.crf),
|
||||
"-pix_fmt", "yuv420p",
|
||||
"-g", String(ASSEMBLE_PRESET.fps * 2), "-keyint_min", String(ASSEMBLE_PRESET.fps * 2), "-sc_threshold", "0",
|
||||
"-c:a", ASSEMBLE_PRESET.audioCodec, "-b:a", `${ASSEMBLE_PRESET.audioBitrateKbps}k`,
|
||||
"-ar", String(ASSEMBLE_PRESET.sampleRate),
|
||||
"-movflags", "+faststart",
|
||||
"-metadata", `width=${W}`, "-metadata", `height=${H}`,
|
||||
"-f", "mp4", `file:${plan.outputPath}`,
|
||||
);
|
||||
return args;
|
||||
}
|
||||
|
||||
/** Failure reasons the runner maps onto the job row; none carries operator text or a path. */
|
||||
export type AssembleFailure =
|
||||
| "invalid request"
|
||||
| "asset unavailable"
|
||||
| "render unavailable"
|
||||
| "download failed"
|
||||
| "encode timed out"
|
||||
| "encode failed"
|
||||
| "publish failed";
|
||||
|
||||
export function assembleError(failure: AssembleFailure, detail?: string): Error {
|
||||
const error = new Error(`assemble: ${failure}`);
|
||||
error.name = failure;
|
||||
if (detail !== undefined) (error as Error & { detail?: string }).detail = detail;
|
||||
return error;
|
||||
}
|
||||
|
|
@ -33,7 +33,7 @@ export type Lane = "local" | "remote";
|
|||
* R2, so the api box never touches video bytes. parseProduceRequest still only
|
||||
* accepts compose/mix payloads — a probe row carries no input_json at all.
|
||||
*/
|
||||
export type ProduceJobKind = "probe" | "compose" | "mix";
|
||||
export type ProduceJobKind = "probe" | "compose" | "mix" | "assemble";
|
||||
|
||||
/** Named bottom-row presets the web posts; the API also accepts raw `{x,y}` percent. */
|
||||
export type OverlayPositionPreset = "bottom-left" | "bottom-center" | "bottom-right";
|
||||
|
|
|
|||
157
apps/media-worker/src/producer/edl-plan.test.ts
Normal file
157
apps/media-worker/src/producer/edl-plan.test.ts
Normal file
|
|
@ -0,0 +1,157 @@
|
|||
/**
|
||||
* EdlPlan — the worker's mirror of the api's EDL types (apps/api/src/media-control/
|
||||
* edl.ts), parsed LENIENTLY: the api enforces the strict contract before
|
||||
* enqueueing; the worker re-checks structure and bounds and fails the JOB (never
|
||||
* the process) on anything this build does not understand. The api side owns the
|
||||
* mirror-assert test that keeps the two files in sync.
|
||||
*/
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
import {
|
||||
AssembleInput, EDL_ASPECTS, MAX_ITEMS, MAX_TOTAL_MS, assembleScratchName, gainDbOk, parseAssembleInput, timelineDurationMs,
|
||||
} from "./edl-plan";
|
||||
|
||||
const ASSET = "00000000-0000-4000-8000-000000000001";
|
||||
const VOICEOVER = "00000000-0000-4000-8000-000000000005";
|
||||
const EDL_ID = "00000000-0000-4000-8000-000000000010";
|
||||
|
||||
function project(overrides: Record<string, unknown> = {}): Record<string, unknown> {
|
||||
return {
|
||||
version: 1,
|
||||
aspect: "16:9",
|
||||
fit: "cover",
|
||||
fps: 30,
|
||||
items: [
|
||||
{ kind: "clip", assetId: ASSET, trimStartMs: 0, trimEndMs: 3_000, rotate: 0, volumeDb: 0 },
|
||||
{ kind: "card", text: "Chapter One", durationMs: 2_000, style: "title" },
|
||||
],
|
||||
overlays: [],
|
||||
voiceover: null,
|
||||
outputLabel: "assembly",
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function envelope(projectValue: Record<string, unknown>, overrides: Record<string, unknown> = {}): Record<string, unknown> {
|
||||
return { edlProjectId: EDL_ID, project: projectValue, ...overrides };
|
||||
}
|
||||
|
||||
test("the canonical envelope parses: envelope fields + full project", () => {
|
||||
const parsed: AssembleInput = parseAssembleInput(envelope(project(), { lane: "remote", requestedBy: ASSET }));
|
||||
assert.equal(parsed.edlProjectId, EDL_ID);
|
||||
assert.equal(parsed.lane, "remote");
|
||||
assert.equal(parsed.requestedBy, ASSET);
|
||||
assert.equal(parsed.project.aspect, "16:9");
|
||||
assert.equal(parsed.project.fit, "cover");
|
||||
assert.equal(parsed.project.items.length, 2);
|
||||
assert.equal(parsed.project.items[0]!.kind, "clip");
|
||||
assert.equal(parsed.project.items[1]!.kind, "card");
|
||||
assert.equal(parsed.project.voiceover, null);
|
||||
// lane/requestedBy are optional on the envelope (the api writes lane for routing).
|
||||
const bare = parseAssembleInput(envelope(project()));
|
||||
assert.equal(bare.lane, null);
|
||||
assert.equal(bare.requestedBy, null);
|
||||
});
|
||||
|
||||
test("timeline math: clips by trim span, cards by duration", () => {
|
||||
const parsed = parseAssembleInput(envelope(project({
|
||||
items: [
|
||||
{ kind: "clip", assetId: ASSET, trimStartMs: 1_000, trimEndMs: 4_000, rotate: 0, volumeDb: 0 },
|
||||
{ kind: "card", text: "x", durationMs: 2_500, style: "chapter" },
|
||||
],
|
||||
})));
|
||||
assert.equal(timelineDurationMs(parsed.project), 5_500);
|
||||
});
|
||||
|
||||
test("version is the closed discriminator: anything but 1 is an unknown shape", () => {
|
||||
assert.throws(() => parseAssembleInput(envelope(project({ version: 2 }))), /version must be 1/);
|
||||
assert.throws(() => parseAssembleInput(envelope({ ...project(), version: "1" })), /version must be 1/);
|
||||
});
|
||||
|
||||
test("closed enums: aspect, fit, rotation, card style, overlay type", () => {
|
||||
assert.throws(() => parseAssembleInput(envelope(project({ aspect: "4:3" }))), /aspect/);
|
||||
assert.throws(() => parseAssembleInput(envelope(project({ fit: "fill" }))), /fit/);
|
||||
assert.throws(() => parseAssembleInput(envelope(project({
|
||||
items: [{ kind: "clip", assetId: ASSET, trimStartMs: 0, trimEndMs: 1_000, rotate: 45, volumeDb: 0 }],
|
||||
}))), /rotate/);
|
||||
assert.throws(() => parseAssembleInput(envelope(project({
|
||||
items: [{ kind: "card", text: "x", durationMs: 2_000, style: "epigraph" }],
|
||||
}))), /style/);
|
||||
assert.throws(() => parseAssembleInput(envelope(project({
|
||||
overlays: [{ overlayType: "clock", fields: { title: "x" }, startMs: 0, endMs: 2_000 }],
|
||||
}))), /overlayType/);
|
||||
});
|
||||
|
||||
test("gains sit on the 3 dB ladder within -36..+6 (the api's GAIN_DB_STEP rule)", () => {
|
||||
assert.equal(gainDbOk(0), true);
|
||||
assert.equal(gainDbOk(-36), true);
|
||||
assert.equal(gainDbOk(6), true);
|
||||
assert.equal(gainDbOk(-5), false);
|
||||
assert.equal(gainDbOk(9), false);
|
||||
assert.throws(() => parseAssembleInput(envelope(project({
|
||||
items: [{ kind: "clip", assetId: ASSET, trimStartMs: 0, trimEndMs: 1_000, rotate: 0, volumeDb: -5 }],
|
||||
}))), /multiple of 3 dB/);
|
||||
assert.throws(() => parseAssembleInput(envelope(project({
|
||||
voiceover: { audioAssetId: VOICEOVER, gainDb: 9, duckOriginal: false, offsetMs: 0 },
|
||||
}))), /multiple of 3 dB/);
|
||||
});
|
||||
|
||||
test("bounds: card duration, overlay window, item count, total length", () => {
|
||||
assert.throws(() => parseAssembleInput(envelope(project({
|
||||
items: [{ kind: "card", text: "x", durationMs: 500, style: "title" }],
|
||||
}))), /durationMs must be between/);
|
||||
assert.throws(() => parseAssembleInput(envelope(project({
|
||||
items: [{ kind: "card", text: "x", durationMs: 20_000, style: "title" }],
|
||||
}))), /durationMs must be between/);
|
||||
assert.throws(() => parseAssembleInput(envelope(project({
|
||||
overlays: [{ overlayType: "lower_third", fields: { title: "x" }, startMs: 0, endMs: 500 }],
|
||||
}))), /window must be between/);
|
||||
const many = Array.from({ length: MAX_ITEMS + 1 }, () => ({ kind: "card", text: "x", durationMs: 1_000, style: "chapter" }));
|
||||
assert.throws(() => parseAssembleInput(envelope(project({ items: many }))), /at most 50/);
|
||||
// 15-min total cap: valid clips (trims are probe-gated at runtime, not parse)
|
||||
// with spans summing past MAX_TOTAL_MS fail here.
|
||||
const long = Array.from({ length: 3 }, () => ({ kind: "clip", assetId: ASSET, trimStartMs: 0, trimEndMs: 400_000, rotate: 0, volumeDb: 0 }));
|
||||
assert.throws(() => parseAssembleInput(envelope(project({ items: long }))), /at most 900000 ms/);
|
||||
assert.ok(MAX_TOTAL_MS === 15 * 60_000);
|
||||
});
|
||||
|
||||
test("an empty timeline, or one an overlay outlives, fails", () => {
|
||||
assert.throws(() => parseAssembleInput(envelope(project({ items: [] }))), /must not be empty/);
|
||||
assert.throws(() => parseAssembleInput(envelope(project({
|
||||
overlays: [{ overlayType: "lower_third", fields: { title: "x" }, startMs: 10_000, endMs: 12_000 }],
|
||||
}))), /beyond the timeline/);
|
||||
});
|
||||
|
||||
test("unknown keys are refused everywhere — nothing is silently ignored", () => {
|
||||
assert.throws(() => parseAssembleInput({ edlProjectId: EDL_ID, project: project(), extra: 1 }), /input_json\.extra/);
|
||||
assert.throws(() => parseAssembleInput(envelope(project({ transition: "dissolve" }))), /project\.transition/);
|
||||
assert.throws(() => parseAssembleInput(envelope(project({
|
||||
items: [{ kind: "clip", assetId: ASSET, trimStartMs: 0, trimEndMs: 1_000, rotate: 0, volumeDb: 0, speed: 2 }],
|
||||
}))), /speed/);
|
||||
});
|
||||
|
||||
test("the voiceover block is closed and fully typed", () => {
|
||||
const vo = { audioAssetId: VOICEOVER, gainDb: -3, duckOriginal: true, offsetMs: 250 };
|
||||
const parsed = parseAssembleInput(envelope(project({ voiceover: vo })));
|
||||
assert.deepEqual(parsed.project.voiceover, { ...vo });
|
||||
assert.throws(() => parseAssembleInput(envelope(project({ voiceover: { ...vo, audioAssetId: "not-a-uuid" } }))), /audioAssetId/);
|
||||
assert.throws(() => parseAssembleInput(envelope(project({ voiceover: { ...vo, offsetMs: -1 } }))), /offsetMs/);
|
||||
assert.throws(() => parseAssembleInput(envelope(project({ voiceover: { ...vo, duckOriginal: "yes" } }))), /duckOriginal/);
|
||||
});
|
||||
|
||||
test("the envelope's edlProjectId must be a uuid", () => {
|
||||
assert.throws(() => parseAssembleInput({ edlProjectId: "edl-1", project: project() }), /edlProjectId must be a UUID/);
|
||||
});
|
||||
|
||||
test("the scratch name is the project's hash: same project → same dir, no ids in paths", () => {
|
||||
const a = parseAssembleInput(envelope(project()));
|
||||
const b = parseAssembleInput(envelope(project()));
|
||||
const c = parseAssembleInput(envelope(project({ outputLabel: "changed" })));
|
||||
assert.equal(assembleScratchName(a), assembleScratchName(b));
|
||||
assert.notEqual(assembleScratchName(a), assembleScratchName(c));
|
||||
assert.match(assembleScratchName(a), /^[0-9a-f]{64}$/);
|
||||
});
|
||||
|
||||
test("the aspect table is the plan's two canvases", () => {
|
||||
assert.deepEqual(EDL_ASPECTS, { "16:9": { w: 1280, h: 720 }, "9:16": { w: 1080, h: 1920 } });
|
||||
});
|
||||
278
apps/media-worker/src/producer/edl-plan.ts
Normal file
278
apps/media-worker/src/producer/edl-plan.ts
Normal file
|
|
@ -0,0 +1,278 @@
|
|||
/**
|
||||
* EdlProject — the editor-v2 timeline contract, LENIENTLY validated worker-side.
|
||||
*
|
||||
* mirror of apps/api/src/media-control/edl.ts — keep in sync (mirror-assert lives in the api)
|
||||
*
|
||||
* The api module owns the strict validator crews post against (closed enums,
|
||||
* basis-point geometry, GAIN_DB_STEP multiples, <=50 items, <=15 min total) and a
|
||||
* mirror-assert test that fails when the two files drift. This copy exists so the
|
||||
* worker package never imports the api module graph (the filemap's boundary rule,
|
||||
* same standing as ./ffprobe.ts being a verbatim port of the api's ffprobe).
|
||||
*
|
||||
* Why LENIENT here: input_json is a queued row. The api already rejected every
|
||||
* bad shape before enqueueing; the worker only needs to (a) trust nothing it
|
||||
* would feed to a shell — nothing here ever is, argv only — and (b) fail the JOB,
|
||||
* not the process, when a row is not the shape this build understands (an
|
||||
* upgraded api ahead of the worker, a tampered row, an unknown `version`). So
|
||||
* this parser checks structure + bounds, ignores nothing silently, and throws a
|
||||
* single error type with a field path — the runner maps that to a failed row.
|
||||
*
|
||||
* v1 is closed (the plan's anti-NLE-creep rule): version !== 1 is an unknown
|
||||
* shape, and the mirroring api test is what enforces field-for-field sync.
|
||||
*/
|
||||
import { createHash } from "node:crypto";
|
||||
|
||||
/** The two canvases v1 renders, per the adopted plan §1 (landscape 720p, portrait 1080x1920). */
|
||||
export const EDL_ASPECTS = { "16:9": { w: 1280, h: 720 }, "9:16": { w: 1080, h: 1920 } } as const;
|
||||
export type EdlAspect = keyof typeof EDL_ASPECTS;
|
||||
export const EDL_FIT = ["cover", "contain"] as const;
|
||||
export type EdlFit = (typeof EDL_FIT)[number];
|
||||
export const CARD_STYLES = ["title", "chapter"] as const;
|
||||
export type EdlCardStyle = (typeof CARD_STYLES)[number];
|
||||
/** EdlOverlay.overlayType: the production overlay templates the api renderer knows. */
|
||||
export const OVERLAY_TYPES = ["lower_third", "chyron", "logo"] as const;
|
||||
export type EdlOverlayType = (typeof OVERLAY_TYPES)[number];
|
||||
export const ROTATIONS = [0, 90, 180, 270] as const;
|
||||
export type EdlRotate = (typeof ROTATIONS)[number];
|
||||
|
||||
/** Closed 3 dB ladder for per-clip and voiceover gain (GAIN_DB_STEP, -36..+6). */
|
||||
export const GAIN_DB_STEP = 3;
|
||||
export const GAIN_DB_MIN = -36;
|
||||
export const GAIN_DB_MAX = 6;
|
||||
/** 3 dB-multiple inside the ladder's span; 0 is a multiple of the step. */
|
||||
export function gainDbOk(value: number): boolean {
|
||||
return Number.isInteger(value / GAIN_DB_STEP) && value >= GAIN_DB_MIN && value <= GAIN_DB_MAX;
|
||||
}
|
||||
|
||||
/** Plan §1 caps, enforced leniently here (the api enforces them strictly). */
|
||||
export const MAX_ITEMS = 50;
|
||||
export const MAX_OVERLAYS = 16;
|
||||
export const MAX_TOTAL_MS = 15 * 60_000;
|
||||
export const MAX_CARD_TEXT = 120;
|
||||
export const CARD_DURATION_MS = { min: 1_000, max: 10_000 } as const;
|
||||
export const OVERLAY_WINDOW_MS = { min: 1_000, max: 10_000 } as const;
|
||||
export const MAX_OUTPUT_LABEL = 80;
|
||||
|
||||
export interface EdlClip {
|
||||
kind: "clip";
|
||||
assetId: string;
|
||||
trimStartMs: number;
|
||||
trimEndMs: number;
|
||||
rotate: EdlRotate;
|
||||
volumeDb: number;
|
||||
}
|
||||
|
||||
export interface EdlCard {
|
||||
kind: "card";
|
||||
text: string;
|
||||
durationMs: number;
|
||||
style: EdlCardStyle;
|
||||
}
|
||||
|
||||
export type EdlItem = EdlClip | EdlCard;
|
||||
|
||||
export interface EdlOverlay {
|
||||
overlayType: EdlOverlayType;
|
||||
fields: Record<string, string>;
|
||||
startMs: number;
|
||||
endMs: number;
|
||||
}
|
||||
|
||||
export interface EdlVoiceover {
|
||||
audioAssetId: string;
|
||||
gainDb: number;
|
||||
duckOriginal: boolean;
|
||||
offsetMs: number;
|
||||
}
|
||||
|
||||
export interface EdlProject {
|
||||
version: 1;
|
||||
aspect: EdlAspect;
|
||||
fit: EdlFit;
|
||||
fps: 30;
|
||||
items: EdlItem[];
|
||||
overlays: EdlOverlay[];
|
||||
voiceover: EdlVoiceover | null;
|
||||
outputLabel: string;
|
||||
}
|
||||
|
||||
/** The `video_processing_jobs.input_json` envelope of an assemble row (043). */
|
||||
export interface AssembleInput {
|
||||
edlProjectId: string;
|
||||
project: EdlProject;
|
||||
lane: "local" | "remote" | null;
|
||||
requestedBy: string | null;
|
||||
}
|
||||
|
||||
export class EdlParseError extends Error {
|
||||
constructor(message: string) { super(message); this.name = "EdlParseError"; }
|
||||
}
|
||||
function fail(message: string): never { throw new EdlParseError(message); }
|
||||
|
||||
const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/;
|
||||
|
||||
function record(value: unknown, field: string, allowed: readonly string[]): Record<string, unknown> {
|
||||
if (typeof value !== "object" || value === null || Array.isArray(value)) fail(`${field} must be an object`);
|
||||
for (const key of Object.keys(value)) if (!allowed.includes(key)) fail(`${field}.${key} is not allowed`);
|
||||
return value as Record<string, unknown>;
|
||||
}
|
||||
|
||||
function stringField(value: unknown, field: string, max: number): string {
|
||||
if (typeof value !== "string") fail(`${field} must be a string`);
|
||||
if (value.length > max) fail(`${field} must be at most ${max} characters`);
|
||||
return value;
|
||||
}
|
||||
|
||||
/** Integer ms >= 0 (the plan's "times derived, no floats" rule). */
|
||||
function msField(value: unknown, field: string): number {
|
||||
if (typeof value !== "number" || !Number.isInteger(value) || value < 0) fail(`${field} must be integer milliseconds >= 0`);
|
||||
return value;
|
||||
}
|
||||
|
||||
function intInSet(value: unknown, field: string, set: readonly number[]): number {
|
||||
if (typeof value !== "number" || !Number.isInteger(value) || !set.includes(value)) fail(`${field} must be one of ${set.join("|")}`);
|
||||
return value;
|
||||
}
|
||||
|
||||
function enumIn<T extends string>(value: unknown, field: string, set: readonly T[]): T {
|
||||
if (typeof value !== "string" || !set.includes(value as T)) fail(`${field} must be one of ${set.join("|")}`);
|
||||
return value as T;
|
||||
}
|
||||
|
||||
function uuidField(value: unknown, field: string): string {
|
||||
if (typeof value !== "string" || !UUID.test(value)) fail(`${field} must be a UUID`);
|
||||
return value;
|
||||
}
|
||||
|
||||
function gainDb(value: unknown, field: string): number {
|
||||
const n = typeof value === "number" && Number.isFinite(value) ? Math.round(value * 100) / 100 : NaN;
|
||||
if (!Number.isFinite(n) || !gainDbOk(n)) fail(`${field} must be a multiple of ${GAIN_DB_STEP} dB between ${GAIN_DB_MIN} and ${GAIN_DB_MAX}`);
|
||||
return n;
|
||||
}
|
||||
|
||||
/**
|
||||
* Card/overlay text: the api's overlay-text character class (render.ts's
|
||||
* `text()`): letters, numbers, punctuation, spaces, marks, symbols — no control
|
||||
* characters, no filtergraph metacharacters. The card never reaches a filter
|
||||
* (it becomes a PNG), so this is a belt-and-braces re-check, not the injection
|
||||
* defense; the defense is that text only ever becomes pixels.
|
||||
*/
|
||||
function overlayTextField(value: unknown, field: string, max: number): string {
|
||||
const text = stringField(value, field, max);
|
||||
if (!/^[\p{L}\p{N}\p{P}\p{Zs}\p{M}\p{S}]*$/u.test(text)) fail(`${field} must not contain control characters`);
|
||||
return text;
|
||||
}
|
||||
|
||||
function item(value: unknown, index: number): EdlItem {
|
||||
const field = `project.items[${index}]`;
|
||||
if (typeof value !== "object" || value === null || Array.isArray(value)) fail(`${field} must be an object`);
|
||||
const kind = (value as Record<string, unknown>).kind;
|
||||
if (kind === "clip") {
|
||||
const c = record(value, field, ["kind", "assetId", "trimStartMs", "trimEndMs", "rotate", "volumeDb"]);
|
||||
return {
|
||||
kind: "clip",
|
||||
assetId: uuidField(c.assetId, `${field}.assetId`),
|
||||
trimStartMs: msField(c.trimStartMs, `${field}.trimStartMs`),
|
||||
trimEndMs: msField(c.trimEndMs, `${field}.trimEndMs`),
|
||||
rotate: intInSet(c.rotate, `${field}.rotate`, ROTATIONS) as EdlRotate,
|
||||
volumeDb: gainDb(c.volumeDb, `${field}.volumeDb`),
|
||||
};
|
||||
}
|
||||
if (kind === "card") {
|
||||
const c = record(value, field, ["kind", "text", "durationMs", "style"]);
|
||||
const durationMs = msField(c.durationMs, `${field}.durationMs`);
|
||||
if (durationMs < CARD_DURATION_MS.min || durationMs > CARD_DURATION_MS.max) {
|
||||
fail(`${field}.durationMs must be between ${CARD_DURATION_MS.min} and ${CARD_DURATION_MS.max}`);
|
||||
}
|
||||
return {
|
||||
kind: "card",
|
||||
text: overlayTextField(c.text, `${field}.text`, MAX_CARD_TEXT),
|
||||
durationMs,
|
||||
style: enumIn(c.style, `${field}.style`, CARD_STYLES),
|
||||
};
|
||||
}
|
||||
fail(`${field}.kind must be 'clip' or 'card'`);
|
||||
}
|
||||
|
||||
function overlay(value: unknown, index: number): EdlOverlay {
|
||||
const field = `project.overlays[${index}]`;
|
||||
const o = record(value, field, ["overlayType", "fields", "startMs", "endMs"]);
|
||||
const overlayType = enumIn(o.overlayType, `${field}.overlayType`, OVERLAY_TYPES);
|
||||
const fieldsRaw = o.fields === undefined || o.fields === null ? {} : o.fields;
|
||||
if (typeof fieldsRaw !== "object" || Array.isArray(fieldsRaw)) fail(`${field}.fields must be an object`);
|
||||
const fields: Record<string, string> = {};
|
||||
for (const [key, entry] of Object.entries(fieldsRaw as Record<string, unknown>)) {
|
||||
if (key.length > 64) fail(`${field}.fields has an over-long key`);
|
||||
fields[key] = overlayTextField(entry, `${field}.fields.${key}`, 200);
|
||||
}
|
||||
const startMs = msField(o.startMs, `${field}.startMs`);
|
||||
const endMs = msField(o.endMs, `${field}.endMs`);
|
||||
if (endMs - startMs < OVERLAY_WINDOW_MS.min || endMs - startMs > OVERLAY_WINDOW_MS.max) {
|
||||
fail(`${field} window must be between ${OVERLAY_WINDOW_MS.min} and ${OVERLAY_WINDOW_MS.max} ms`);
|
||||
}
|
||||
if (startMs < 0) fail(`${field}.startMs must be >= 0`);
|
||||
if (overlayType !== "logo" && fields.title === undefined) fail(`${field}.fields.title is required for ${overlayType}`);
|
||||
return { overlayType, fields, startMs, endMs };
|
||||
}
|
||||
|
||||
/** Total timeline length in ms (clips by trim span, cards by their duration). */
|
||||
export function timelineDurationMs(project: EdlProject): number {
|
||||
return project.items.reduce((total, entry) =>
|
||||
total + (entry.kind === "clip" ? entry.trimEndMs - entry.trimStartMs : entry.durationMs), 0);
|
||||
}
|
||||
|
||||
/** Parse `input_json` of an assemble row: { edlProjectId, project, lane?, requestedBy? }. */
|
||||
export function parseAssembleInput(value: unknown): AssembleInput {
|
||||
const env = record(value, "input_json", ["edlProjectId", "project", "lane", "requestedBy"]);
|
||||
const edlProjectId = uuidField(env.edlProjectId, "input_json.edlProjectId");
|
||||
const lane = env.lane === undefined || env.lane === null ? null
|
||||
: enumIn(env.lane, "input_json.lane", ["local", "remote"] as const);
|
||||
const requestedBy = env.requestedBy === undefined || env.requestedBy === null ? null
|
||||
: uuidField(env.requestedBy, "input_json.requestedBy");
|
||||
|
||||
const p = record(env.project, "project", ["version", "aspect", "fit", "fps", "items", "overlays", "voiceover", "outputLabel"]);
|
||||
if (p.version !== 1) fail("project.version must be 1 (this build renders v1 timelines only)");
|
||||
const aspect = enumIn(p.aspect, "project.aspect", Object.keys(EDL_ASPECTS) as EdlAspect[]);
|
||||
const fit = enumIn(p.fit, "project.fit", EDL_FIT);
|
||||
const fps = intInSet(p.fps, "project.fps", [30]);
|
||||
const itemsRaw = p.items === undefined || p.items === null ? [] : p.items;
|
||||
if (!Array.isArray(itemsRaw)) fail("project.items must be an array");
|
||||
if (itemsRaw.length === 0) fail("project.items must not be empty");
|
||||
if (itemsRaw.length > MAX_ITEMS) fail(`project.items allows at most ${MAX_ITEMS} entries`);
|
||||
const items = itemsRaw.map(item);
|
||||
const overlaysRaw = p.overlays === undefined || p.overlays === null ? [] : p.overlays;
|
||||
if (!Array.isArray(overlaysRaw)) fail("project.overlays must be an array");
|
||||
if (overlaysRaw.length > MAX_OVERLAYS) fail(`project.overlays allows at most ${MAX_OVERLAYS} entries`);
|
||||
const overlays = overlaysRaw.map(overlay);
|
||||
|
||||
let voiceover: EdlVoiceover | null = null;
|
||||
if (p.voiceover !== undefined && p.voiceover !== null) {
|
||||
const v = record(p.voiceover, "project.voiceover", ["audioAssetId", "gainDb", "duckOriginal", "offsetMs"]);
|
||||
voiceover = {
|
||||
audioAssetId: uuidField(v.audioAssetId, "project.voiceover.audioAssetId"),
|
||||
gainDb: gainDb(v.gainDb, "project.voiceover.gainDb"),
|
||||
duckOriginal: v.duckOriginal === undefined ? false : typeof v.duckOriginal === "boolean" ? v.duckOriginal : fail("project.voiceover.duckOriginal must be a boolean"),
|
||||
offsetMs: msField(v.offsetMs, "project.voiceover.offsetMs"),
|
||||
};
|
||||
}
|
||||
|
||||
const outputLabel = p.outputLabel === undefined || p.outputLabel === null ? ""
|
||||
: stringField(p.outputLabel, "project.outputLabel", MAX_OUTPUT_LABEL);
|
||||
const project: EdlProject = { version: 1, aspect, fit, fps: 30, items, overlays, voiceover, outputLabel };
|
||||
const total = timelineDurationMs(project);
|
||||
if (total <= 0) fail("project timeline must be longer than 0 ms");
|
||||
if (total > MAX_TOTAL_MS) fail(`project timeline must be at most ${MAX_TOTAL_MS} ms`);
|
||||
// An overlay that starts after the timeline can never show; the api rejects it, we fail it.
|
||||
for (const o of overlays) if (o.startMs >= total) fail("project.overlays start beyond the timeline");
|
||||
return { edlProjectId, project, lane, requestedBy };
|
||||
}
|
||||
|
||||
/**
|
||||
* The job's scratch identity: the same project renders into the same sha-named
|
||||
* dir, so a re-render of an unchanged project reuses fetched assets (the plan's
|
||||
* idempotency rule). Input ids never reach a path — only the hash does.
|
||||
*/
|
||||
export function assembleScratchName(input: AssembleInput): string {
|
||||
const canonical = JSON.stringify({ edlProjectId: input.edlProjectId, project: input.project });
|
||||
return createHash("sha256").update(canonical).digest("hex");
|
||||
}
|
||||
99
apps/media-worker/src/producer/overlay-png.test.ts
Normal file
99
apps/media-worker/src/producer/overlay-png.test.ts
Normal file
|
|
@ -0,0 +1,99 @@
|
|||
/**
|
||||
* Overlay/card PNG renderer for the assemble lane: the api renderer's mechanism
|
||||
* (overlaySvg template → isolated sharp worker → PNG), relocated to the worker so
|
||||
* text becomes PIXELS offline — never a drawtext argument. The SVG-shape tests
|
||||
* are pure functions; the rasterization tests run only when sharp resolves
|
||||
* (SHARP_MODULE_PATH or an ambient install), the same guard the api's render
|
||||
* suite leans on.
|
||||
*/
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
import { createRequire } from "node:module";
|
||||
import { cardSvg, overlayTemplateSvg, renderAssemblyArtwork } from "./overlay-png";
|
||||
import { parseAssembleInput, type EdlProject } from "./edl-plan";
|
||||
|
||||
const ASSET = "00000000-0000-4000-8000-000000000001";
|
||||
const EDL_ID = "00000000-0000-4000-8000-000000000010";
|
||||
|
||||
function project(overrides: Partial<EdlProject> = {}): EdlProject {
|
||||
return parseAssembleInput({
|
||||
edlProjectId: EDL_ID,
|
||||
project: {
|
||||
version: 1, aspect: "16:9", fit: "cover", fps: 30,
|
||||
items: [
|
||||
{ kind: "clip", assetId: ASSET, trimStartMs: 0, trimEndMs: 2_000, rotate: 0, volumeDb: 0 },
|
||||
{ kind: "card", text: "Solidarity Forever", durationMs: 2_000, style: "title" },
|
||||
],
|
||||
overlays: [{ overlayType: "lower_third", fields: { title: "Ada <Lovelace> & Sons" }, startMs: 0, endMs: 2_000 }],
|
||||
voiceover: null, outputLabel: "x",
|
||||
...overrides,
|
||||
},
|
||||
}).project;
|
||||
}
|
||||
|
||||
/** sharp resolution mirrors the module's own resolver (env override first). */
|
||||
function hasSharp(): boolean {
|
||||
const require = createRequire(__filename);
|
||||
try {
|
||||
require.resolve(process.env.SHARP_MODULE_PATH ?? "sharp");
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
const PNG_MAGIC = Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]);
|
||||
|
||||
test("overlay templates keep the api renderer's shape: rect + red accent + escaped text", () => {
|
||||
const { svg, width, height } = overlayTemplateSvg(project(), "lower_third", { title: 'Ada <Lovelace> & "Crew"' });
|
||||
assert.ok(svg.includes("<rect") && svg.includes("#FF1610") && svg.includes('fill="#171717"'));
|
||||
assert.ok(svg.includes("<Lovelace>"), "SVG-significant characters are escaped");
|
||||
assert.ok(svg.includes("& \"Crew\"".replace("& \"Crew\"", "& "Crew"")), "quotes escape too");
|
||||
assert.ok(!svg.includes("<Lovelace>"), "no raw markup can survive into the SVG");
|
||||
assert.ok(width < 1280 && height < 720, "a lower third is a bar, not the frame");
|
||||
});
|
||||
|
||||
test("a chyron is a full-width strip; style red swaps the fill", () => {
|
||||
const wide = overlayTemplateSvg(project(), "chyron", { title: "march" });
|
||||
assert.equal(wide.width, 1280);
|
||||
const red = overlayTemplateSvg(project(), "lower_third", { title: "x", style: "red" });
|
||||
assert.ok(red.svg.includes('fill="#FF1610"'));
|
||||
});
|
||||
|
||||
test("a card owns the whole canvas: #060403 ground + wrapped text, per style", () => {
|
||||
const title = cardSvg(project(), "Solidarity Forever", "title");
|
||||
assert.equal(title.width, 1280);
|
||||
assert.equal(title.height, 720);
|
||||
assert.ok(title.svg.includes('fill="#060403"'), "the identity page color is the ground");
|
||||
assert.ok(title.svg.includes("Solidarity Forever"));
|
||||
const portrait = cardSvg(project({ aspect: "9:16" }), "x", "chapter");
|
||||
assert.equal(portrait.width, 1080);
|
||||
assert.equal(portrait.height, 1920);
|
||||
// Long text wraps at a character budget instead of overflowing the frame.
|
||||
const wrapped = cardSvg(project(), "a".repeat(60), "title");
|
||||
assert.ok((wrapped.svg.match(/<text /g) ?? []).length > 1, "60 chars on a title card must wrap to multiple lines");
|
||||
});
|
||||
|
||||
test("rasterized artwork is real PNGs at the canvas geometry", { skip: !hasSharp() ? "needs sharp (SHARP_MODULE_PATH or ambient install)" : false }, async () => {
|
||||
const { cards, overlays } = await renderAssemblyArtwork(parseAssembleInput({
|
||||
edlProjectId: EDL_ID,
|
||||
project: {
|
||||
version: 1, aspect: "9:16", fit: "cover", fps: 30,
|
||||
items: [
|
||||
{ kind: "clip", assetId: ASSET, trimStartMs: 0, trimEndMs: 1_000, rotate: 0, volumeDb: 0 },
|
||||
{ kind: "card", text: "Chapter", durationMs: 1_500, style: "chapter" },
|
||||
],
|
||||
overlays: [{ overlayType: "chyron", fields: { title: "BREAKING" }, startMs: 0, endMs: 1_000 }],
|
||||
voiceover: null, outputLabel: "x",
|
||||
},
|
||||
}));
|
||||
assert.equal(cards.length, 1);
|
||||
assert.equal(overlays.length, 1);
|
||||
for (const png of [...cards, ...overlays]) {
|
||||
assert.ok(png.bytes.subarray(0, 8).equals(PNG_MAGIC), "renderer output is a PNG");
|
||||
assert.match(png.name, /^[0-9a-f]{64}\.png$/, "scratch names are content hashes");
|
||||
}
|
||||
// The card PNG is the full portrait canvas (the renderer wrote it at 1080x1920).
|
||||
assert.equal(cards[0]!.bytes.readUInt32BE(16), 1080);
|
||||
assert.equal(cards[0]!.bytes.readUInt32BE(20), 1920);
|
||||
});
|
||||
193
apps/media-worker/src/producer/overlay-png.ts
Normal file
193
apps/media-worker/src/producer/overlay-png.ts
Normal file
|
|
@ -0,0 +1,193 @@
|
|||
/**
|
||||
* Card + overlay PNG rasterization for the assemble lane (editor-v2 L1).
|
||||
*
|
||||
* THE MECHANISM IS THE API'S, VERBATIM IN SHAPE: apps/api/src/production/render.ts
|
||||
* (overlaySvg → an isolated sharp worker thread → PNG). That is exactly how
|
||||
* compose-path overlay pixels are produced today: production.service.ts renders
|
||||
* an overlay through `renderOverlay`, seals the PNG into object storage, and
|
||||
* ffmpeg only ever sees the sha-named asset (`overlay_revisions.asset_id`).
|
||||
*
|
||||
* The worker needs the same pixels OFFLINE (it has no api to call, and the plan
|
||||
* forbids drawtext for user text outright), so it runs the renderer's exact SVG
|
||||
* templates in its own isolated sharp worker — same template markup, same
|
||||
* geometry, same escape function, same resource limits — and writes the PNG to
|
||||
* the job's scratch dir under a content-hash name.
|
||||
*
|
||||
* sharp itself is resolved like the api resolves it (SHARP_MODULE_PATH, else the
|
||||
* ambient install): the worker image carries the same binary the api does, and a
|
||||
* box without it fails the JOB as `render unavailable` — never a fallback to
|
||||
* drawtext, because a crew must never ship text that was not renderer-rasterized.
|
||||
*/
|
||||
import { Worker } from "node:worker_threads";
|
||||
import { createHash } from "node:crypto";
|
||||
import { createRequire } from "node:module";
|
||||
import { EDL_ASPECTS, type AssembleInput, type EdlProject } from "./edl-plan";
|
||||
|
||||
export const OVERLAY_RENDER_TIMEOUT_MS = 5_000;
|
||||
const MAX_PNG_BYTES = 8_000_000;
|
||||
|
||||
/** The api renderer's escape (render.ts): the only five SVG-significant characters. */
|
||||
function escapeXml(value: string): string {
|
||||
return value.replace(/[&<>"']/g, (c) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" })[c] ?? c);
|
||||
}
|
||||
|
||||
/** The api's overlay-text charset re-check (belt and braces; edl-plan checked it too). */
|
||||
function overlayText(value: string): string {
|
||||
if (!/^[\p{L}\p{N}\p{P}\p{Zs}\p{M}\p{S}]*$/u.test(value)) throw new Error("invalid overlay text");
|
||||
return value;
|
||||
}
|
||||
|
||||
export interface RenderedPng { bytes: Buffer; /** Content-hash scratch filename: <64hex>.png */ name: string }
|
||||
|
||||
/**
|
||||
* The api's lower-third/chyron template, scaled to the assemble canvas: the same
|
||||
* rect + red accent + DejaVu text markup render.ts emits, at the target canvas
|
||||
* geometry so a 9:16 timeline gets a 9:16-wide bar. A logo overlay names a
|
||||
* sanitized geometric SVG (fields.svg), which the api path rasterizes through
|
||||
* the very same worker call — so it is accepted here identically.
|
||||
*/
|
||||
export function overlayTemplateSvg(project: EdlProject, overlayType: string, fields: Record<string, string>): { svg: string; width: number; height: number } {
|
||||
const { w, h } = EDL_ASPECTS[project.aspect];
|
||||
if (overlayType === "logo") {
|
||||
const svg = fields.svg ?? "";
|
||||
if (svg.length > 64_000) throw new Error("unsafe SVG");
|
||||
return { svg, width: Math.min(512, w), height: Math.min(512, h) };
|
||||
}
|
||||
// Chyron is a full-width strip; a lower third is a bar in the lower third.
|
||||
const width = overlayType === "chyron" ? w : Math.round(w * 0.9);
|
||||
const height = overlayType === "chyron" ? Math.max(96, Math.round(h / 12)) : Math.max(120, Math.round(h / 6));
|
||||
const fill = fields.style === "red" ? "#FF1610" : "#171717";
|
||||
const titleSize = Math.round(height * 0.38);
|
||||
const subtitleSize = Math.round(height * 0.24);
|
||||
const title = escapeXml(overlayText(fields.title ?? ""));
|
||||
const subtitle = escapeXml(overlayText(fields.subtitle ?? ""));
|
||||
return {
|
||||
width, height,
|
||||
svg: `<svg xmlns="http://www.w3.org/2000/svg" width="${width}" height="${height}"><rect x="0" y="0" width="${width}" height="${height}" rx="12" fill="${fill}"/><rect width="14" height="${height}" fill="#FF1610"/><text x="48" y="${Math.round(height * 0.42)}" font-family="DejaVu Sans" font-size="${titleSize}" fill="white">${title}</text><text x="48" y="${Math.round(height * 0.72)}" font-family="DejaVu Sans" font-size="${subtitleSize}" fill="white">${subtitle}</text></svg>`,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* A card owns the FULL FRAME for its duration (plan d2), so its PNG is the whole
|
||||
* canvas: #060403 ground, red accent bar, the card text set in the renderer's
|
||||
* font stack. `title` is the hero line; `chapter` is the quieter section mark.
|
||||
*/
|
||||
export function cardSvg(project: EdlProject, text: string, style: "title" | "chapter"): { svg: string; width: number; height: number } {
|
||||
const { w, h } = EDL_ASPECTS[project.aspect];
|
||||
const titleSize = Math.round(w * (style === "title" ? 0.072 : 0.045));
|
||||
const lineHeight = Math.round(titleSize * 1.25);
|
||||
const lines = wrapLines(text, style === "title" ? 22 : 30);
|
||||
const blockHeight = lines.length * lineHeight;
|
||||
const startY = Math.round(h / 2 - blockHeight / 2 + titleSize);
|
||||
const body = lines.map((line, i) =>
|
||||
`<text x="${Math.round(w * 0.09)}" y="${startY + i * lineHeight}" font-family="DejaVu Sans" font-size="${titleSize}" fill="white">${escapeXml(line)}</text>`).join("");
|
||||
const accentHeight = style === "title" ? Math.round(h * 0.5) : Math.round(h * 0.16);
|
||||
const accentY = Math.round((h - accentHeight) / 2);
|
||||
const svg = `<svg xmlns="http://www.w3.org/2000/svg" width="${w}" height="${h}"><rect width="${w}" height="${h}" fill="#060403"/>${style === "title" ? `<rect x="0" y="${accentY}" width="${Math.round(w * 0.012)}" height="${accentHeight}" fill="#FF1610"/>` : `<rect x="${Math.round(w * 0.09)}" y="${startY - titleSize - Math.round(titleSize * 0.6)}" width="${Math.round(w * 0.08)}" height="4" fill="#FF1610"/>`}${body}</svg>`;
|
||||
return { svg, width: w, height: h };
|
||||
}
|
||||
|
||||
/** Simple word wrap at a character budget (the card text cap is 120 chars total). */
|
||||
function wrapLines(text: string, maxChars: number): string[] {
|
||||
// Unbreakable words longer than the budget (hashtags, links) hard-split —
|
||||
// a 60-char wall of text must never overflow the card frame.
|
||||
const words: string[] = [];
|
||||
for (const raw of text.split(/\s+/)) {
|
||||
if (raw.length <= maxChars) { words.push(raw); continue; }
|
||||
for (let i = 0; i < raw.length; i += maxChars) words.push(raw.slice(i, i + maxChars));
|
||||
}
|
||||
if (words.length === 0) return [""];
|
||||
const lines: string[] = [];
|
||||
let current = "";
|
||||
for (const word of words) {
|
||||
if (current === "") current = word;
|
||||
else if ((current + " " + word).length <= maxChars) current += " " + word;
|
||||
else { lines.push(current); current = word; }
|
||||
}
|
||||
if (current !== "") lines.push(current);
|
||||
return lines.slice(0, 5);
|
||||
}
|
||||
|
||||
function sharpPath(): string {
|
||||
// eslint-disable-next-line no-restricted-syntax -- createRequire is how the api resolves sharp too.
|
||||
const require = createRequire(__filename);
|
||||
if (process.env.SHARP_MODULE_PATH) return require.resolve(process.env.SHARP_MODULE_PATH);
|
||||
try { return require.resolve("sharp"); } catch {
|
||||
throw new Error("sharp not found — install it or point SHARP_MODULE_PATH at a sharp build");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The api's RENDER_SCRIPT, character-for-character in behavior: an isolated
|
||||
* worker thread, sharp's cache off and concurrency pinned to 1, contain-fit
|
||||
* resize onto a transparent ground, PNG out, bounded wall time, no credentials
|
||||
* in the thread's environment (render.ts's exact env allowlist).
|
||||
*/
|
||||
const RENDER_SCRIPT = "const {parentPort,workerData}=require('node:worker_threads');const sharp=require(workerData.modulePath);sharp.cache(false);sharp.concurrency(1);sharp(Buffer.from(workerData.svg),{limitInputPixels:16777216}).resize(workerData.width,workerData.height,{fit:'contain',background:{r:0,g:0,b:0,alpha:0}}).png().toBuffer().then(png=>parentPort.postMessage(png)).catch(()=>{throw new Error('PNG rasterization failed')})";
|
||||
|
||||
let activeJobs = 0;
|
||||
const MAX_CONCURRENT_RENDERS = 4;
|
||||
|
||||
async function rasterize(svg: string, width: number, height: number): Promise<Buffer> {
|
||||
if (activeJobs >= MAX_CONCURRENT_RENDERS) throw new Error("overlay render capacity exceeded");
|
||||
activeJobs += 1;
|
||||
try {
|
||||
return await new Promise<Buffer>((resolve, reject) => {
|
||||
const worker = new Worker(RENDER_SCRIPT, {
|
||||
eval: true,
|
||||
workerData: { svg, width, height, modulePath: sharpPath() },
|
||||
resourceLimits: { maxOldGenerationSizeMb: 128, maxYoungGenerationSizeMb: 16, stackSizeMb: 4 },
|
||||
env: { PATH: process.env.PATH, HOME: process.env.HOME, LANG: "C.UTF-8" },
|
||||
});
|
||||
let settled = false;
|
||||
const finish = (error: Error | null, body?: Buffer): void => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
clearTimeout(timer);
|
||||
void worker.terminate();
|
||||
if (error !== null || body === undefined) reject(error ?? new Error("overlay render failed"));
|
||||
else resolve(body);
|
||||
};
|
||||
const timer = setTimeout(() => finish(new Error("overlay render timed out")), OVERLAY_RENDER_TIMEOUT_MS);
|
||||
worker.once("message", (message: unknown) => {
|
||||
if (!(message instanceof Uint8Array) || message.byteLength > MAX_PNG_BYTES || message.byteLength < 8) finish(new Error("invalid overlay render output"));
|
||||
else finish(null, Buffer.from(message));
|
||||
});
|
||||
worker.once("error", (error: Error) => finish(error));
|
||||
worker.once("exit", () => finish(new Error("overlay render exited before producing PNG")));
|
||||
});
|
||||
} finally {
|
||||
activeJobs -= 1;
|
||||
}
|
||||
}
|
||||
|
||||
async function render(svg: string, width: number, height: number): Promise<RenderedPng> {
|
||||
const bytes = await rasterize(svg, width, height);
|
||||
return { bytes, name: `${createHash("sha256").update(bytes).digest("hex")}.png` };
|
||||
}
|
||||
|
||||
/** Render every card of a project to a full-frame PNG (in timeline item order). */
|
||||
export async function renderCards(project: EdlProject): Promise<RenderedPng[]> {
|
||||
const out: RenderedPng[] = [];
|
||||
for (const entry of project.items) {
|
||||
if (entry.kind !== "card") continue;
|
||||
const { svg, width, height } = cardSvg(project, entry.text, entry.style);
|
||||
out.push(await render(svg, width, height));
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Render every timed overlay to its PNG (in overlay order). */
|
||||
export async function renderOverlays(project: EdlProject): Promise<RenderedPng[]> {
|
||||
const out: RenderedPng[] = [];
|
||||
for (const overlay of project.overlays) {
|
||||
const { svg, width, height } = overlayTemplateSvg(project, overlay.overlayType, overlay.fields);
|
||||
out.push(await render(svg, width, height));
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Convenience: cards + overlays in one pass (the runner's call). */
|
||||
export async function renderAssemblyArtwork(input: AssembleInput): Promise<{ cards: RenderedPng[]; overlays: RenderedPng[] }> {
|
||||
return { cards: await renderCards(input.project), overlays: await renderOverlays(input.project) };
|
||||
}
|
||||
|
|
@ -18,9 +18,10 @@
|
|||
import type { Pool, PoolClient } from "pg";
|
||||
import type { Lane, ProduceJobKind } from "./command-builder";
|
||||
import { parseProduceRequest } from "./command-builder";
|
||||
import { parseAssembleInput, type AssembleInput } from "./edl-plan";
|
||||
import type { ClaimedProduceJob, ProduceJobStore, ProduceOutput, ProduceSourceAsset, ProbeResult } from "./store";
|
||||
|
||||
const KINDS: readonly ProduceJobKind[] = ["probe", "compose", "mix"];
|
||||
const KINDS: readonly ProduceJobKind[] = ["probe", "compose", "mix", "assemble"];
|
||||
|
||||
/**
|
||||
* A claimed `transcoding` row older than this is assumed ownerless (the worker
|
||||
|
|
@ -109,8 +110,10 @@ export class PostgresProduceJobStore implements ProduceJobStore {
|
|||
if (claimed.rowCount === 0) return null;
|
||||
const row = claimed.rows[0]!;
|
||||
const workspaceId = uuid(row.workspace_id, "workspace id");
|
||||
const kind = row.kind === "mix" ? "mix" : row.kind === "compose" ? "compose" : row.kind === "probe" ? "probe" : null;
|
||||
const kind = row.kind === "mix" ? "mix" : row.kind === "compose" ? "compose"
|
||||
: row.kind === "probe" ? "probe" : row.kind === "assemble" ? "assemble" : null;
|
||||
let request = null;
|
||||
let assemble: AssembleInput | null = null;
|
||||
if (kind === "compose" || kind === "mix") {
|
||||
try {
|
||||
request = parseProduceRequest(row.input_json, kind);
|
||||
|
|
@ -130,6 +133,25 @@ export class PostgresProduceJobStore implements ProduceJobStore {
|
|||
return null;
|
||||
}
|
||||
}
|
||||
if (kind === "assemble") {
|
||||
// The assemble lane's payload is the EDL envelope (043); parseAssembleInput is
|
||||
// deliberately lenient — unknown shapes (a version the api knows and this
|
||||
// build does not) fail the row HERE with a bounded reason, exactly like an
|
||||
// undecodable compose request: never a strand, never a crash.
|
||||
try {
|
||||
assemble = parseAssembleInput(row.input_json);
|
||||
} catch (error) {
|
||||
const reason = error instanceof Error ? error.message : "undecodable input_json";
|
||||
await this.tenant(workspaceId, (client) =>
|
||||
client.query(
|
||||
`UPDATE video_processing_jobs
|
||||
SET state = 'failed', progress_percent = 100, error = $2, updated_at = now()
|
||||
WHERE id = $1 AND state = 'transcoding'`,
|
||||
[row.id, `assemble invalid request: ${reason.slice(0, 160)}`],
|
||||
));
|
||||
return null;
|
||||
}
|
||||
}
|
||||
if (kind === null) {
|
||||
await this.tenant(workspaceId, (client) =>
|
||||
client.query(
|
||||
|
|
@ -173,6 +195,7 @@ export class PostgresProduceJobStore implements ProduceJobStore {
|
|||
workspaceId,
|
||||
kind,
|
||||
request,
|
||||
assemble,
|
||||
source: sourceFromRow(assets.rows[0] as Record<string, unknown>),
|
||||
claimedAt: this.now().toISOString(),
|
||||
};
|
||||
|
|
@ -265,6 +288,23 @@ export class PostgresProduceJobStore implements ProduceJobStore {
|
|||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* The assemble lane's asset read: the FULL row, because the runner needs the
|
||||
* probe metadata (durationMs is the pre-spawn trim gate's authority, audio_codec
|
||||
* decides the clip's audio bed). Same tenant scope as every post-claim read.
|
||||
*/
|
||||
async assembleAsset(workspaceId: string, assetId: string): Promise<ProduceSourceAsset | null> {
|
||||
return this.tenant(workspaceId, async (client) => {
|
||||
const rows = await client.query(
|
||||
`SELECT id, storage_key, mime_type, size_bytes, width, height, duration_ms, frame_rate, audio_codec
|
||||
FROM video_assets WHERE id = $1`,
|
||||
[assetId],
|
||||
);
|
||||
if (rows.rowCount === 0) return null;
|
||||
return sourceFromRow(rows.rows[0] as Record<string, unknown>);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* A claimed job's workspace, for the tenant-scoped follow-up writes: the 040
|
||||
* definer lookup (012's live_monitor_source_workspace shape), because an
|
||||
|
|
|
|||
|
|
@ -88,6 +88,11 @@ function doubles() {
|
|||
return storageKey === undefined ? null : { storageKey, mimeType: "audio/mp4" };
|
||||
},
|
||||
async completeProbe() { /* probe completions are asserted in the pg-store suite */ },
|
||||
async assembleAsset(_workspaceId, assetId) {
|
||||
const storageKey = assetKeys.get(assetId);
|
||||
return storageKey === undefined ? null
|
||||
: { id: assetId, storageKey, mimeType: "video/mp4", sizeBytes: 1, width: 1280, height: 720, durationMs: 30_000, frameRate: 30, hasAudio: true };
|
||||
},
|
||||
};
|
||||
const storage: ProduceObjectStorage = {
|
||||
async getSealedObject(k) {
|
||||
|
|
@ -111,6 +116,7 @@ function job(input: string, kind: "compose" | "mix", request: unknown, source: P
|
|||
workspaceId: WS,
|
||||
kind,
|
||||
request: parseProduceRequest(request, kind),
|
||||
assemble: null,
|
||||
source: {
|
||||
id: UUID(1), storageKey: input, mimeType: "video/mp4", sizeBytes: 1,
|
||||
width: 320, height: 180, durationMs: 2000, frameRate: 30, hasAudio: true, ...source,
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
/**
|
||||
* Produce runner: the compose/mix job executor, plus the ingest probe lane.
|
||||
* Produce runner: the compose/mix job executor, the ingest probe lane, and the
|
||||
* editor-v2 assemble lane.
|
||||
*
|
||||
* Poll loop in the supervisor's shape (one claim per tick, retry with backoff on
|
||||
* store errors, stop flag honoured between ticks), one ffmpeg per job through
|
||||
|
|
@ -21,7 +22,10 @@ import {
|
|||
buildProduceArgs, produceError, PRODUCE_PRESET, scratchAssetName,
|
||||
type Lane, type ProducePlan, type ProduceFailure,
|
||||
} from "./command-builder";
|
||||
import type { ClaimedProduceJob, ProduceJobStore, ProduceObjectStorage, ProbeResult } from "./store";
|
||||
import { assembleError, buildAssembleArgs, validateTrims, type AssemblePlan, type AssembleClipSource } from "./assemble-builder";
|
||||
import { renderAssemblyArtwork } from "./overlay-png";
|
||||
import { assembleScratchName, EDL_ASPECTS } from "./edl-plan";
|
||||
import type { ClaimedProduceJob, ProduceJobStore, ProduceObjectStorage, ProbeResult, ProduceSourceAsset } from "./store";
|
||||
import { probeVideoFile, type VideoProbeResult } from "./ffprobe";
|
||||
|
||||
export interface ProduceRunnerDeps {
|
||||
|
|
@ -42,12 +46,20 @@ export interface ProduceRunnerDeps {
|
|||
downloadTimeoutMs?: number;
|
||||
/** Hard cap on one probe-lane ffprobe run. */
|
||||
probeTimeoutMs?: number;
|
||||
/** Hard cap on the card+overlay PNG render pass of an assemble job. */
|
||||
renderArtworkTimeoutMs?: number;
|
||||
/** Injection for tests; `which ffmpeg`. */
|
||||
ffmpegBinary?: string;
|
||||
ffprobeBinary?: string;
|
||||
scheduler?: { setTimeout(ms: number, action: () => void): { cancel(): void } };
|
||||
/** The probe lane's ffprobe result; defaults to a real ffprobe run. */
|
||||
probeInput?: (inputPath: string) => Promise<VideoProbeResult>;
|
||||
/**
|
||||
* Card + overlay PNG rasterization (the api renderer's sharp-worker mechanism,
|
||||
* overlay-png.ts). Injectable so tests double it; a box without sharp fails the
|
||||
* JOB as `assemble render unavailable` — there is no drawtext fallback, ever.
|
||||
*/
|
||||
renderArtwork?: (project: import("./edl-plan").EdlProject) => Promise<{ cards: { bytes: Buffer; name: string }[]; overlays: { bytes: Buffer; name: string }[] }>;
|
||||
}
|
||||
|
||||
const DEFAULTS = { pollIntervalMs: 2_000, encodeTimeoutMs: 15 * 60_000, downloadTimeoutMs: 120_000, probeTimeoutMs: 30_000 };
|
||||
|
|
@ -61,6 +73,15 @@ const defaultScheduler = {
|
|||
/** ffmpeg's own progress line is not parsed; these are the checkpoints a job walks through. */
|
||||
const STAGES: ReadonlyArray<[label: string, percent: number]> = [["claimed", 5], ["downloaded", 20], ["encoded", 80], ["published", 95]];
|
||||
|
||||
/**
|
||||
* The assemble lane walks more checkpoints than a single-source produce (artwork,
|
||||
* per-input fetches), so it reports its own ladder; same rule — every write bumps
|
||||
* `updated_at`, which is the lease's liveness signal for 044's stale sweep.
|
||||
*/
|
||||
const ASSEMBLE_STAGES: ReadonlyArray<[label: string, percent: number]> = [
|
||||
["claimed", 5], ["artwork", 15], ["sources", 30], ["encoded", 80], ["published", 95],
|
||||
];
|
||||
|
||||
export class ProduceRunner {
|
||||
private stopped = false;
|
||||
private timer: { cancel(): void } | null = null;
|
||||
|
|
@ -68,6 +89,7 @@ export class ProduceRunner {
|
|||
private readonly encodeTimeoutMs: number;
|
||||
private readonly downloadTimeoutMs: number;
|
||||
private readonly probeTimeoutMs: number;
|
||||
private readonly renderArtworkTimeoutMs: number;
|
||||
private readonly scheduler: NonNullable<ProduceRunnerDeps["scheduler"]>;
|
||||
private running: Promise<unknown> | null = null;
|
||||
|
||||
|
|
@ -76,6 +98,7 @@ export class ProduceRunner {
|
|||
this.encodeTimeoutMs = deps.encodeTimeoutMs ?? DEFAULTS.encodeTimeoutMs;
|
||||
this.downloadTimeoutMs = deps.downloadTimeoutMs ?? DEFAULTS.downloadTimeoutMs;
|
||||
this.probeTimeoutMs = deps.probeTimeoutMs ?? DEFAULTS.probeTimeoutMs;
|
||||
this.renderArtworkTimeoutMs = deps.renderArtworkTimeoutMs ?? 60_000;
|
||||
this.scheduler = deps.scheduler ?? defaultScheduler;
|
||||
}
|
||||
|
||||
|
|
@ -134,6 +157,7 @@ export class ProduceRunner {
|
|||
// writes the metadata back — the api's probeVideo, relocated off the api box
|
||||
// so the direct-to-R2 path never moves video bytes through a request.
|
||||
if (job.kind === "probe") return this.runProbe(job);
|
||||
if (job.kind === "assemble") return this.runAssemble(job);
|
||||
const directory = await mkdtemp(join(this.deps.scratchRoot, "produce-"));
|
||||
try {
|
||||
const plan = await this.prepare(job, directory);
|
||||
|
|
@ -158,6 +182,191 @@ export class ProduceRunner {
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The assemble lane (editor-v2 L1): an EDL timeline → ONE ffmpeg process.
|
||||
*
|
||||
* 1. resolve every clip asset through the store (probe metadata + storage key)
|
||||
* 2. render cards + overlays to PNGs — the api renderer's sharp-worker
|
||||
* mechanism, so text becomes pixels and never a filter argument
|
||||
* 3. download every input to a sha-named scratch file
|
||||
* 4. PRE-SPAWN trim gate: every trimEndMs checked against the probe
|
||||
* durationMs (fail with a human reason, not ffmpeg's "Invalid data found")
|
||||
* 5. one argv (buildAssembleArgs) → encode → publish → derived asset row +
|
||||
* job complete, exactly the compose lane's registerAndProbe-equivalent
|
||||
*
|
||||
* Scratch is the job's own mkdtemp dir, removed in `finally`; stderr redacted;
|
||||
* 15-min hard timeout through the same encode path the compose lane uses.
|
||||
*/
|
||||
private async runAssemble(job: ClaimedProduceJob): Promise<void> {
|
||||
const input = job.assemble;
|
||||
if (input === null) {
|
||||
// The claim already fails undecodable rows; reaching here means a lane mixup.
|
||||
const reason = "assemble invalid request: no EDL payload";
|
||||
await this.deps.jobs.fail(job.jobId, reason).catch(() => undefined);
|
||||
this.deps.onEvent?.({ type: "failed", jobId: job.jobId, detail: reason });
|
||||
return;
|
||||
}
|
||||
const directory = await mkdtemp(join(this.deps.scratchRoot, "assemble-"));
|
||||
try {
|
||||
const project = input.project;
|
||||
const render = this.deps.renderArtwork ?? ((p: import("./edl-plan").EdlProject) => renderAssemblyArtwork({ edlProjectId: input.edlProjectId, project: p, lane: input.lane, requestedBy: input.requestedBy }));
|
||||
const artwork = await withTimeout(render(project), this.renderArtworkTimeoutMs, "render unavailable");
|
||||
await this.deps.jobs.progress(job.jobId, ASSEMBLE_STAGES[1]![1]);
|
||||
|
||||
// Every clip resolves to its asset row; duplicates of one asset are fetched once.
|
||||
const assetIds = [...new Set(project.items.filter((e): e is import("./edl-plan").EdlClip => e.kind === "clip").map((e) => e.assetId))];
|
||||
const assets = new Map<string, ProduceSourceAsset>();
|
||||
for (const assetId of assetIds) {
|
||||
const asset = await this.deps.jobs.assembleAsset(job.workspaceId, assetId);
|
||||
if (asset === null) throw assembleError("asset unavailable", `clip ${assetId}`);
|
||||
assets.set(assetId, asset);
|
||||
}
|
||||
let voiceoverAsset: ProduceSourceAsset | null = null;
|
||||
if (project.voiceover !== null) {
|
||||
voiceoverAsset = await this.deps.jobs.assembleAsset(job.workspaceId, project.voiceover.audioAssetId);
|
||||
if (voiceoverAsset === null) throw assembleError("asset unavailable", "voiceover");
|
||||
}
|
||||
await this.deps.jobs.progress(job.jobId, ASSEMBLE_STAGES[2]![1] / 2);
|
||||
|
||||
// Download: one sha-named scratch file per unique object (cards/overlays are
|
||||
// already rendered; write them under their PNG content hash).
|
||||
const files = new Map<string, string>();
|
||||
const download = async (storageKey: string): Promise<string> => {
|
||||
const existing = files.get(storageKey);
|
||||
if (existing !== undefined) return existing;
|
||||
const path = await this.download(storageKey, directory);
|
||||
files.set(storageKey, path);
|
||||
return path;
|
||||
};
|
||||
const clips: AssembleClipSource[] = [];
|
||||
let cardCursor = 0;
|
||||
for (const entry of project.items) {
|
||||
if (entry.kind !== "clip") continue;
|
||||
const asset = assets.get(entry.assetId)!;
|
||||
clips.push({
|
||||
clip: entry,
|
||||
path: await download(asset.storageKey),
|
||||
durationMs: asset.durationMs ?? 0,
|
||||
hasAudio: asset.hasAudio !== false,
|
||||
});
|
||||
}
|
||||
const cardFiles: { itemIndex: number; path: string; durationMs: number }[] = [];
|
||||
project.items.forEach((entry, i) => {
|
||||
if (entry.kind !== "card") return;
|
||||
const png = artwork.cards[cardCursor];
|
||||
cardCursor += 1;
|
||||
if (png === undefined) throw assembleError("render unavailable", "card artwork missing");
|
||||
cardFiles.push({ itemIndex: i, path: join(directory, png.name), durationMs: entry.durationMs });
|
||||
});
|
||||
if (cardCursor !== artwork.cards.length) throw assembleError("render unavailable", "card artwork mismatch");
|
||||
const overlayFiles: { overlay: import("./edl-plan").EdlOverlay; path: string }[] = [];
|
||||
for (let j = 0; j < project.overlays.length; j += 1) {
|
||||
const png = artwork.overlays[j];
|
||||
if (png === undefined) throw assembleError("render unavailable", "overlay artwork missing");
|
||||
overlayFiles.push({ overlay: project.overlays[j]!, path: join(directory, png.name) });
|
||||
}
|
||||
for (const png of [...artwork.cards, ...artwork.overlays]) {
|
||||
await writeFile(join(directory, png.name), png.bytes, { mode: 0o600 });
|
||||
}
|
||||
let voiceover: { path: string; gainDb: number; duckOriginal: boolean; offsetMs: number } | null = null;
|
||||
if (project.voiceover !== null && voiceoverAsset !== null) {
|
||||
voiceover = {
|
||||
path: await download(voiceoverAsset.storageKey),
|
||||
gainDb: project.voiceover.gainDb,
|
||||
duckOriginal: project.voiceover.duckOriginal,
|
||||
offsetMs: project.voiceover.offsetMs,
|
||||
};
|
||||
}
|
||||
await this.deps.jobs.progress(job.jobId, ASSEMBLE_STAGES[2]![1]);
|
||||
|
||||
const plan: AssemblePlan = {
|
||||
input,
|
||||
clips,
|
||||
cards: cardFiles,
|
||||
overlays: overlayFiles,
|
||||
voiceover,
|
||||
outputPath: join(directory, "output.mp4"),
|
||||
};
|
||||
// Pre-spawn gate (plan §2 risk #4): trims vs probe durations, before any spawn.
|
||||
validateTrims(project.items, clips);
|
||||
|
||||
await this.encodeAssemble(job, plan);
|
||||
await this.deps.jobs.progress(job.jobId, ASSEMBLE_STAGES[3]![1]);
|
||||
const output = await this.publishAssemble(job, plan);
|
||||
await this.deps.jobs.progress(job.jobId, ASSEMBLE_STAGES[4]![1]);
|
||||
// derived_from = the project's FIRST source clip (043's convention; full
|
||||
// provenance lives in the edl_projects row the api owns).
|
||||
const firstSourceId = project.items.find((e): e is import("./edl-plan").EdlClip => e.kind === "clip")?.assetId ?? job.source.id;
|
||||
const producedAssetId = await this.deps.jobs.complete(job.jobId, output, firstSourceId);
|
||||
this.deps.onEvent?.({ type: "complete", jobId: job.jobId, detail: `${output.storageKey} → asset ${producedAssetId} (assemble ${assembleScratchName(input).slice(0, 12)})` });
|
||||
} catch (error: unknown) {
|
||||
const reason = this.assembleReason(error);
|
||||
this.deps.onStderr(`assemble job ${job.jobId}: ${reason}`);
|
||||
await this.deps.jobs.fail(job.jobId, reason).catch((failError: unknown) => {
|
||||
this.deps.onEvent?.({ type: "store-error", jobId: job.jobId, detail: errorMessage(failError) });
|
||||
});
|
||||
this.deps.onEvent?.({ type: "failed", jobId: job.jobId, detail: reason });
|
||||
} finally {
|
||||
await rm(directory, { recursive: true, force: true }).catch(() => undefined);
|
||||
}
|
||||
}
|
||||
|
||||
/** Bounded, secret-free assemble failure reason (known names, never stderr/paths). */
|
||||
private assembleReason(error: unknown): string {
|
||||
const name = error instanceof Error ? error.name : "";
|
||||
const known = ["invalid request", "asset unavailable", "render unavailable", "download failed", "encode timed out", "encode failed", "publish failed"] as const;
|
||||
if ((known as readonly string[]).includes(name)) return `assemble ${name}`;
|
||||
return `assemble failed: ${this.deps.redactor.redact(String(error)).slice(0, 200)}`;
|
||||
}
|
||||
|
||||
/** The assemble encode: same spawn discipline as the compose lane (argv only, redacted stderr, hard timeout). */
|
||||
private async encodeAssemble(job: ClaimedProduceJob, plan: AssemblePlan): Promise<void> {
|
||||
const args = buildAssembleArgs(plan);
|
||||
const redactor = this.deps.redactor;
|
||||
const managed = spawnManaged(this.deps.ffmpegBinary ?? "ffmpeg", args, ["ignore", "ignore", "pipe"], redactor,
|
||||
(message) => this.deps.onStderr(`job ${job.jobId}: ${message}`));
|
||||
const timer = this.scheduler.setTimeout(this.encodeTimeoutMs, () => {
|
||||
void managed.terminate(2_000).then(() => undefined, () => undefined);
|
||||
});
|
||||
try {
|
||||
const exited = await managed.exited;
|
||||
if (this.stopped) throw assembleError("encode failed", "worker stopped");
|
||||
if (exited.code !== 0) throw assembleError(exited.code === null ? "encode timed out" : "encode failed", `code=${exited.code} signal=${exited.signal ?? "-"}`);
|
||||
const info = await stat(plan.outputPath).catch(() => null);
|
||||
if (info === null || info.size === 0) throw assembleError("encode failed", "no output written");
|
||||
} finally {
|
||||
timer.cancel();
|
||||
await managed.terminate(0).catch(() => undefined);
|
||||
}
|
||||
}
|
||||
|
||||
/** The assemble publish: real duration via ffprobe, real canvas geometry, then the shared complete(). */
|
||||
private async publishAssemble(job: ClaimedProduceJob, plan: AssemblePlan): Promise<{ storageKey: string; sizeBytes: number; durationMs: number | null; width: number; height: number; frameRate: number }> {
|
||||
let body: Buffer;
|
||||
try {
|
||||
body = await readFile(plan.outputPath);
|
||||
} catch {
|
||||
throw assembleError("encode failed", "output unreadable");
|
||||
}
|
||||
let key: string;
|
||||
try {
|
||||
const stored = await withTimeout(this.deps.storage.putContent(body, "video/mp4"), this.downloadTimeoutMs, "publish failed");
|
||||
key = stored.key;
|
||||
} catch {
|
||||
throw assembleError("publish failed");
|
||||
}
|
||||
const durationMs = await this.probeDurationMs(plan.outputPath);
|
||||
const canvas = EDL_ASPECTS[plan.input.project.aspect];
|
||||
return {
|
||||
storageKey: key,
|
||||
sizeBytes: body.byteLength,
|
||||
durationMs,
|
||||
width: canvas.w,
|
||||
height: canvas.h,
|
||||
frameRate: 30,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* The probe lane: sealed source → scratch file → ffprobe → metadata onto the
|
||||
* source's own video_assets row → job complete 100. Failures mark the job
|
||||
|
|
@ -375,9 +584,11 @@ function probeTimeoutError(): Error {
|
|||
return error;
|
||||
}
|
||||
|
||||
function withTimeout<T>(promise: Promise<T>, ms: number, name: ProduceFailure): Promise<T> {
|
||||
function withTimeout<T>(promise: Promise<T>, ms: number, name: ProduceFailure | import("./assemble-builder").AssembleFailure): Promise<T> {
|
||||
return new Promise<T>((resolve, reject) => {
|
||||
const timer = setTimeout(() => reject(produceError(name)), ms);
|
||||
const timer = setTimeout(() => reject(
|
||||
name === "render unavailable" ? assembleError(name) : produceError(name as ProduceFailure),
|
||||
), ms);
|
||||
promise.then((value) => { clearTimeout(timer); resolve(value); },
|
||||
(error: unknown) => { clearTimeout(timer); reject(error); });
|
||||
});
|
||||
|
|
|
|||
|
|
@ -8,8 +8,10 @@
|
|||
* the same standing as `ObservedStore`, `AssetStore` and `PreviewObjectStore`.
|
||||
*/
|
||||
import type { Lane, ProduceJobKind, ProduceRequest } from "./command-builder";
|
||||
import type { AssembleInput } from "./edl-plan";
|
||||
|
||||
export type { Lane, ProduceJobKind, ProduceRequest } from "./command-builder";
|
||||
export type { AssembleInput } from "./edl-plan";
|
||||
|
||||
/** The source `video_assets` row a claimed job renders from. */
|
||||
export interface ProduceSourceAsset {
|
||||
|
|
@ -31,6 +33,13 @@ export interface ClaimedProduceJob {
|
|||
kind: ProduceJobKind;
|
||||
/** The validated producer request; null for a probe row (no input_json). */
|
||||
request: ProduceRequest | null;
|
||||
/**
|
||||
* The parsed assemble payload ({ edlProjectId, project }); null on every other
|
||||
* kind. An assemble row's `video_asset_id` points at the project's first clip
|
||||
* (the row's source asset), while the project itself may name many more — the
|
||||
* runner resolves each through `asset()`.
|
||||
*/
|
||||
assemble: AssembleInput | null;
|
||||
source: ProduceSourceAsset;
|
||||
claimedAt: string;
|
||||
}
|
||||
|
|
@ -77,8 +86,14 @@ export interface ProduceJobStore {
|
|||
completeProbe(jobId: string, sourceAssetId: string, result: ProbeResult): Promise<void>;
|
||||
/** Fail a claimed job; `reason` must already be redacted/bounded. */
|
||||
fail(jobId: string, reason: string): Promise<void>;
|
||||
/** Resolve a voiceover asset id to its storage key. */
|
||||
/** Resolve a voiceover/clip asset id to its storage key. */
|
||||
asset(workspaceId: string, assetId: string): Promise<{ storageKey: string; mimeType: string } | null>;
|
||||
/**
|
||||
* Resolve an asset id to its FULL row (the assemble lane needs probe metadata:
|
||||
* durationMs for the pre-spawn trim gate, hasAudio for the audio bed, and the
|
||||
* storage key to download). Returns null when the row is gone (tenant-deleted).
|
||||
*/
|
||||
assembleAsset(workspaceId: string, assetId: string): Promise<ProduceSourceAsset | null>;
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue