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,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;
}