import type { AnonymizeMode, ConsentInput, MarkTreatment, MediaFaceTrack, MediaMark, MediaMarkKind, MediaMarkPrompt, MediaPerson, } from "@rakazo/contracts"; import type { MediaMark as MediaMarkRow, PrismaClient } from "./client.js"; import type { MediaDb } from "./media-shared.js"; import { assertAssetsInSpace, InvalidMediaInputError, lockMediaKey, mapMediaFaceTrack, mapMediaMark, mapMediaPerson, toBytes, } from "./media-shared.js"; import { IsolationError } from "./scope.js"; export interface MediaPersonSuggestion { /** Float32 mean identity embedding. Biometric: stays in this database. */ centroid: Uint8Array; trackIds: string[]; } export interface MediaPersonPatch { displayName?: string; isSelf?: boolean; confirmed?: boolean; anonymizeByDefault?: AnonymizeMode; syntheticSeed?: number | null; } /** * Who records consent. A user id is mandatory by type and checked against the space, * so a caller without an authenticated user (an agent tool) cannot produce one. */ export interface ConsentRecorder { spaceId: string; recordedByUserId: string; } export interface RemovedPerson { deletedFaceTracks: number; deletedMarks: number; /** Files the caller must delete too: box keyframes and mark masks. */ keyframesPaths: string[]; maskDirs: string[]; } export interface NewMediaFaceTrack { startMs: number; endMs: number; confidenceMin: number; keyframesPath: string; /** Float32 identity embedding. Biometric: stays in this database. */ embedding: Uint8Array | null; personId?: string | null; } export interface MediaFaceTrackRecord extends MediaFaceTrack { keyframesPath: string; modelVersion: string; } export interface MediaFaceEmbedding { trackId: string; assetId: string; personId: string | null; embedding: Uint8Array; } export interface NewMediaMark { kind: MediaMarkKind; label: string | null; personId: string | null; assetId: string; prompt: MediaMarkPrompt; treatment: MarkTreatment; } export interface MediaMarkPatch { treatment?: MarkTreatment; label?: string | null; personId?: string | null; } export interface MediaMarkResult { qualityScore: number | null; suggestedFallback: MarkTreatment | null; maskDir: string | null; } export interface MediaMarkRecord extends MediaMark { maskDir: string | null; } function markRecord(row: MediaMarkRow): MediaMarkRecord { return { ...mapMediaMark(row), maskDir: row.maskDir }; } /** People, face tracks and identifying marks. Biometric data stays in this database. */ export function createMediaPeopleRepos(prisma: PrismaClient) { // ------------------------------------------------------------------------- // People // ------------------------------------------------------------------------- const personInclude = { _count: { select: { faceTracks: true } }, faceTracks: { orderBy: [{ confidenceMin: "desc" as const }, { id: "asc" as const }], take: 1, select: { assetId: true }, }, }; async function personDto(db: MediaDb, spaceId: string, personId: string): Promise { const row = await db.mediaPerson.findFirst({ where: { id: personId, spaceId }, include: personInclude, }); if (!row) throw new IsolationError(); return mapMediaPerson(row, row._count.faceTracks, row.faceTracks[0]?.assetId ?? null); } async function listPeople(spaceId: string): Promise { const rows = await prisma.mediaPerson.findMany({ where: { spaceId }, orderBy: [{ isSelf: "desc" }, { createdAt: "asc" }, { id: "asc" }], include: personInclude, }); return rows.map((row) => mapMediaPerson(row, row._count.faceTracks, row.faceTracks[0]?.assetId ?? null), ); } async function getPerson(spaceId: string, personId: string): Promise { return personDto(prisma, spaceId, personId); } /** People with consent fields and seed, for the render gate. */ async function listPersonRecords(spaceId: string, personIds?: string[]) { return prisma.mediaPerson.findMany({ where: { spaceId, ...(personIds ? { id: { in: personIds } } : {}) }, omit: { centroid: true }, orderBy: [{ createdAt: "asc" }, { id: "asc" }], }); } /** Identity centroids for matching new face tracks to known people. Local only. */ async function personCentroids( spaceId: string, ): Promise> { const rows = await prisma.mediaPerson.findMany({ where: { spaceId, centroid: { not: null } }, select: { id: true, centroid: true }, orderBy: [{ createdAt: "asc" }, { id: "asc" }], }); return rows.map((row) => ({ personId: row.id, centroid: row.centroid as Uint8Array })); } /** * Turn face clusters into unconfirmed people ("Person N"). Only tracks that have no * person yet are assigned, so a user's earlier assignment is never overwritten. */ async function createPersonSuggestions( spaceId: string, suggestions: MediaPersonSuggestion[], ): Promise { if (suggestions.length === 0) return []; const ids = await prisma.$transaction(async (tx) => { await lockMediaKey(tx, `people:${spaceId}`); const trackIds = [...new Set(suggestions.flatMap((suggestion) => suggestion.trackIds))]; const owned = await tx.mediaFaceTrack.count({ where: { spaceId, id: { in: trackIds } } }); if (owned !== trackIds.length) throw new IsolationError(); let number = await tx.mediaPerson.count({ where: { spaceId } }); const created: string[] = []; for (const suggestion of suggestions) { number += 1; const person = await tx.mediaPerson.create({ data: { spaceId, displayName: `Person ${number}`, centroid: toBytes(suggestion.centroid), }, select: { id: true }, }); await tx.mediaFaceTrack.updateMany({ where: { spaceId, id: { in: suggestion.trackIds }, personId: null }, data: { personId: person.id }, }); created.push(person.id); } return created; }); return Promise.all(ids.map((id) => personDto(prisma, spaceId, id))); } async function updatePerson( spaceId: string, personId: string, patch: MediaPersonPatch, ): Promise { await prisma.$transaction(async (tx) => { const result = await tx.mediaPerson.updateMany({ where: { id: personId, spaceId }, data: { ...(patch.displayName !== undefined ? { displayName: patch.displayName } : {}), ...(patch.isSelf !== undefined ? { isSelf: patch.isSelf } : {}), ...(patch.confirmed !== undefined ? { confirmed: patch.confirmed } : {}), ...(patch.anonymizeByDefault !== undefined ? { anonymizeByDefault: patch.anonymizeByDefault } : {}), ...(patch.syntheticSeed !== undefined ? { syntheticSeed: patch.syntheticSeed } : {}), }, }); if (result.count !== 1) throw new IsolationError(); // One person per space is the user themself. if (patch.isSelf) { await tx.mediaPerson.updateMany({ where: { spaceId, isSelf: true, id: { not: personId } }, data: { isSelf: false }, }); } }); return personDto(prisma, spaceId, personId); } /** * Merge into the first person: tracks and marks move, the others are deleted along * with their centroids. The target keeps its own consent record. */ async function mergePeople(spaceId: string, personIds: string[]): Promise { const ids = [...new Set(personIds)]; if (ids.length < 2) throw new InvalidMediaInputError("Merge needs two people"); const [targetId, ...others] = ids as [string, ...string[]]; await prisma.$transaction(async (tx) => { const owned = await tx.mediaPerson.count({ where: { spaceId, id: { in: ids } } }); if (owned !== ids.length) throw new IsolationError(); await tx.mediaFaceTrack.updateMany({ where: { spaceId, personId: { in: others } }, data: { personId: targetId }, }); await tx.mediaMark.updateMany({ where: { spaceId, personId: { in: others } }, data: { personId: targetId }, }); await tx.mediaPerson.updateMany({ where: { spaceId, id: { in: others } }, data: { centroid: null }, }); await tx.mediaPerson.deleteMany({ where: { spaceId, id: { in: others } } }); await tx.mediaPerson.update({ where: { id: targetId }, data: { confirmed: true } }); }); return personDto(prisma, spaceId, targetId); } async function assignTrack( spaceId: string, trackId: string, personId: string | null, ): Promise { return prisma.$transaction(async (tx) => { if (personId) { const person = await tx.mediaPerson.findFirst({ where: { id: personId, spaceId }, select: { id: true }, }); if (!person) throw new IsolationError(); } const result = await tx.mediaFaceTrack.updateMany({ where: { id: trackId, spaceId }, data: { personId }, }); if (result.count !== 1) throw new IsolationError(); const row = await tx.mediaFaceTrack.findUniqueOrThrow({ where: { id: trackId } }); return mapMediaFaceTrack(row); }); } /** * Record consent and age verification for a person. Only a signed-in member of the * space can do this; the recorder is stored with the record. */ async function recordConsent( recorder: ConsentRecorder, input: ConsentInput, ): Promise { const { spaceId, recordedByUserId } = recorder; if (typeof recordedByUserId !== "string" || recordedByUserId.trim() === "") { throw new InvalidMediaInputError("Consent needs the recording user"); } if (input.ageVerificationMethod !== "none" && !input.ageVerifiedAt) { throw new InvalidMediaInputError("Age verification needs a date"); } const member = await prisma.spaceMember.findFirst({ where: { spaceId, userId: recordedByUserId }, select: { id: true }, }); if (!member) throw new IsolationError(); const result = await prisma.mediaPerson.updateMany({ where: { id: input.personId, spaceId }, data: { consentArtifactId: input.consentArtifactId, consentRecordedAt: new Date(input.consentRecordedAt), consentRecordedBy: recordedByUserId, ageVerificationMethod: input.ageVerificationMethod, ageVerifiedAt: input.ageVerificationMethod === "none" || !input.ageVerifiedAt ? null : new Date(input.ageVerifiedAt), }, }); if (result.count !== 1) throw new IsolationError(); return personDto(prisma, spaceId, input.personId); } /** * Delete a person and every biometric record tied to them: the centroid, the * embeddings of their face tracks, the tracks themselves, and person-bound marks. * Embeddings are blanked before the rows go so no copy survives in the same * transaction's row versions; the caller deletes the returned files. */ async function removePersonCompletely(spaceId: string, personId: string): Promise { return prisma.$transaction(async (tx) => { const person = await tx.mediaPerson.findFirst({ where: { id: personId, spaceId }, select: { id: true }, }); if (!person) throw new IsolationError(); const tracks = await tx.mediaFaceTrack.findMany({ where: { spaceId, personId }, select: { keyframesPath: true }, }); const marks = await tx.mediaMark.findMany({ where: { spaceId, personId }, select: { maskDir: true }, }); await tx.mediaFaceTrack.updateMany({ where: { spaceId, personId }, data: { embedding: null }, }); const deletedTracks = await tx.mediaFaceTrack.deleteMany({ where: { spaceId, personId } }); const deletedMarks = await tx.mediaMark.deleteMany({ where: { spaceId, personId } }); await tx.mediaPerson.update({ where: { id: personId }, data: { centroid: null } }); await tx.mediaPerson.delete({ where: { id: personId } }); return { deletedFaceTracks: deletedTracks.count, deletedMarks: deletedMarks.count, keyframesPaths: tracks.map((track) => track.keyframesPath), maskDirs: marks.flatMap((mark) => (mark.maskDir ? [mark.maskDir] : [])), }; }); } // ------------------------------------------------------------------------- // Face tracks // ------------------------------------------------------------------------- /** Replace the face tracks of one asset with a fresh tracking run. */ async function replaceFaceTracks( spaceId: string, assetId: string, modelVersion: string, tracks: NewMediaFaceTrack[], ): Promise { return prisma.$transaction(async (tx) => { const asset = await tx.mediaAsset.findFirst({ where: { id: assetId, spaceId }, select: { id: true }, }); if (!asset) throw new IsolationError(); const personIds = [ ...new Set(tracks.flatMap((track) => (track.personId ? [track.personId] : []))), ]; if (personIds.length > 0) { const owned = await tx.mediaPerson.count({ where: { spaceId, id: { in: personIds } } }); if (owned !== personIds.length) throw new IsolationError(); } await lockMediaKey(tx, `faces:${assetId}`); await tx.mediaFaceTrack.updateMany({ where: { spaceId, assetId }, data: { embedding: null }, }); await tx.mediaFaceTrack.deleteMany({ where: { spaceId, assetId } }); const rows = await tx.mediaFaceTrack.createManyAndReturn({ data: tracks.map((track) => ({ spaceId, assetId, modelVersion, startMs: track.startMs, endMs: track.endMs, confidenceMin: track.confidenceMin, keyframesPath: track.keyframesPath, embedding: track.embedding ? toBytes(track.embedding) : null, personId: track.personId ?? null, })), omit: { embedding: true }, }); return rows .sort((a, b) => a.startMs - b.startMs || a.id.localeCompare(b.id)) .map(mapMediaFaceTrack); }); } async function listFaceTracks( spaceId: string, filter: { assetId?: string; personId?: string } = {}, ): Promise { const rows = await prisma.mediaFaceTrack.findMany({ where: { spaceId, ...filter }, omit: { embedding: true }, orderBy: [{ assetId: "asc" }, { startMs: "asc" }, { id: "asc" }], }); return rows.map(mapMediaFaceTrack); } /** Tracks with their keyframe files, for anonymization and the gate. */ async function faceTrackRecords( spaceId: string, assetIds: string[], ): Promise { if (assetIds.length === 0) return []; const rows = await prisma.mediaFaceTrack.findMany({ where: { spaceId, assetId: { in: [...new Set(assetIds)] } }, omit: { embedding: true }, orderBy: [{ assetId: "asc" }, { startMs: "asc" }, { id: "asc" }], }); return rows.map((row) => ({ ...mapMediaFaceTrack(row), keyframesPath: row.keyframesPath, modelVersion: row.modelVersion, })); } /** Identity embeddings for local person clustering. Never leaves the host. */ async function faceTrackEmbeddings( spaceId: string, options: { unassignedOnly?: boolean } = {}, ): Promise { const rows = await prisma.mediaFaceTrack.findMany({ where: { spaceId, embedding: { not: null }, ...(options.unassignedOnly ? { personId: null } : {}), }, select: { id: true, assetId: true, personId: true, embedding: true }, orderBy: [{ assetId: "asc" }, { startMs: "asc" }, { id: "asc" }], }); return rows.map((row) => ({ trackId: row.id, assetId: row.assetId, personId: row.personId, embedding: row.embedding as Uint8Array, })); } // ------------------------------------------------------------------------- // Marks // ------------------------------------------------------------------------- async function assertPerson( db: MediaDb, spaceId: string, personId: string | null, ): Promise { if (!personId) return; const person = await db.mediaPerson.findFirst({ where: { id: personId, spaceId }, select: { id: true }, }); if (!person) throw new IsolationError(); } async function requireMark(spaceId: string, markId: string): Promise { const row = await prisma.mediaMark.findFirst({ where: { id: markId, spaceId } }); if (!row) throw new IsolationError(); return row; } async function createMark(spaceId: string, input: NewMediaMark): Promise { await assertAssetsInSpace(prisma, spaceId, [input.assetId]); await assertPerson(prisma, spaceId, input.personId); const row = await prisma.mediaMark.create({ data: { spaceId, kind: input.kind, label: input.label, personId: input.personId, assetId: input.assetId, prompt: input.prompt, treatment: input.treatment, }, }); return mapMediaMark(row); } async function listMarks( spaceId: string, filter: { personId?: string; assetId?: string } = {}, ): Promise { const rows = await prisma.mediaMark.findMany({ where: { spaceId, ...filter }, orderBy: [{ createdAt: "asc" }, { id: "asc" }], }); return rows.map(mapMediaMark); } /** * Marks that apply to an asset: those drawn on it plus the standing marks of every * person seen in it (person rules hold across all projects). */ async function marksForAsset(spaceId: string, assetId: string): Promise { const rows = await prisma.mediaMark.findMany({ where: { spaceId, OR: [{ assetId }, { person: { faceTracks: { some: { assetId, spaceId } } } }], }, orderBy: [{ createdAt: "asc" }, { id: "asc" }], }); return rows.map(markRecord); } async function getMark(spaceId: string, markId: string): Promise { return markRecord(await requireMark(spaceId, markId)); } async function updateMark( spaceId: string, markId: string, patch: MediaMarkPatch, ): Promise { if (patch.personId !== undefined) await assertPerson(prisma, spaceId, patch.personId); const result = await prisma.mediaMark.updateMany({ where: { id: markId, spaceId }, data: { ...(patch.treatment !== undefined ? { treatment: patch.treatment } : {}), ...(patch.label !== undefined ? { label: patch.label } : {}), ...(patch.personId !== undefined ? { personId: patch.personId } : {}), }, }); if (result.count !== 1) throw new IsolationError(); return mapMediaMark(await requireMark(spaceId, markId)); } /** Store the segmentation/treatment outcome: mask, quality, and any fallback proposal. */ async function recordMarkResult( spaceId: string, markId: string, outcome: MediaMarkResult, ): Promise { const result = await prisma.mediaMark.updateMany({ where: { id: markId, spaceId }, data: { qualityScore: outcome.qualityScore, suggestedFallback: outcome.suggestedFallback, maskDir: outcome.maskDir, }, }); if (result.count !== 1) throw new IsolationError(); return getMark(spaceId, markId); } async function removeMark(spaceId: string, markId: string): Promise<{ maskDir: string | null }> { const row = await requireMark(spaceId, markId); const result = await prisma.mediaMark.deleteMany({ where: { id: markId, spaceId } }); if (result.count !== 1) throw new IsolationError(); return { maskDir: row.maskDir }; } return { listPeople, getPerson, listPersonRecords, personCentroids, createPersonSuggestions, updatePerson, mergePeople, assignTrack, recordConsent, removePersonCompletely, replaceFaceTracks, listFaceTracks, faceTrackRecords, faceTrackEmbeddings, createMark, listMarks, marksForAsset, getMark, updateMark, recordMarkResult, removeMark, }; }