/** * 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|bin)$/;; 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, trimSpanSec: string): 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, ((entry.trimEndMs - entry.trimStartMs) / 1000).toFixed(3))}[${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,atrim=0:${(source.durationMs / 1000).toFixed(3)},asetpts=PTS-STARTPTS,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 . 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 .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 .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; }