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
599 lines
20 KiB
TypeScript
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,
|
|
};
|
|
}
|