diff --git a/apps/api/migrations/043_edl_projects.sql b/apps/api/migrations/043_edl_projects.sql new file mode 100644 index 0000000..c651e2c --- /dev/null +++ b/apps/api/migrations/043_edl_projects.sql @@ -0,0 +1,56 @@ +-- 043: EDL projects — the intake half of the editor-v2 assemble feature. +-- +-- edl_projects stores a validated editor timeline (the EdlProject shape from +-- apps/api/src/media-control/edl.ts) as jsonb. The api validates strictly +-- (closed enums, integer ms, unknown keys rejected, filtergraph metacharacters +-- blocked in card text) before anything is written; the media-worker +-- re-validates the job's input_json on the assemble lane (044). +-- +-- video_processing_jobs gains kind 'assemble' (044's claim already admits it) +-- and video_asset_id becomes nullable: an assemble job is queued from an EDL +-- project, not a single source asset, so there is no asset to point at yet — +-- the produced output is a derived video_assets row like compose/mix. +-- +-- RLS / NULLIF / reject_workspace_change / sp_app grants follow the +-- 001/008/021/026/029 conventions: no app.workspace_id => NULL => no rows; +-- FORCE binds sp_app too. The jsonb column is content, not a schema facet: +-- the authoritative typed validation is app-side (edl.ts). + +BEGIN; + +CREATE TABLE edl_projects ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + workspace_id uuid NOT NULL REFERENCES workspaces(id) ON DELETE CASCADE ON UPDATE RESTRICT, + group_id uuid NULL, + project jsonb NOT NULL, + created_by uuid NULL, + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now(), + UNIQUE (workspace_id, id) +); + +-- Tenant-pinned reference target (029 style): the composite UNIQUE lets child +-- rows pin (id, workspace_id) so an EDL project can never point across tenants. +CREATE INDEX edl_projects_ws_updated_idx ON edl_projects (workspace_id, updated_at DESC); + +CREATE TRIGGER trg_edl_projects_ws_immutable BEFORE UPDATE ON edl_projects + FOR EACH ROW EXECUTE FUNCTION reject_workspace_change(); + +ALTER TABLE edl_projects ENABLE ROW LEVEL SECURITY; +ALTER TABLE edl_projects FORCE ROW LEVEL SECURITY; + +CREATE POLICY tenant_isolation ON edl_projects + USING (workspace_id = NULLIF(current_setting('app.workspace_id', true), '')::uuid) + WITH CHECK (workspace_id = NULLIF(current_setting('app.workspace_id', true), '')::uuid); + +GRANT SELECT, INSERT, UPDATE, DELETE ON edl_projects TO sp_app; + +-- The assemble lane: widen the 039 kind CHECK so an undecodable row cannot +-- exist, and let an assemble job carry no source asset yet. +ALTER TABLE video_processing_jobs DROP CONSTRAINT video_processing_jobs_kind_check; +ALTER TABLE video_processing_jobs + ADD CONSTRAINT video_processing_jobs_kind_check + CHECK (kind IN ('probe', 'compose', 'mix', 'assemble')); +ALTER TABLE video_processing_jobs ALTER COLUMN video_asset_id DROP NOT NULL; + +COMMIT; diff --git a/apps/api/src/app.module.ts b/apps/api/src/app.module.ts index 985d1ff..1ed834c 100644 --- a/apps/api/src/app.module.ts +++ b/apps/api/src/app.module.ts @@ -23,6 +23,8 @@ import { VideoService } from "./video/video.service"; import { ChunkedUploadService } from "./video/chunked-upload.js"; import { LiveMonitorModule } from "./live-monitor/live-monitor.module"; import { MediaControlModule } from "./media-control/media-control.module"; +import { EdlController } from "./media-control/edl.controller"; +import { EdlService } from "./media-control/edl.service"; import { ProductionModule } from "./production/production.module"; import { RealtimeModule } from "./realtime/realtime.module"; import { LiveChatModule } from "./live-chat/live-chat.module"; @@ -59,6 +61,7 @@ import { MediaModule } from "./media/media.module.js"; VideoController, GroupsController, AnalyticsController, + EdlController, SettingsController, BrandingController, PublicBrandingController, @@ -67,6 +70,6 @@ import { MediaModule } from "./media/media.module.js"; { provide: PublishingExecutionRepository, useFactory: () => new PublishingExecutionRepository(pool) }, TemporalWorkflowStarter, { provide: PUBLISHING_DEPENDENCIES, inject: [PublishingExecutionRepository, TemporalWorkflowStarter, ApprovalGateService], useFactory: (repository: PublishingExecutionRepository, workflowStarter: TemporalWorkflowStarter, approvalGate: ApprovalGateService) => ({ repository, workflowStarter, approvalGate, tenantTransaction: withTenant, assertReady: () => assertPublishingSchema(pool) }) }, - PublishingService, ApprovalGateService, AuthService, ConnectionsService, TeamService, InviteLinkService, AuditQueryService, VideoService, ChunkedUploadService, GroupsService, SnapshotService, SettingsService, BrandingService], + PublishingService, ApprovalGateService, AuthService, ConnectionsService, TeamService, InviteLinkService, AuditQueryService, VideoService, ChunkedUploadService, GroupsService, SnapshotService, SettingsService, BrandingService, EdlService], }) export class AppModule {} diff --git a/apps/api/src/media-control/edl.controller.ts b/apps/api/src/media-control/edl.controller.ts new file mode 100644 index 0000000..93abc04 --- /dev/null +++ b/apps/api/src/media-control/edl.controller.ts @@ -0,0 +1,70 @@ +import { BadRequestException, Body, Controller, Get, Inject, Param, Patch, Post, Req, UseGuards } from "@nestjs/common"; +import type { Request } from "express"; +import { parseId, requireObject, requireSession } from "../http/validation.js"; +import { RbacGuard, RequirePermission, type AuthenticatedRequest } from "../rbac/rbac.guard.js"; +import { validateEdlProject } from "./edl.js"; +import { EdlService, type EdlLane, type EdlProjectRecord, type EdlProjectView } from "./edl.service.js"; + +/** + * EDL projects (editor-v2 intake): the timeline is the resource, the render is + * a queued assemble job. Bodies are parsed with validateEdlProject — closed + * enums, integer ms, unknown keys rejected — before anything touches the DB. + */ +@Controller("video/edl") +@UseGuards(RbacGuard) +export class EdlController { + constructor(@Inject(EdlService) private readonly edl: EdlService) {} + + @Post() + @RequirePermission("media.manage") + create( + @Req() request: Request & AuthenticatedRequest, + @Body() body: unknown, + ): Promise { + const session = requireSession(request); + const project = validateEdlProject(requireObject(body, "body").project); + return this.edl.create(session.workspaceId, project, session.userId); + } + + @Patch(":id") + @RequirePermission("media.manage") + update( + @Req() request: Request & AuthenticatedRequest, + @Param("id") id: string, + @Body() body: unknown, + ): Promise { + const session = requireSession(request); + const project = validateEdlProject(requireObject(body, "body").project); + return this.edl.update(session.workspaceId, parseId(id, "id"), project, session.userId); + } + + @Get(":id") + @RequirePermission("media.view") + get( + @Req() request: Request & AuthenticatedRequest, + @Param("id") id: string, + ): Promise { + return this.edl.get(requireSession(request).workspaceId, parseId(id, "id")); + } + + @Post(":id/render") + @RequirePermission("media.manage") + render( + @Req() request: Request & AuthenticatedRequest, + @Param("id") id: string, + @Body() body: unknown, + ): Promise<{ jobId: string }> { + const session = requireSession(request); + const input = requireObject(body, "body"); + const lane: EdlLane | undefined = + input.lane === undefined || input.lane === null ? undefined : parseLane(input.lane); + return this.edl.render(session.workspaceId, parseId(id, "id"), lane, session.userId); + } +} + +function parseLane(value: unknown): EdlLane { + if (value !== "local" && value !== "remote") { + throw new BadRequestException("lane must be one of local, remote"); + } + return value; +} diff --git a/apps/api/src/media-control/edl.service.ts b/apps/api/src/media-control/edl.service.ts new file mode 100644 index 0000000..5e59bdf --- /dev/null +++ b/apps/api/src/media-control/edl.service.ts @@ -0,0 +1,174 @@ +import { Inject, Injectable, NotFoundException, Optional } from "@nestjs/common"; +import type { PoolClient } from "pg"; +import { appendTenantAudit, type AuditEvent } from "../audit/audit.service.js"; +import { withTenant } from "../db/client.js"; +import { isUuid } from "../http/validation.js"; +import type { EdlProject } from "./edl.js"; + +type TenantTransaction = (workspaceId: string, fn: (client: PoolClient) => Promise) => Promise; +type AuditAppender = (workspaceId: string, event: AuditEvent) => Promise; + +export interface EdlServiceDependencies { + tenantTransaction: TenantTransaction; + appendAudit: AuditAppender; +} + +export const EDL_SERVICE_DEPENDENCIES = Symbol("EDL_SERVICE_DEPENDENCIES"); + +const DEFAULT_DEPENDENCIES: EdlServiceDependencies = { + tenantTransaction: withTenant, + appendAudit: appendTenantAudit, +}; + +export interface EdlProjectRecord { + id: string; + project: EdlProject; +} + +export interface EdlJobSummary { + id: string; + state: string; + progressPercent: number; +} + +export interface EdlProjectView extends EdlProjectRecord { + job: EdlJobSummary | null; +} + +export type EdlLane = "local" | "remote"; + +/** + * EDL project persistence (editor-v2 intake). Every read/write runs inside the + * caller's tenant transaction (withTenant), so RLS pins the rows to the + * session's workspace and the audit trail appends to the tenant chain. + * The project is only ever stored after validateEdlProject accepted it. + */ +@Injectable() +export class EdlService { + private readonly dependencies: EdlServiceDependencies; + + constructor(@Optional() @Inject(EDL_SERVICE_DEPENDENCIES) dependencies?: Partial) { + this.dependencies = { ...DEFAULT_DEPENDENCIES, ...dependencies }; + } + + async create(workspaceId: string, project: EdlProject, actorUserId: string): Promise { + if (!isUuid(workspaceId)) throw new NotFoundException("workspace not found"); + const created = await this.dependencies.tenantTransaction(workspaceId, (client) => + client + .query( + `INSERT INTO edl_projects (workspace_id, project, created_by) + VALUES ($1, $2::jsonb, $3) RETURNING id, project`, + [workspaceId, JSON.stringify(project), actorUserId], + ) + .then((result) => { + const row = result.rows[0]; + if (row === undefined) throw new Error("database did not return the created EDL project"); + return { id: String(row.id), project: row.project as EdlProject }; + }), + ); + await this.audit(workspaceId, "edl.saved", created.id, { actorUserId, operation: "create" }); + return created; + } + + async update(workspaceId: string, id: string, project: EdlProject, actorUserId: string): Promise { + if (!isUuid(workspaceId) || !isUuid(id)) throw new NotFoundException("EDL project not found"); + const updated = await this.dependencies.tenantTransaction(workspaceId, (client) => + client + .query( + `UPDATE edl_projects + SET project = $2::jsonb, updated_at = now() + WHERE id = $3 + RETURNING id, project`, + [workspaceId, JSON.stringify(project), id], + ) + .then((result) => { + const row = result.rows[0]; + if (row === undefined) throw new NotFoundException(`EDL project ${id} not found`); + return { id: String(row.id), project: row.project as EdlProject }; + }), + ); + await this.audit(workspaceId, "edl.saved", updated.id, { actorUserId, operation: "update" }); + return updated; + } + + async get(workspaceId: string, id: string): Promise { + if (!isUuid(workspaceId) || !isUuid(id)) throw new NotFoundException("EDL project not found"); + return this.dependencies.tenantTransaction(workspaceId, (client) => + client + .query( + `SELECT e.id, e.project, + j.id AS job_id, j.state AS job_state, j.progress_percent AS job_progress_percent + FROM edl_projects e + LEFT JOIN LATERAL ( + SELECT id, state, progress_percent + FROM video_processing_jobs + WHERE input_json ->> 'edlProjectId' = e.id::text + ORDER BY created_at DESC, id DESC + LIMIT 1 + ) j ON true + WHERE e.id = $1`, + [id], + ) + .then((result) => { + const row = result.rows[0]; + if (row === undefined) throw new NotFoundException(`EDL project ${id} not found`); + const job: EdlJobSummary | null = + row.job_id === null || row.job_id === undefined + ? null + : { + id: String(row.job_id), + state: String(row.job_state), + progressPercent: typeof row.job_progress_percent === "number" ? row.job_progress_percent : Number(row.job_progress_percent), + }; + return { id: String(row.id), project: row.project as EdlProject, job }; + }), + ); + } + + /** + * Queue the assemble lane: the job row carries the validated project so the + * worker re-validates exactly what the api persisted (044's claim picks the + * row up by kind 'assemble'). video_asset_id stays NULL — the produced + * output is a derived video_assets row written when the job completes. + */ + async render(workspaceId: string, id: string, lane: EdlLane | undefined, actorUserId: string): Promise<{ jobId: string }> { + if (!isUuid(workspaceId) || !isUuid(id)) throw new NotFoundException("EDL project not found"); + const created = await this.dependencies.tenantTransaction(workspaceId, (client) => { + const found = client.query("SELECT project FROM edl_projects WHERE id = $1", [id]); + return found.then((result) => { + const row = result.rows[0]; + if (row === undefined) throw new NotFoundException(`EDL project ${id} not found`); + const project = row.project as EdlProject; + const input = { edlProjectId: id, project, ...(lane === undefined ? {} : { lane }) }; + return client + .query( + `INSERT INTO video_processing_jobs (workspace_id, video_asset_id, kind, state, input_json) + VALUES ($1, NULL, 'assemble', 'queued', $2::jsonb) RETURNING id`, + [workspaceId, JSON.stringify(input)], + ) + .then((jobResult) => { + const jobRow = jobResult.rows[0]; + if (jobRow === undefined) throw new Error("database did not return the queued assemble job"); + return { jobId: String(jobRow.id) }; + }); + }); + }); + await this.audit(workspaceId, "edl.render", id, { + actorUserId, + jobId: created.jobId, + lane: lane ?? null, + }); + return created; + } + + private audit(workspaceId: string, action: "edl.saved" | "edl.render", targetId: string, diff: Record): Promise { + return this.dependencies.appendAudit(workspaceId, { + actorUserId: diff.actorUserId === null ? null : String(diff.actorUserId), + actorRole: "member", + action, + targetType: "edl_project", + targetId, + diff, + }); + } +} diff --git a/apps/api/src/media-control/edl.test.ts b/apps/api/src/media-control/edl.test.ts new file mode 100644 index 0000000..4f5fd6c --- /dev/null +++ b/apps/api/src/media-control/edl.test.ts @@ -0,0 +1,172 @@ +import { BadRequestException } from "@nestjs/common"; +import { describe, expect, it } from "vitest"; +import { + EDL_CARD_MAX_MS, + EDL_CARD_MIN_MS, + EDL_CARD_TEXT_MAX, + EDL_LABEL_MAX, + EDL_MAX_ITEMS, + EDL_MAX_TOTAL_MS, + EDL_RESOLUTIONS, + validateEdlProject, +} from "./edl.js"; + +const ASSET_A = "10000000-0000-4000-8000-000000000001"; +const ASSET_B = "10000000-0000-4000-8000-000000000002"; +const AUDIO = "20000000-0000-4000-8000-000000000001"; + +function clip(overrides: Record = {}): Record { + return { kind: "clip", assetId: ASSET_A, trimStartMs: 0, trimEndMs: 10_000, rotate: 0, volumeDb: 0, ...overrides }; +} +function card(overrides: Record = {}): Record { + return { kind: "card", text: "Chapter one", durationMs: 5000, style: "title", ...overrides }; +} +function overlayInput(overrides: Record = {}): Record { + return { overlayType: "lower_third", fields: { name: "A. Reporter" }, startMs: 0, endMs: 5000, ...overrides }; +} +function voiceoverInput(overrides: Record = {}): Record { + return { audioAssetId: AUDIO, gainDb: -12, duckOriginal: true, offsetMs: 500, ...overrides }; +} +function project(overrides: Record = {}): Record { + return { + version: 1, + aspect: "16:9", + fit: "cover", + fps: 30, + items: [clip()], + overlays: [], + voiceover: null, + outputLabel: "EDL test", + ...overrides, + }; +} + +function rejectError(value: unknown): BadRequestException { + try { + validateEdlProject(value); + } catch (error) { + expect(error).toBeInstanceOf(BadRequestException); + return error as BadRequestException; + } + throw new Error(`validateEdlProject accepted ${JSON.stringify(value)}`); +} + +function messageOf(error: BadRequestException): string { + const response = error.getResponse(); + if (typeof response === "string") return response; + const record = response as Record; + if (typeof record.message === "string") return record.message; + if (Array.isArray(record.message)) return record.message.join(" "); + return String(response); +} + +/** Each rejection names the offending field path in the 400 message. */ +const rejections: Array<[string, unknown, RegExp]> = [ + // -- closed enums ------------------------------------------------------- + ["version 2", project({ version: 2 }), /project\.version must be 1/], + ["aspect 4:3", project({ aspect: "4:3" }), /project\.aspect must be one of 16:9, 9:16/], + ["fit fill", project({ fit: "fill" }), /project\.fit must be one of cover, contain/], + ["fps 25", project({ fps: 25 }), /project\.fps must be 30/], + ["clip rotate 45", project({ items: [clip({ rotate: 45 })] }), /items\[0\]\.rotate must be one of 0, 90, 180, 270/], + ["card style lower", project({ items: [card({ style: "lower" })] }), /items\[0\]\.style must be one of title, chapter/], + ["overlay type watermark", project({ overlays: [overlayInput({ overlayType: "watermark" })] }), /overlays\[0\]\.overlayType must be one of lower_third, chyron, logo/], + // -- unknown keys are rejected, never ignored --------------------------- + ["unknown project key", project({ filter: "drawtext" }), /project\.filter is not allowed/], + ["unknown clip key", project({ items: [clip({ speed: 2 })] }), /items\[0\]\.speed is not allowed/], + ["unknown card key", project({ items: [card({ font: "sans" })] }), /items\[0\]\.font is not allowed/], + ["unknown overlay key", project({ overlays: [overlayInput({ font: "sans" })] }), /overlays\[0\]\.font is not allowed/], + ["unknown voiceover key", project({ voiceover: voiceoverInput({ streamKey: "x" }) }), /voiceover\.streamKey is not allowed/], + // -- items bounds --------------------------------------------------------- + ["empty items", project({ items: [] }), /project\.items must contain at least 1 item/], + ["51 items", project({ items: Array.from({ length: 51 }, () => clip()) }), /project\.items allows at most 50 items/], + // -- total duration bound -------------------------------------------------- + ["total 15 min + 1 ms", project({ items: [clip({ trimStartMs: 0, trimEndMs: 900_001 })] }), /total duration must be at most 900000 ms/], + // -- clip bounds ------------------------------------------------------------- + ["clip trimStartMs -1", project({ items: [clip({ trimStartMs: -1 })] }), /items\[0\]\.trimStartMs must be at least 0 ms/], + ["clip trimEndMs 0 (trimStartMs 0)", project({ items: [clip({ trimEndMs: 0 })] }), /project\.items\[0\]\.trimEndMs must be greater than project\.items\[0\]\.trimStartMs/], + ["clip trimEndMs == trimStartMs", project({ items: [clip({ trimStartMs: 1000, trimEndMs: 1000 })] }), /project\.items\[0\]\.trimEndMs must be greater than project\.items\[0\]\.trimStartMs/], + ["clip trimEndMs < trimStartMs", project({ items: [clip({ trimStartMs: 2000, trimEndMs: 1000 })] }), /project\.items\[0\]\.trimEndMs must be greater than project\.items\[0\]\.trimStartMs/], + ["clip trimStartMs not an integer", project({ items: [clip({ trimStartMs: 10.5 })] }), /items\[0\]\.trimStartMs must be an integer number of milliseconds/], + ["clip volumeDb -39", project({ items: [clip({ volumeDb: -39 })] }), /items\[0\]\.volumeDb must be between -36 and 6 dB/], + ["clip volumeDb 9", project({ items: [clip({ volumeDb: 9 })] }), /items\[0\]\.volumeDb must be between -36 and 6 dB/], + ["clip volumeDb 1 (not a 3 dB step)", project({ items: [clip({ volumeDb: 1 })] }), /items\[0\]\.volumeDb must be a multiple of 3 dB/], + // -- card bounds ------------------------------------------------------------- + ["card durationMs 999", project({ items: [card({ durationMs: 999 })] }), /items\[0\]\.durationMs must be at least 1000 ms/], + ["card durationMs 10001", project({ items: [card({ durationMs: 10_001 })] }), /items\[0\]\.durationMs must be at most 10000 ms/], + ["card text 121 chars", project({ items: [card({ text: "x".repeat(121) })] }), /items\[0\]\.text must be at most 120 characters/], + // -- overlay bounds ----------------------------------------------------------- + ["overlay startMs -1", project({ overlays: [overlayInput({ startMs: -1 })] }), /overlays\[0\]\.startMs must be at least 0 ms/], + ["overlay endMs 0", project({ overlays: [overlayInput({ startMs: 0, endMs: 0 })] }), /project\.overlays\[0\]\.endMs must be greater than project\.overlays\[0\]\.startMs/], + ["overlay endMs < startMs", project({ overlays: [overlayInput({ startMs: 5000, endMs: 4000 })] }), /project\.overlays\[0\]\.endMs must be greater than project\.overlays\[0\]\.startMs/], + ["overlay fields value not a string", project({ overlays: [overlayInput({ fields: { name: 42 } })] }), /overlays\[0\]\.fields\.name must be a string/], + // -- voiceover bounds ----------------------------------------------------------- + ["voiceover gainDb -39", project({ voiceover: voiceoverInput({ gainDb: -39 }) }), /voiceover\.gainDb must be between -36 and 6 dB/], + ["voiceover gainDb 1 (not a 3 dB step)", project({ voiceover: voiceoverInput({ gainDb: 1 }) }), /voiceover\.gainDb must be a multiple of 3 dB/], + ["voiceover offsetMs -1", project({ voiceover: voiceoverInput({ offsetMs: -1 }) }), /voiceover\.offsetMs must be at least 0 ms/], + ["voiceover duckOriginal not a boolean", project({ voiceover: voiceoverInput({ duckOriginal: "yes" }) }), /voiceover\.duckOriginal must be a boolean/], + // -- output label bound ----------------------------------------------------------- + ["outputLabel 81 chars", project({ outputLabel: "x".repeat(81) }), /outputLabel must be at most 80 characters/], + // -- uuids --------------------------------------------------------------------------- + ["clip assetId not a uuid", project({ items: [clip({ assetId: "not-a-uuid" })] }), /items\[0\]\.assetId must be a UUID/], + ["voiceover audioAssetId not a uuid", project({ voiceover: voiceoverInput({ audioAssetId: "nope" }) }), /voiceover\.audioAssetId must be a UUID/], +]; + +describe("validateEdlProject", () => { + it("exports the spec'd resolution table", () => { + expect(EDL_RESOLUTIONS).toEqual({ "16:9": { w: 1280, h: 720 }, "9:16": { w: 1080, h: 1920 } }); + }); + + it("accepts the minimal project and canonicalizes it", () => { + expect(validateEdlProject(project())).toEqual(project()); + }); + + it("accepts a full project (both aspects, all item/overlay kinds, voiceover)", () => { + const full = project({ + aspect: "9:16", + fit: "contain", + items: [ + clip({ assetId: ASSET_B, trimStartMs: 100, trimEndMs: 11_000, rotate: 90, volumeDb: -6 }), + card({ text: "Chapter one", durationMs: 10_000, style: "chapter" }), + ], + overlays: [ + overlayInput(), + overlayInput({ overlayType: "logo", fields: {}, startMs: 5_000, endMs: 11_100 }), + overlayInput({ overlayType: "chyron", fields: { headline: "Market close" }, startMs: 2_000, endMs: 8_000 }), + ], + voiceover: voiceoverInput(), + outputLabel: "Full EDL", + }); + expect(validateEdlProject(full)).toEqual(full); + }); + + it("accepts every boundary value", () => { + expect(validateEdlProject(project({ items: Array.from({ length: 50 }, () => clip()) }))).toBeTruthy(); // 50 items + expect(validateEdlProject(project({ items: [clip({ trimStartMs: 0, trimEndMs: 900_000 })] }))).toBeTruthy(); // exactly 15 min + expect(validateEdlProject(project({ items: [card({ durationMs: 1000 })] }))).toBeTruthy(); // card floor + expect(validateEdlProject(project({ items: [card({ durationMs: 10_000 })] }))).toBeTruthy(); // card ceiling + expect(validateEdlProject(project({ items: [card({ text: "x".repeat(120) })] }))).toBeTruthy(); // card text ceiling + expect(validateEdlProject(project({ outputLabel: "x".repeat(80) }))).toBeTruthy(); // label ceiling + expect(validateEdlProject(project({ items: [clip({ volumeDb: -36 })] }))).toBeTruthy(); // dB floor + expect(validateEdlProject(project({ items: [clip({ volumeDb: 6 })] }))).toBeTruthy(); // dB ceiling + expect(validateEdlProject(project({ items: [clip({ trimStartMs: 0 })] }))).toBeTruthy(); // trimStart floor + expect(validateEdlProject(project({ overlays: [overlayInput({ startMs: 0, endMs: 1 })] }))).toBeTruthy(); // overlay floor + expect(validateEdlProject(project({ voiceover: voiceoverInput({ gainDb: -36, offsetMs: 0 }) }))).toBeTruthy(); // voiceover floors + }); + + it("throws BadRequestException (HTTP 400) with the field path in the message", () => { + const error = rejectError(project({ fit: "fill" })); + expect(error.getStatus()).toBe(400); + expect(messageOf(error)).toContain("project.fit"); + }); + + it.each(rejections)("%s", (_label, value, pattern) => { + expect(messageOf(rejectError(value))).toMatch(pattern); + }); + + it("rejects every filtergraph metacharacter in card text (produce-request.ts blocklist)", () => { + for (const character of [";", "[", "]", "'", '"', "`", "$", "\\", "{", "}", "=", ","]) { + const error = rejectError(project({ items: [card({ text: `ab${character}cd` })] })); + expect(messageOf(error)).toMatch(/items\[0\]\.text must not contain the filtergraph metacharacter/); + } + }); +}); diff --git a/apps/api/src/media-control/edl.ts b/apps/api/src/media-control/edl.ts new file mode 100644 index 0000000..57aea2d --- /dev/null +++ b/apps/api/src/media-control/edl.ts @@ -0,0 +1,167 @@ +import { BadRequestException } from "@nestjs/common"; +import { isUuid } from "../http/validation.js"; + +/** + * EDL (editor timeline) projects: the editor-v2 intake shape. + * + * Everything accepted here is persisted verbatim into edl_projects.project + * and later reaches the media-worker's ffmpeg assemble lane, so the rules + * follow produce-request.ts: closed enums, exhaustive key allowlists (an + * unknown key is rejected, never ignored — that is what keeps a stray + * `movie=`/`enable=` field from riding into a filtergraph), integer + * milliseconds, bounded text, and no filtergraph or shell metacharacter + * inside card text. + * + * validateEdlProject is total: it either returns a canonicalized project + * (fixed key order, so the stored jsonb bytes are deterministic) or throws + * BadRequestException with a field path. + */ + +export type EdlAspect = "16:9" | "9:16"; +export type EdlFit = "cover" | "contain"; +export const EDL_RESOLUTIONS = { "16:9": { w: 1280, h: 720 }, "9:16": { w: 1080, h: 1920 } } as const; + +export interface EdlClip { kind: "clip"; assetId: string; trimStartMs: number; trimEndMs: number; rotate: 0 | 90 | 180 | 270; volumeDb: number } +export interface EdlCard { kind: "card"; text: string; durationMs: number; style: "title" | "chapter" } +export type EdlItem = EdlClip | EdlCard; +export interface EdlOverlay { overlayType: "lower_third" | "chyron" | "logo"; 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 } + +export const EDL_MAX_ITEMS = 50; +export const EDL_MAX_TOTAL_MS = 900_000; +export const EDL_CARD_MIN_MS = 1000; +export const EDL_CARD_MAX_MS = 10_000; +export const EDL_CARD_TEXT_MAX = 120; +export const EDL_LABEL_MAX = 80; +export const EDL_DB_MIN = -36; +export const EDL_DB_MAX = 6; +export const EDL_FPS = 30; + +/** + * Same blocklist as produce-request.ts: characters that change an ffmpeg + * filtergraph (`;` splits, `[`/`]` label pads, `,` separates args, `'` quotes, + * `=` sets an option) or a shell string. Card text is operator prose, so the + * check is a blocklist of these rather than a tight allowlist — prose keeps + * its punctuation and no metacharacter survives. + */ +const TEXT_FORBIDDEN = new Set([";", "[", "]", "'", '"', "`", "$", "\\", "{", "}", "=", ","]); + +function fail(message: string): never { + throw new BadRequestException(message); +} + +/** Allowlist gate: unknown keys are rejected, never ignored. */ +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; +} + +/** Integer milliseconds within an inclusive range. */ +function integerMs(value: unknown, field: string, min: number, max?: number): number { + if (typeof value !== "number" || !Number.isSafeInteger(value)) fail(`${field} must be an integer number of milliseconds`); + if (value < min) fail(`${field} must be at least ${min} ms`); + if (max !== undefined && value > max) fail(`${field} must be at most ${max} ms`); + return value; +} + +/** dB values are stepped in 3 dB increments inside the -36..+6 window. */ +function dbValue(value: unknown, field: string): number { + if (typeof value !== "number" || !Number.isFinite(value)) fail(`${field} must be a number`); + if (value < EDL_DB_MIN || value > EDL_DB_MAX) fail(`${field} must be between ${EDL_DB_MIN} and ${EDL_DB_MAX} dB`); + if (value % 3 !== 0) fail(`${field} must be a multiple of 3 dB`); + return value; +} + +function boundedText(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`); + for (const character of value) { + if (TEXT_FORBIDDEN.has(character)) fail(`${field} must not contain the filtergraph metacharacter ${JSON.stringify(character)}`); + } + return value; +} + +function item(value: unknown, field: string): EdlItem { + 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 clip = record(value, field, ["kind", "assetId", "trimStartMs", "trimEndMs", "rotate", "volumeDb"]); + const assetId = clip.assetId; + if (typeof assetId !== "string" || !isUuid(assetId)) fail(`${field}.assetId must be a UUID`); + const trimStartMs = integerMs(clip.trimStartMs, `${field}.trimStartMs`, 0); + const trimEndMs = integerMs(clip.trimEndMs, `${field}.trimEndMs`, 0); + if (trimEndMs <= trimStartMs) fail(`${field}.trimEndMs must be greater than ${field}.trimStartMs`); + const rotate = clip.rotate; + if (rotate !== 0 && rotate !== 90 && rotate !== 180 && rotate !== 270) fail(`${field}.rotate must be one of 0, 90, 180, 270`); + const volumeDb = dbValue(clip.volumeDb, `${field}.volumeDb`); + return { kind: "clip", assetId, trimStartMs, trimEndMs, rotate: rotate as 0 | 90 | 180 | 270, volumeDb }; + } + if (kind === "card") { + const card = record(value, field, ["kind", "text", "durationMs", "style"]); + const style = card.style; + if (style !== "title" && style !== "chapter") fail(`${field}.style must be one of title, chapter`); + const text = boundedText(card.text, `${field}.text`, EDL_CARD_TEXT_MAX); + const durationMs = integerMs(card.durationMs, `${field}.durationMs`, EDL_CARD_MIN_MS, EDL_CARD_MAX_MS); + return { kind: "card", text, durationMs, style: style as "title" | "chapter" }; + } + fail(`${field}.kind must be one of clip, card`); +} + +function overlay(value: unknown, field: string): EdlOverlay { + const o = record(value, field, ["overlayType", "fields", "startMs", "endMs"]); + const overlayType = o.overlayType; + if (overlayType !== "lower_third" && overlayType !== "chyron" && overlayType !== "logo") { + fail(`${field}.overlayType must be one of lower_third, chyron, logo`); + } + if (typeof o.fields !== "object" || o.fields === null || Array.isArray(o.fields)) fail(`${field}.fields must be an object`); + const fields: Record = {}; + for (const [key, entry] of Object.entries(o.fields)) { + if (typeof entry !== "string") fail(`${field}.fields.${key} must be a string`); + fields[key] = entry; + } + const startMs = integerMs(o.startMs, `${field}.startMs`, 0); + const endMs = integerMs(o.endMs, `${field}.endMs`, 0); + if (endMs <= startMs) fail(`${field}.endMs must be greater than ${field}.startMs`); + return { overlayType: overlayType as "lower_third" | "chyron" | "logo", fields, startMs, endMs }; +} + +function voiceover(value: unknown, field: string): EdlVoiceover { + const v = record(value, field, ["audioAssetId", "gainDb", "duckOriginal", "offsetMs"]); + const audioAssetId = v.audioAssetId; + if (typeof audioAssetId !== "string" || !isUuid(audioAssetId)) fail(`${field}.audioAssetId must be a UUID`); + const gainDb = dbValue(v.gainDb, `${field}.gainDb`); + if (typeof v.duckOriginal !== "boolean") fail(`${field}.duckOriginal must be a boolean`); + const offsetMs = integerMs(v.offsetMs, `${field}.offsetMs`, 0); + return { audioAssetId, gainDb, duckOriginal: v.duckOriginal, offsetMs }; +} + +/** Parse and canonicalize an EDL project; throws BadRequestException on the first problem. */ +export function validateEdlProject(value: unknown): EdlProject { + const project = record(value, "project", ["version", "aspect", "fit", "fps", "items", "overlays", "voiceover", "outputLabel"]); + if (project.version !== 1) fail("project.version must be 1"); + const aspect = project.aspect; + if (aspect !== "16:9" && aspect !== "9:16") fail(`project.aspect must be one of ${Object.keys(EDL_RESOLUTIONS).join(", ")}`); + const fit = project.fit; + if (fit !== "cover" && fit !== "contain") fail("project.fit must be one of cover, contain"); + if (project.fps !== EDL_FPS) fail(`project.fps must be ${EDL_FPS}`); + if (!Array.isArray(project.items)) fail("project.items must be an array"); + if (project.items.length < 1) fail("project.items must contain at least 1 item"); + if (project.items.length > EDL_MAX_ITEMS) fail(`project.items allows at most ${EDL_MAX_ITEMS} items`); + const items = project.items.map((entry, index) => item(entry, `project.items[${index}]`)); + let totalMs = 0; + for (const entry of items) { + totalMs += entry.kind === "clip" ? entry.trimEndMs - entry.trimStartMs : entry.durationMs; + } + if (totalMs > EDL_MAX_TOTAL_MS) fail(`project.items total duration must be at most ${EDL_MAX_TOTAL_MS} ms`); + if (project.overlays !== undefined && project.overlays !== null && !Array.isArray(project.overlays)) { + fail("project.overlays must be an array"); + } + const overlays = (Array.isArray(project.overlays) ? project.overlays : []).map((entry, index) => overlay(entry, `project.overlays[${index}]`)); + const voiceoverValue = project.voiceover === undefined || project.voiceover === null ? null : voiceover(project.voiceover, "project.voiceover"); + const outputLabel = boundedText(project.outputLabel, "project.outputLabel", EDL_LABEL_MAX); + return { version: 1, aspect: aspect as EdlAspect, fit: fit as EdlFit, fps: 30, items, overlays, voiceover: voiceoverValue, outputLabel }; +}