T-HUB/apps/media-worker/src/producer/edl-plan.ts
glass beadian 10dfda3b7c feat: editor-v2 assemble lane — multi-clip/card timeline rendering in the worker
Per the adopted qwen38 plan §2 (+ glm53 amendments): video_processing_jobs
kind 'assemble' (claim widened via 044), EdlPlan mirror validation
(lenient-fail), the filter_complex builder (interleaved concat pairs, fit
cover/contain chains, absolute-timeline PNG overlays with enable windows,
VO adelay/sidechain duck, hard-breaking word wrap for card PNGs), and the
runner's assemble path with pre-spawn trim gates, attempt backoff and 15-min
cap. Sources open by magic (SPENC1 kek / SPENC2 direct key) so direct-to-R2
uploads assemble without the api ever seeing bytes. Tests: builder 18,
edl-plan 12, overlay-png 3, runner fakes extended.
2026-10-16 21:12:55 +00:00

278 lines
13 KiB
TypeScript

/**
* EdlProject — the editor-v2 timeline contract, LENIENTLY validated worker-side.
*
* mirror of apps/api/src/media-control/edl.ts — keep in sync (mirror-assert lives in the api)
*
* The api module owns the strict validator crews post against (closed enums,
* basis-point geometry, GAIN_DB_STEP multiples, <=50 items, <=15 min total) and a
* mirror-assert test that fails when the two files drift. This copy exists so the
* worker package never imports the api module graph (the filemap's boundary rule,
* same standing as ./ffprobe.ts being a verbatim port of the api's ffprobe).
*
* Why LENIENT here: input_json is a queued row. The api already rejected every
* bad shape before enqueueing; the worker only needs to (a) trust nothing it
* would feed to a shell — nothing here ever is, argv only — and (b) fail the JOB,
* not the process, when a row is not the shape this build understands (an
* upgraded api ahead of the worker, a tampered row, an unknown `version`). So
* this parser checks structure + bounds, ignores nothing silently, and throws a
* single error type with a field path — the runner maps that to a failed row.
*
* v1 is closed (the plan's anti-NLE-creep rule): version !== 1 is an unknown
* shape, and the mirroring api test is what enforces field-for-field sync.
*/
import { createHash } from "node:crypto";
/** The two canvases v1 renders, per the adopted plan §1 (landscape 720p, portrait 1080x1920). */
export const EDL_ASPECTS = { "16:9": { w: 1280, h: 720 }, "9:16": { w: 1080, h: 1920 } } as const;
export type EdlAspect = keyof typeof EDL_ASPECTS;
export const EDL_FIT = ["cover", "contain"] as const;
export type EdlFit = (typeof EDL_FIT)[number];
export const CARD_STYLES = ["title", "chapter"] as const;
export type EdlCardStyle = (typeof CARD_STYLES)[number];
/** EdlOverlay.overlayType: the production overlay templates the api renderer knows. */
export const OVERLAY_TYPES = ["lower_third", "chyron", "logo"] as const;
export type EdlOverlayType = (typeof OVERLAY_TYPES)[number];
export const ROTATIONS = [0, 90, 180, 270] as const;
export type EdlRotate = (typeof ROTATIONS)[number];
/** Closed 3 dB ladder for per-clip and voiceover gain (GAIN_DB_STEP, -36..+6). */
export const GAIN_DB_STEP = 3;
export const GAIN_DB_MIN = -36;
export const GAIN_DB_MAX = 6;
/** 3 dB-multiple inside the ladder's span; 0 is a multiple of the step. */
export function gainDbOk(value: number): boolean {
return Number.isInteger(value / GAIN_DB_STEP) && value >= GAIN_DB_MIN && value <= GAIN_DB_MAX;
}
/** Plan §1 caps, enforced leniently here (the api enforces them strictly). */
export const MAX_ITEMS = 50;
export const MAX_OVERLAYS = 16;
export const MAX_TOTAL_MS = 15 * 60_000;
export const MAX_CARD_TEXT = 120;
export const CARD_DURATION_MS = { min: 1_000, max: 10_000 } as const;
export const OVERLAY_WINDOW_MS = { min: 1_000, max: 10_000 } as const;
export const MAX_OUTPUT_LABEL = 80;
export interface EdlClip {
kind: "clip";
assetId: string;
trimStartMs: number;
trimEndMs: number;
rotate: EdlRotate;
volumeDb: number;
}
export interface EdlCard {
kind: "card";
text: string;
durationMs: number;
style: EdlCardStyle;
}
export type EdlItem = EdlClip | EdlCard;
export interface EdlOverlay {
overlayType: EdlOverlayType;
fields: Record<string, string>;
startMs: number;
endMs: number;
}
export interface EdlVoiceover {
audioAssetId: string;
gainDb: number;
duckOriginal: boolean;
offsetMs: number;
}
export interface EdlProject {
version: 1;
aspect: EdlAspect;
fit: EdlFit;
fps: 30;
items: EdlItem[];
overlays: EdlOverlay[];
voiceover: EdlVoiceover | null;
outputLabel: string;
}
/** The `video_processing_jobs.input_json` envelope of an assemble row (043). */
export interface AssembleInput {
edlProjectId: string;
project: EdlProject;
lane: "local" | "remote" | null;
requestedBy: string | null;
}
export class EdlParseError extends Error {
constructor(message: string) { super(message); this.name = "EdlParseError"; }
}
function fail(message: string): never { throw new EdlParseError(message); }
const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/;
function record(value: unknown, field: string, allowed: readonly string[]): Record<string, unknown> {
if (typeof value !== "object" || value === null || Array.isArray(value)) fail(`${field} must be an object`);
for (const key of Object.keys(value)) if (!allowed.includes(key)) fail(`${field}.${key} is not allowed`);
return value as Record<string, unknown>;
}
function stringField(value: unknown, field: string, max: number): string {
if (typeof value !== "string") fail(`${field} must be a string`);
if (value.length > max) fail(`${field} must be at most ${max} characters`);
return value;
}
/** Integer ms >= 0 (the plan's "times derived, no floats" rule). */
function msField(value: unknown, field: string): number {
if (typeof value !== "number" || !Number.isInteger(value) || value < 0) fail(`${field} must be integer milliseconds >= 0`);
return value;
}
function intInSet(value: unknown, field: string, set: readonly number[]): number {
if (typeof value !== "number" || !Number.isInteger(value) || !set.includes(value)) fail(`${field} must be one of ${set.join("|")}`);
return value;
}
function enumIn<T extends string>(value: unknown, field: string, set: readonly T[]): T {
if (typeof value !== "string" || !set.includes(value as T)) fail(`${field} must be one of ${set.join("|")}`);
return value as T;
}
function uuidField(value: unknown, field: string): string {
if (typeof value !== "string" || !UUID.test(value)) fail(`${field} must be a UUID`);
return value;
}
function gainDb(value: unknown, field: string): number {
const n = typeof value === "number" && Number.isFinite(value) ? Math.round(value * 100) / 100 : NaN;
if (!Number.isFinite(n) || !gainDbOk(n)) fail(`${field} must be a multiple of ${GAIN_DB_STEP} dB between ${GAIN_DB_MIN} and ${GAIN_DB_MAX}`);
return n;
}
/**
* Card/overlay text: the api's overlay-text character class (render.ts's
* `text()`): letters, numbers, punctuation, spaces, marks, symbols — no control
* characters, no filtergraph metacharacters. The card never reaches a filter
* (it becomes a PNG), so this is a belt-and-braces re-check, not the injection
* defense; the defense is that text only ever becomes pixels.
*/
function overlayTextField(value: unknown, field: string, max: number): string {
const text = stringField(value, field, max);
if (!/^[\p{L}\p{N}\p{P}\p{Zs}\p{M}\p{S}]*$/u.test(text)) fail(`${field} must not contain control characters`);
return text;
}
function item(value: unknown, index: number): EdlItem {
const field = `project.items[${index}]`;
if (typeof value !== "object" || value === null || Array.isArray(value)) fail(`${field} must be an object`);
const kind = (value as Record<string, unknown>).kind;
if (kind === "clip") {
const c = record(value, field, ["kind", "assetId", "trimStartMs", "trimEndMs", "rotate", "volumeDb"]);
return {
kind: "clip",
assetId: uuidField(c.assetId, `${field}.assetId`),
trimStartMs: msField(c.trimStartMs, `${field}.trimStartMs`),
trimEndMs: msField(c.trimEndMs, `${field}.trimEndMs`),
rotate: intInSet(c.rotate, `${field}.rotate`, ROTATIONS) as EdlRotate,
volumeDb: gainDb(c.volumeDb, `${field}.volumeDb`),
};
}
if (kind === "card") {
const c = record(value, field, ["kind", "text", "durationMs", "style"]);
const durationMs = msField(c.durationMs, `${field}.durationMs`);
if (durationMs < CARD_DURATION_MS.min || durationMs > CARD_DURATION_MS.max) {
fail(`${field}.durationMs must be between ${CARD_DURATION_MS.min} and ${CARD_DURATION_MS.max}`);
}
return {
kind: "card",
text: overlayTextField(c.text, `${field}.text`, MAX_CARD_TEXT),
durationMs,
style: enumIn(c.style, `${field}.style`, CARD_STYLES),
};
}
fail(`${field}.kind must be 'clip' or 'card'`);
}
function overlay(value: unknown, index: number): EdlOverlay {
const field = `project.overlays[${index}]`;
const o = record(value, field, ["overlayType", "fields", "startMs", "endMs"]);
const overlayType = enumIn(o.overlayType, `${field}.overlayType`, OVERLAY_TYPES);
const fieldsRaw = o.fields === undefined || o.fields === null ? {} : o.fields;
if (typeof fieldsRaw !== "object" || Array.isArray(fieldsRaw)) fail(`${field}.fields must be an object`);
const fields: Record<string, string> = {};
for (const [key, entry] of Object.entries(fieldsRaw as Record<string, unknown>)) {
if (key.length > 64) fail(`${field}.fields has an over-long key`);
fields[key] = overlayTextField(entry, `${field}.fields.${key}`, 200);
}
const startMs = msField(o.startMs, `${field}.startMs`);
const endMs = msField(o.endMs, `${field}.endMs`);
if (endMs - startMs < OVERLAY_WINDOW_MS.min || endMs - startMs > OVERLAY_WINDOW_MS.max) {
fail(`${field} window must be between ${OVERLAY_WINDOW_MS.min} and ${OVERLAY_WINDOW_MS.max} ms`);
}
if (startMs < 0) fail(`${field}.startMs must be >= 0`);
if (overlayType !== "logo" && fields.title === undefined) fail(`${field}.fields.title is required for ${overlayType}`);
return { overlayType, fields, startMs, endMs };
}
/** Total timeline length in ms (clips by trim span, cards by their duration). */
export function timelineDurationMs(project: EdlProject): number {
return project.items.reduce((total, entry) =>
total + (entry.kind === "clip" ? entry.trimEndMs - entry.trimStartMs : entry.durationMs), 0);
}
/** Parse `input_json` of an assemble row: { edlProjectId, project, lane?, requestedBy? }. */
export function parseAssembleInput(value: unknown): AssembleInput {
const env = record(value, "input_json", ["edlProjectId", "project", "lane", "requestedBy"]);
const edlProjectId = uuidField(env.edlProjectId, "input_json.edlProjectId");
const lane = env.lane === undefined || env.lane === null ? null
: enumIn(env.lane, "input_json.lane", ["local", "remote"] as const);
const requestedBy = env.requestedBy === undefined || env.requestedBy === null ? null
: uuidField(env.requestedBy, "input_json.requestedBy");
const p = record(env.project, "project", ["version", "aspect", "fit", "fps", "items", "overlays", "voiceover", "outputLabel"]);
if (p.version !== 1) fail("project.version must be 1 (this build renders v1 timelines only)");
const aspect = enumIn(p.aspect, "project.aspect", Object.keys(EDL_ASPECTS) as EdlAspect[]);
const fit = enumIn(p.fit, "project.fit", EDL_FIT);
const fps = intInSet(p.fps, "project.fps", [30]);
const itemsRaw = p.items === undefined || p.items === null ? [] : p.items;
if (!Array.isArray(itemsRaw)) fail("project.items must be an array");
if (itemsRaw.length === 0) fail("project.items must not be empty");
if (itemsRaw.length > MAX_ITEMS) fail(`project.items allows at most ${MAX_ITEMS} entries`);
const items = itemsRaw.map(item);
const overlaysRaw = p.overlays === undefined || p.overlays === null ? [] : p.overlays;
if (!Array.isArray(overlaysRaw)) fail("project.overlays must be an array");
if (overlaysRaw.length > MAX_OVERLAYS) fail(`project.overlays allows at most ${MAX_OVERLAYS} entries`);
const overlays = overlaysRaw.map(overlay);
let voiceover: EdlVoiceover | null = null;
if (p.voiceover !== undefined && p.voiceover !== null) {
const v = record(p.voiceover, "project.voiceover", ["audioAssetId", "gainDb", "duckOriginal", "offsetMs"]);
voiceover = {
audioAssetId: uuidField(v.audioAssetId, "project.voiceover.audioAssetId"),
gainDb: gainDb(v.gainDb, "project.voiceover.gainDb"),
duckOriginal: v.duckOriginal === undefined ? false : typeof v.duckOriginal === "boolean" ? v.duckOriginal : fail("project.voiceover.duckOriginal must be a boolean"),
offsetMs: msField(v.offsetMs, "project.voiceover.offsetMs"),
};
}
const outputLabel = p.outputLabel === undefined || p.outputLabel === null ? ""
: stringField(p.outputLabel, "project.outputLabel", MAX_OUTPUT_LABEL);
const project: EdlProject = { version: 1, aspect, fit, fps: 30, items, overlays, voiceover, outputLabel };
const total = timelineDurationMs(project);
if (total <= 0) fail("project timeline must be longer than 0 ms");
if (total > MAX_TOTAL_MS) fail(`project timeline must be at most ${MAX_TOTAL_MS} ms`);
// An overlay that starts after the timeline can never show; the api rejects it, we fail it.
for (const o of overlays) if (o.startMs >= total) fail("project.overlays start beyond the timeline");
return { edlProjectId, project, lane, requestedBy };
}
/**
* The job's scratch identity: the same project renders into the same sha-named
* dir, so a re-render of an unchanged project reuses fetched assets (the plan's
* idempotency rule). Input ids never reach a path — only the hash does.
*/
export function assembleScratchName(input: AssembleInput): string {
const canonical = JSON.stringify({ edlProjectId: input.edlProjectId, project: input.project });
return createHash("sha256").update(canonical).digest("hex");
}