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 { ChunkedUploadService } from "./video/chunked-upload.js";
|
||||||
import { LiveMonitorModule } from "./live-monitor/live-monitor.module";
|
import { LiveMonitorModule } from "./live-monitor/live-monitor.module";
|
||||||
import { MediaControlModule } from "./media-control/media-control.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 { ProductionModule } from "./production/production.module";
|
||||||
import { RealtimeModule } from "./realtime/realtime.module";
|
import { RealtimeModule } from "./realtime/realtime.module";
|
||||||
import { LiveChatModule } from "./live-chat/live-chat.module";
|
import { LiveChatModule } from "./live-chat/live-chat.module";
|
||||||
|
|
@ -59,6 +61,7 @@ import { MediaModule } from "./media/media.module.js";
|
||||||
VideoController,
|
VideoController,
|
||||||
GroupsController,
|
GroupsController,
|
||||||
AnalyticsController,
|
AnalyticsController,
|
||||||
|
EdlController,
|
||||||
SettingsController,
|
SettingsController,
|
||||||
BrandingController,
|
BrandingController,
|
||||||
PublicBrandingController,
|
PublicBrandingController,
|
||||||
|
|
@ -67,6 +70,6 @@ import { MediaModule } from "./media/media.module.js";
|
||||||
{ provide: PublishingExecutionRepository, useFactory: () => new PublishingExecutionRepository(pool) },
|
{ provide: PublishingExecutionRepository, useFactory: () => new PublishingExecutionRepository(pool) },
|
||||||
TemporalWorkflowStarter,
|
TemporalWorkflowStarter,
|
||||||
{ provide: PUBLISHING_DEPENDENCIES, inject: [PublishingExecutionRepository, TemporalWorkflowStarter, ApprovalGateService], useFactory: (repository: PublishingExecutionRepository, workflowStarter: TemporalWorkflowStarter, approvalGate: ApprovalGateService) => ({ repository, workflowStarter, approvalGate, tenantTransaction: withTenant, assertReady: () => assertPublishingSchema(pool) }) },
|
{ 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 {}
|
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