evOo-local/packages/db/src/media-projects.ts
Claude c1c39b6e46
feat(media): space-scoped Media Studio repositories
Sources, assets with keyset search, analyses, segments, uploads,
collections with re-clustering, people with consent and complete
biometric removal, marks, brand kits, projects with versioned plans,
renders with atomic claims, publications, metrics and the funnel.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0138YqDj3ttfayRwuMKKrqVj
2026-10-03 17:55:49 +00:00

491 lines
17 KiB
TypeScript

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<MediaRenderOutput & Record<string, unknown>>;
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<void> {
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<BrandKitRecord> {
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<BrandKitRecord[]> {
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<BrandKitRecord> {
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<void> {
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<MediaProject> {
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<MediaProject> {
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<MediaPage<MediaProject>> {
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<MediaProjectRecord> {
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<void> {
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<MediaEditPlan> {
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<MediaEditPlan> {
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<MediaEditPlan | null> {
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<MediaEditPlan[]> {
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<MediaEditPlan[]> {
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<MediaRender> {
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<MediaRender> {
return mapMediaRender(await requireRender(prisma, spaceId, renderId));
}
/** Full row, including outputs with internal paths. */
async function getRenderRecord(spaceId: string, renderId: string): Promise<MediaRenderRow> {
return requireRender(prisma, spaceId, renderId);
}
async function listRenders(spaceId: string, projectId: string): Promise<MediaRender[]> {
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<MediaRender | null> {
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<MediaRender | null> {
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<MediaRender> {
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,
};
}