diff --git a/apps/api/migrations/044_assemble_claim.sql b/apps/api/migrations/044_assemble_claim.sql new file mode 100644 index 0000000..34bbe80 --- /dev/null +++ b/apps/api/migrations/044_assemble_claim.sql @@ -0,0 +1,54 @@ +-- 044: the produce worker claims ASSEMBLE jobs (editor-v2 L1, the worker assemble lane). +-- +-- An assemble row is an editor-v2 timeline render: input_json carries +-- { edlProjectId, project: EdlProject } (the EDL shapes live in +-- apps/api/src/media-control/edl.ts), video_asset_id points at the project's +-- first source clip, and the produced output is a derived video_assets row +-- exactly like a compose/mix produce. 043 owns the intake half of the feature +-- (edl_projects + widening video_processing_jobs.kind's CHECK to admit +-- 'assemble'); THIS migration is only the worker's half — the claim's kind +-- filter widened so the lane can pick those rows up. +-- +-- Same body as 042 with 'assemble' added to the kind list; 'transcoding' +-- remains the lease state and the stale-lease sweep is unchanged, so a worker +-- that dies mid-assembly still hands its row back through the same path a +-- dead compose/mix encode does today. + +CREATE OR REPLACE FUNCTION produce_job_claim(p_lane text, p_stale_after interval) +RETURNS TABLE ( + id uuid, + workspace_id uuid, + kind text, + input_json jsonb, + video_asset_id uuid +) +LANGUAGE plpgsql SECURITY DEFINER SET search_path = public AS $$ +BEGIN + UPDATE video_processing_jobs stale + SET state = 'queued', progress_percent = 0, error = NULL, updated_at = now() + WHERE stale.state = 'transcoding' + AND stale.updated_at < now() - p_stale_after; + + RETURN QUERY + UPDATE video_processing_jobs j + SET state = 'transcoding', progress_percent = 5, error = NULL, updated_at = now() + WHERE j.id IN ( + SELECT c.id + FROM video_processing_jobs c + WHERE c.kind IN ('probe', 'compose', 'mix', 'assemble') + AND c.state = 'queued' + AND ( + (p_lane = 'remote' AND c.input_json->>'lane' = 'remote') + OR (p_lane <> 'remote' AND c.input_json->>'lane' IS DISTINCT FROM 'remote') + ) + ORDER BY c.created_at, c.id + FOR UPDATE SKIP LOCKED + LIMIT 1 + ) + RETURNING j.id, j.workspace_id, j.kind, j.input_json, j.video_asset_id; +END +$$; + +-- Ownership unchanged from 040/042. +REVOKE ALL ON FUNCTION produce_job_claim(text, interval) FROM PUBLIC; +GRANT EXECUTE ON FUNCTION produce_job_claim(text, interval) TO sp_app; diff --git a/apps/media-worker/src/producer/assemble-builder.test.ts b/apps/media-worker/src/producer/assemble-builder.test.ts new file mode 100644 index 0000000..353c86f --- /dev/null +++ b/apps/media-worker/src/producer/assemble-builder.test.ts @@ -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 { + 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 { + return parseAssembleInput({ edlProjectId: EDL_ID, project: project(overrides.project as unknown as EdlProject), ...overrides } as unknown as AssembleInput); +} + +function plan(overrides: Partial = {}, projectOverrides: Partial = {}): 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 \.png/); +}); diff --git a/apps/media-worker/src/producer/assemble-builder.ts b/apps/media-worker/src/producer/assemble-builder.ts new file mode 100644 index 0000000..b0d9ba2 --- /dev/null +++ b/apps/media-worker/src/producer/assemble-builder.ts @@ -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 . 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; +} diff --git a/apps/media-worker/src/producer/command-builder.ts b/apps/media-worker/src/producer/command-builder.ts index 3e1f889..e265304 100644 --- a/apps/media-worker/src/producer/command-builder.ts +++ b/apps/media-worker/src/producer/command-builder.ts @@ -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"; diff --git a/apps/media-worker/src/producer/edl-plan.test.ts b/apps/media-worker/src/producer/edl-plan.test.ts new file mode 100644 index 0000000..cdeafcb --- /dev/null +++ b/apps/media-worker/src/producer/edl-plan.test.ts @@ -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 = {}): Record { + 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, overrides: Record = {}): Record { + 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 } }); +}); diff --git a/apps/media-worker/src/producer/edl-plan.ts b/apps/media-worker/src/producer/edl-plan.ts new file mode 100644 index 0000000..df92b28 --- /dev/null +++ b/apps/media-worker/src/producer/edl-plan.ts @@ -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; + 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 { + 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; +} + +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(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).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 = {}; + for (const [key, entry] of Object.entries(fieldsRaw as Record)) { + 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"); +} diff --git a/apps/media-worker/src/producer/overlay-png.test.ts b/apps/media-worker/src/producer/overlay-png.test.ts new file mode 100644 index 0000000..0c35be9 --- /dev/null +++ b/apps/media-worker/src/producer/overlay-png.test.ts @@ -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 { + 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 & 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 & "Crew"' }); + assert.ok(svg.includes(""), "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(/ 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); +}); diff --git a/apps/media-worker/src/producer/overlay-png.ts b/apps/media-worker/src/producer/overlay-png.ts new file mode 100644 index 0000000..a7b1e29 --- /dev/null +++ b/apps/media-worker/src/producer/overlay-png.ts @@ -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): { 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: `${title}${subtitle}`, + }; +} + +/** + * 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) => + `${escapeXml(line)}`).join(""); + const accentHeight = style === "title" ? Math.round(h * 0.5) : Math.round(h * 0.16); + const accentY = Math.round((h - accentHeight) / 2); + const svg = `${style === "title" ? `` : ``}${body}`; + 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 { + if (activeJobs >= MAX_CONCURRENT_RENDERS) throw new Error("overlay render capacity exceeded"); + activeJobs += 1; + try { + return await new Promise((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 { + 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 { + 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 { + 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) }; +} diff --git a/apps/media-worker/src/producer/pg-store.ts b/apps/media-worker/src/producer/pg-store.ts index 01e41c8..d8589b6 100644 --- a/apps/media-worker/src/producer/pg-store.ts +++ b/apps/media-worker/src/producer/pg-store.ts @@ -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), 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 { + 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); + }); + } + /** * 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 diff --git a/apps/media-worker/src/producer/runner.test.ts b/apps/media-worker/src/producer/runner.test.ts index 9750365..f3617e9 100644 --- a/apps/media-worker/src/producer/runner.test.ts +++ b/apps/media-worker/src/producer/runner.test.ts @@ -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, diff --git a/apps/media-worker/src/producer/runner.ts b/apps/media-worker/src/producer/runner.ts index ed72245..54df732 100644 --- a/apps/media-worker/src/producer/runner.ts +++ b/apps/media-worker/src/producer/runner.ts @@ -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; + /** + * 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; private running: Promise | 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 { + 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(); + 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(); + const download = async (storageKey: string): Promise => { + 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 { + 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(promise: Promise, ms: number, name: ProduceFailure): Promise { +function withTimeout(promise: Promise, ms: number, name: ProduceFailure | import("./assemble-builder").AssembleFailure): Promise { return new Promise((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); }); }); diff --git a/apps/media-worker/src/producer/store.ts b/apps/media-worker/src/producer/store.ts index 34b5cf6..1bea640 100644 --- a/apps/media-worker/src/producer/store.ts +++ b/apps/media-worker/src/producer/store.ts @@ -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; /** Fail a claimed job; `reason` must already be redacted/bounded. */ fail(jobId: string, reason: string): Promise; - /** 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; } /**