/** * 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"); }