import type { BrandKitRecord, FfprobeReport, GateReport, MediaEditPlan, MediaProject, MediaProjectGoal, MediaProjectStatus, MediaRender, MediaRenderOutput, MediaRenderQuality, MediaRenderStatus, } from "@rakazo/contracts"; import { BrandKitSchema, EdlSchema, MAX_AUTO_FIX_ROUNDS } from "@rakazo/contracts"; import type { MediaProject as MediaProjectRow, MediaRender as MediaRenderRow, PrismaClient, } from "./client.js"; import { Prisma } from "./client.js"; import type { MediaDb, MediaPage, MediaPageInput } from "./media-shared.js"; import { decodeKeysetCursor, InvalidMediaInputError, isUniqueViolation, MediaStateConflictError, mapBrandKit, mapMediaEditPlan, mapMediaProject, mapMediaRender, pageLimit, toPage, } from "./media-shared.js"; import { IsolationError } from "./scope.js"; export interface NewMediaProject { title: string; goal: MediaProjectGoal; profileIds: string[]; collectionIds?: string[]; assetIds?: string[]; brandKitId?: string | null; } export interface MediaProjectPatch { title?: string; goal?: MediaProjectGoal; collectionIds?: string[]; assetIds?: string[]; profileIds?: string[]; brandKitId?: string | null; status?: MediaProjectStatus; } export interface MediaProjectRecord extends MediaProject { assetIds: string[]; createdBy: string; } export interface NewMediaEditPlan { projectId: string; profileId: string; /** Validated against EdlSchema here; semantic validation happens before (validateEdl). */ edl: unknown; createdBy: "agent" | "user"; rationale: string | null; } /** Fields a render worker reports; outputs may carry internal paths next to the public fields. */ export interface MediaRenderPatch { status?: MediaRenderStatus; progress?: number; outputs?: Array>; gateReport?: GateReport | null; ffprobe?: FfprobeReport | null; autoFixRound?: number; error?: string | null; } /** Statuses a render can still be changed or canceled from. */ export const ACTIVE_RENDER_STATUSES: MediaRenderStatus[] = ["queued", "rendering", "gating"]; const PLAN_VERSION_ATTEMPTS = 50; function projectRecord(row: MediaProjectRow): MediaProjectRecord { return { ...mapMediaProject(row), assetIds: row.assetIds, createdBy: row.createdBy }; } /** Brand kits, projects, versioned edit plans and renders. */ export function createMediaProjectRepos(prisma: PrismaClient) { async function assertOwned( db: MediaDb, spaceId: string, model: "mediaBrandKit" | "mediaCollection" | "mediaAsset", ids: string[], ): Promise { const unique = [...new Set(ids)]; if (unique.length === 0) return; const where = { spaceId, id: { in: unique } }; const owned = model === "mediaBrandKit" ? await db.mediaBrandKit.count({ where }) : model === "mediaCollection" ? await db.mediaCollection.count({ where }) : await db.mediaAsset.count({ where }); if (owned !== unique.length) throw new IsolationError(); } // ------------------------------------------------------------------------- // Brand kits // ------------------------------------------------------------------------- async function upsertBrandKit( spaceId: string, input: { id?: string; kit: unknown }, ): Promise { const parsed = BrandKitSchema.safeParse(input.kit); if (!parsed.success) throw new InvalidMediaInputError("Invalid brand kit"); const kit = parsed.data as Prisma.InputJsonObject; if (!input.id) { return mapBrandKit(await prisma.mediaBrandKit.create({ data: { spaceId, kit } })); } const result = await prisma.mediaBrandKit.updateMany({ where: { id: input.id, spaceId }, data: { kit }, }); if (result.count !== 1) throw new IsolationError(); return getBrandKit(spaceId, input.id); } async function listBrandKits(spaceId: string): Promise { const rows = await prisma.mediaBrandKit.findMany({ where: { spaceId }, orderBy: [{ updatedAt: "desc" }, { id: "desc" }], }); return rows.map(mapBrandKit); } async function getBrandKit(spaceId: string, id: string): Promise { const row = await prisma.mediaBrandKit.findFirst({ where: { id, spaceId } }); if (!row) throw new IsolationError(); return mapBrandKit(row); } async function removeBrandKit(spaceId: string, id: string): Promise { const result = await prisma.mediaBrandKit.deleteMany({ where: { id, spaceId } }); if (result.count !== 1) throw new IsolationError(); } // ------------------------------------------------------------------------- // Projects and plans // ------------------------------------------------------------------------- async function requireProject(db: MediaDb, spaceId: string, projectId: string) { const row = await db.mediaProject.findFirst({ where: { id: projectId, spaceId } }); if (!row) throw new IsolationError(); return row; } async function createProject( spaceId: string, createdBy: string, input: NewMediaProject, ): Promise { return prisma.$transaction(async (tx) => { await assertOwned(tx, spaceId, "mediaBrandKit", input.brandKitId ? [input.brandKitId] : []); await assertOwned(tx, spaceId, "mediaCollection", input.collectionIds ?? []); await assertOwned(tx, spaceId, "mediaAsset", input.assetIds ?? []); const row = await tx.mediaProject.create({ data: { spaceId, createdBy, title: input.title, goal: input.goal, profileIds: [...new Set(input.profileIds)], collectionIds: [...new Set(input.collectionIds ?? [])], assetIds: [...new Set(input.assetIds ?? [])], brandKitId: input.brandKitId ?? null, }, }); return mapMediaProject(row); }); } async function updateProject( spaceId: string, projectId: string, patch: MediaProjectPatch, ): Promise { return prisma.$transaction(async (tx) => { await assertOwned(tx, spaceId, "mediaBrandKit", patch.brandKitId ? [patch.brandKitId] : []); await assertOwned(tx, spaceId, "mediaCollection", patch.collectionIds ?? []); await assertOwned(tx, spaceId, "mediaAsset", patch.assetIds ?? []); const result = await tx.mediaProject.updateMany({ where: { id: projectId, spaceId }, data: { ...(patch.title !== undefined ? { title: patch.title } : {}), ...(patch.goal !== undefined ? { goal: patch.goal } : {}), ...(patch.status !== undefined ? { status: patch.status } : {}), ...(patch.brandKitId !== undefined ? { brandKitId: patch.brandKitId } : {}), ...(patch.profileIds !== undefined ? { profileIds: [...new Set(patch.profileIds)] } : {}), ...(patch.collectionIds !== undefined ? { collectionIds: [...new Set(patch.collectionIds)] } : {}), ...(patch.assetIds !== undefined ? { assetIds: [...new Set(patch.assetIds)] } : {}), }, }); if (result.count !== 1) throw new IsolationError(); return mapMediaProject(await requireProject(tx, spaceId, projectId)); }); } /** Most recently changed first. */ async function listProjects( spaceId: string, input: MediaPageInput = {}, ): Promise> { const limit = pageLimit(input.limit); const cursor = input.cursor ? decodeKeysetCursor(input.cursor) : null; if (cursor && !cursor.at) throw new InvalidMediaInputError("Invalid cursor"); const rows = await prisma.mediaProject.findMany({ where: { spaceId, ...(cursor?.at ? { OR: [ { updatedAt: { lt: cursor.at } }, { updatedAt: cursor.at, id: { lt: cursor.id } }, ], } : {}), }, orderBy: [{ updatedAt: "desc" }, { id: "desc" }], take: limit + 1, }); return toPage(rows, limit, (row) => ({ at: row.updatedAt, id: row.id }), mapMediaProject); } async function getProjectRecord(spaceId: string, projectId: string): Promise { return projectRecord(await requireProject(prisma, spaceId, projectId)); } /** Project with the latest plan per profile and its renders, newest first. */ async function getProject(spaceId: string, projectId: string) { const row = await requireProject(prisma, spaceId, projectId); const [plans, renders] = await Promise.all([ latestPlans(spaceId, projectId), listRenders(spaceId, projectId), ]); return { project: mapMediaProject(row), plans, renders }; } async function removeProject(spaceId: string, projectId: string): Promise { const result = await prisma.mediaProject.deleteMany({ where: { id: projectId, spaceId } }); if (result.count !== 1) throw new IsolationError(); } /** * Store the next plan version for (project, profile). Concurrent writers race on the * unique (projectId, profileId, version); the loser re-reads and takes the next number. */ async function createPlanVersion( spaceId: string, input: NewMediaEditPlan, ): Promise { const parsed = EdlSchema.safeParse(input.edl); if (!parsed.success) throw new InvalidMediaInputError("Invalid edit decision list"); if (parsed.data.output.profileId !== input.profileId) { throw new InvalidMediaInputError("Plan profile does not match its EDL"); } await requireProject(prisma, spaceId, input.projectId); for (let attempt = 1; ; attempt += 1) { const latest = await prisma.mediaEditPlan.findFirst({ where: { projectId: input.projectId, profileId: input.profileId }, orderBy: { version: "desc" }, select: { version: true }, }); try { const row = await prisma.mediaEditPlan.create({ data: { spaceId, projectId: input.projectId, profileId: input.profileId, version: (latest?.version ?? 0) + 1, edl: parsed.data as Prisma.InputJsonObject, createdBy: input.createdBy, rationale: input.rationale, }, }); return mapMediaEditPlan(row); } catch (error) { if (!isUniqueViolation(error) || attempt >= PLAN_VERSION_ATTEMPTS) throw error; } } } async function getPlan(spaceId: string, planId: string): Promise { const row = await prisma.mediaEditPlan.findFirst({ where: { id: planId, spaceId } }); if (!row) throw new IsolationError(); return mapMediaEditPlan(row); } async function latestPlan( spaceId: string, projectId: string, profileId: string, ): Promise { await requireProject(prisma, spaceId, projectId); const row = await prisma.mediaEditPlan.findFirst({ where: { spaceId, projectId, profileId }, orderBy: { version: "desc" }, }); return row ? mapMediaEditPlan(row) : null; } /** The newest plan of every profile in the project, ordered by profile id. */ async function latestPlans(spaceId: string, projectId: string): Promise { const rows = await prisma.mediaEditPlan.findMany({ where: { spaceId, projectId }, orderBy: [{ profileId: "asc" }, { version: "desc" }], distinct: ["profileId"], }); return rows.map(mapMediaEditPlan); } async function listPlanVersions( spaceId: string, projectId: string, profileId: string, ): Promise { await requireProject(prisma, spaceId, projectId); const rows = await prisma.mediaEditPlan.findMany({ where: { spaceId, projectId, profileId }, orderBy: { version: "desc" }, }); return rows.map(mapMediaEditPlan); } // ------------------------------------------------------------------------- // Renders // ------------------------------------------------------------------------- async function requireRender(db: MediaDb, spaceId: string, renderId: string) { const row = await db.mediaRender.findFirst({ where: { id: renderId, spaceId } }); if (!row) throw new IsolationError(); return row; } async function createRender( spaceId: string, input: { planId: string; quality: MediaRenderQuality; createdBy: string }, ): Promise { const plan = await prisma.mediaEditPlan.findFirst({ where: { id: input.planId, spaceId }, select: { id: true, projectId: true, profileId: true }, }); if (!plan) throw new IsolationError(); const row = await prisma.mediaRender.create({ data: { spaceId, projectId: plan.projectId, planId: plan.id, profileId: plan.profileId, quality: input.quality, createdBy: input.createdBy, }, }); return mapMediaRender(row); } async function getRender(spaceId: string, renderId: string): Promise { return mapMediaRender(await requireRender(prisma, spaceId, renderId)); } /** Full row, including outputs with internal paths. */ async function getRenderRecord(spaceId: string, renderId: string): Promise { return requireRender(prisma, spaceId, renderId); } async function listRenders(spaceId: string, projectId: string): Promise { const rows = await prisma.mediaRender.findMany({ where: { spaceId, projectId }, orderBy: [{ createdAt: "desc" }, { id: "desc" }], }); return rows.map(mapMediaRender); } /** * Take a queued render for processing. The conditional update makes exactly one * concurrent caller win; the others get null. */ async function claimRender(spaceId: string, renderId: string): Promise { const result = await prisma.mediaRender.updateMany({ where: { id: renderId, spaceId, status: "queued" }, data: { status: "rendering", progress: 0, error: null }, }); const row = await requireRender(prisma, spaceId, renderId); return result.count === 1 ? mapMediaRender(row) : null; } /** * Apply worker progress or results while the render is in one of `from` (default: * still active). Returns null when the render already left those states, e.g. after * a cancel, so a late worker never overwrites it. */ async function updateRender( spaceId: string, renderId: string, patch: MediaRenderPatch, from: MediaRenderStatus[] = ACTIVE_RENDER_STATUSES, ): Promise { if ( patch.autoFixRound !== undefined && (!Number.isInteger(patch.autoFixRound) || patch.autoFixRound < 0 || patch.autoFixRound > MAX_AUTO_FIX_ROUNDS) ) { throw new InvalidMediaInputError("Auto-fix round out of range"); } const result = await prisma.mediaRender.updateMany({ where: { id: renderId, spaceId, status: { in: from } }, data: { ...(patch.status !== undefined ? { status: patch.status } : {}), ...(patch.progress !== undefined ? { progress: Math.min(1, Math.max(0, patch.progress)) } : {}), ...(patch.outputs !== undefined ? { outputs: patch.outputs as unknown as Prisma.InputJsonArray } : {}), ...(patch.gateReport !== undefined ? { gateReport: patch.gateReport === null ? Prisma.DbNull : (patch.gateReport as unknown as Prisma.InputJsonObject), } : {}), ...(patch.ffprobe !== undefined ? { ffprobe: patch.ffprobe === null ? Prisma.DbNull : (patch.ffprobe as unknown as Prisma.InputJsonObject), } : {}), ...(patch.autoFixRound !== undefined ? { autoFixRound: patch.autoFixRound } : {}), ...(patch.error !== undefined ? { error: patch.error } : {}), }, }); const row = await requireRender(prisma, spaceId, renderId); return result.count === 1 ? mapMediaRender(row) : null; } async function cancelRender(spaceId: string, renderId: string): Promise { const result = await prisma.mediaRender.updateMany({ where: { id: renderId, spaceId, status: { in: ACTIVE_RENDER_STATUSES } }, data: { status: "canceled" }, }); const row = await requireRender(prisma, spaceId, renderId); if (result.count !== 1) { throw new MediaStateConflictError(`Render is already ${row.status}`); } return mapMediaRender(row); } return { upsertBrandKit, listBrandKits, getBrandKit, removeBrandKit, createProject, updateProject, listProjects, getProject, getProjectRecord, removeProject, createPlanVersion, getPlan, latestPlan, latestPlans, listPlanVersions, createRender, getRender, getRenderRecord, listRenders, claimRender, updateRender, cancelRender, }; }