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:
parent
10dfda3b7c
commit
d8de9f5d82
6 changed files with 643 additions and 1 deletions
56
apps/api/migrations/043_edl_projects.sql
Normal file
56
apps/api/migrations/043_edl_projects.sql
Normal 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;
|
||||
|
|
@ -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 {}
|
||||
|
|
|
|||
70
apps/api/src/media-control/edl.controller.ts
Normal file
70
apps/api/src/media-control/edl.controller.ts
Normal 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;
|
||||
}
|
||||
174
apps/api/src/media-control/edl.service.ts
Normal file
174
apps/api/src/media-control/edl.service.ts
Normal 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,
|
||||
});
|
||||
}
|
||||
}
|
||||
172
apps/api/src/media-control/edl.test.ts
Normal file
172
apps/api/src/media-control/edl.test.ts
Normal 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/);
|
||||
}
|
||||
});
|
||||
});
|
||||
167
apps/api/src/media-control/edl.ts
Normal file
167
apps/api/src/media-control/edl.ts
Normal 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 };
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue