evOo-local/packages/db/src/media-people.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

599 lines
20 KiB
TypeScript

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<MediaPerson> {
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<MediaPerson[]> {
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<MediaPerson> {
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<Array<{ personId: string; centroid: Uint8Array }>> {
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<MediaPerson[]> {
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<MediaPerson> {
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<MediaPerson> {
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<MediaFaceTrack> {
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<MediaPerson> {
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<RemovedPerson> {
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<MediaFaceTrack[]> {
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<MediaFaceTrack[]> {
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<MediaFaceTrackRecord[]> {
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<MediaFaceEmbedding[]> {
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<void> {
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<MediaMarkRow> {
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<MediaMark> {
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<MediaMark[]> {
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<MediaMarkRecord[]> {
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<MediaMarkRecord> {
return markRecord(await requireMark(spaceId, markId));
}
async function updateMark(
spaceId: string,
markId: string,
patch: MediaMarkPatch,
): Promise<MediaMark> {
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<MediaMarkRecord> {
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,
};
}