feat: editor-v2 S1 — EDL schema, strict validation, /video/edl API, edl_projects store

apps/api/src/media-control/edl.ts: the EdlProject schema (closed enums,
integer ms, 3dB steps, <=50 items, <=15 min, filtergraph-metacharacter
blocklist on card text) + validateEdlProject — the mirror-assert owner for
the worker's edl-plan port. 043_edl_projects: RLS tenant table. EdlController:
POST /video/edl, PATCH autosave, GET (project + latest assemble job),
POST :id/render (queues kind 'assemble' with the validated project).
Tests 130 passing across media-control.
This commit is contained in:
glass beadian 2026-10-16 21:22:00 +00:00
parent 10dfda3b7c
commit d8de9f5d82
6 changed files with 643 additions and 1 deletions

View file

@ -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;

View file

@ -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 {}

View file

@ -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<EdlProjectRecord> {
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<EdlProjectRecord> {
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<EdlProjectView> {
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;
}

View file

@ -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 = <T>(workspaceId: string, fn: (client: PoolClient) => Promise<T>) => Promise<T>;
type AuditAppender = (workspaceId: string, event: AuditEvent) => Promise<string>;
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<EdlServiceDependencies>) {
this.dependencies = { ...DEFAULT_DEPENDENCIES, ...dependencies };
}
async create(workspaceId: string, project: EdlProject, actorUserId: string): Promise<EdlProjectRecord> {
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<EdlProjectRecord> {
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<EdlProjectView> {
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<string, unknown>): Promise<string> {
return this.dependencies.appendAudit(workspaceId, {
actorUserId: diff.actorUserId === null ? null : String(diff.actorUserId),
actorRole: "member",
action,
targetType: "edl_project",
targetId,
diff,
});
}
}

View file

@ -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<string, unknown> = {}): Record<string, unknown> {
return { kind: "clip", assetId: ASSET_A, trimStartMs: 0, trimEndMs: 10_000, rotate: 0, volumeDb: 0, ...overrides };
}
function card(overrides: Record<string, unknown> = {}): Record<string, unknown> {
return { kind: "card", text: "Chapter one", durationMs: 5000, style: "title", ...overrides };
}
function overlayInput(overrides: Record<string, unknown> = {}): Record<string, unknown> {
return { overlayType: "lower_third", fields: { name: "A. Reporter" }, startMs: 0, endMs: 5000, ...overrides };
}
function voiceoverInput(overrides: Record<string, unknown> = {}): Record<string, unknown> {
return { audioAssetId: AUDIO, gainDb: -12, duckOriginal: true, offsetMs: 500, ...overrides };
}
function project(overrides: Record<string, unknown> = {}): Record<string, unknown> {
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<string, unknown>;
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/);
}
});
});

View file

@ -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<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 }
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<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>;
}
/** 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<string, unknown>).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<string, string> = {};
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 };
}