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
491 lines
17 KiB
TypeScript
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,
|
|
};
|
|
}
|