merge: W1-db media repositories

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0138YqDj3ttfayRwuMKKrqVj
This commit is contained in:
Claude 2026-10-03 17:56:32 +00:00
commit c877343218
No known key found for this signature in database
14 changed files with 5666 additions and 0 deletions

View File

@ -7,6 +7,7 @@ export * from "./events.js";
export * from "./external-conversations.js"; export * from "./external-conversations.js";
export * from "./groups.js"; export * from "./groups.js";
export * from "./main-agent.js"; export * from "./main-agent.js";
export * from "./media.js";
export * from "./memory-config.js"; export * from "./memory-config.js";
export * from "./messages.js"; export * from "./messages.js";
export * from "./messaging.js"; export * from "./messaging.js";

View File

@ -0,0 +1,395 @@
import type { MediaCollection, MediaCollectionKind, MediaLabelSource } from "@rakazo/contracts";
import { roundCoordinate } from "@rakazo/media";
import type { MediaCollection as MediaCollectionRow, PrismaClient } from "./client.js";
import { Prisma } from "./client.js";
import type { CollectionStats, MediaDb, MediaPage, MediaPageInput } from "./media-shared.js";
import {
assertAssetsInSpace,
decodeKeysetCursor,
EMPTY_COLLECTION_STATS,
InvalidMediaInputError,
keysetAfterSql,
lockMediaKey,
mapMediaAsset,
mapMediaCollection,
pageLimit,
toPage,
} from "./media-shared.js";
import { IsolationError } from "./scope.js";
/** One session or theme cluster computed by the worker. */
export interface MediaClusterInput {
/** Stable key so a re-run updates the same collection. */
key: string;
title: string;
labelSource: Exclude<MediaLabelSource, "user">;
startAt: Date | null;
endAt: Date | null;
centroid: { lat: number; lon: number } | null;
coverAssetId: string | null;
/** In display order. */
assetIds: string[];
}
export interface ReplaceClustersResult {
created: number;
updated: number;
deleted: number;
}
async function collectionStats(db: MediaDb, ids: string[]): Promise<Map<string, CollectionStats>> {
if (ids.length === 0) return new Map();
const rows = await db.$queryRaw<
Array<{ id: string; assetCount: number; videoCount: number; totalDurationMs: bigint }>
>(Prisma.sql`
SELECT ci."collectionId" AS id,
count(*)::int AS "assetCount",
(count(*) FILTER (WHERE a.kind = 'video'))::int AS "videoCount",
coalesce(sum(a."durationMs"), 0)::bigint AS "totalDurationMs"
FROM media_collection_items ci
JOIN media_assets a ON a.id = ci."assetId"
WHERE ci."collectionId" IN (${Prisma.join(ids)})
GROUP BY ci."collectionId"
`);
return new Map(
rows.map((row) => [
row.id,
{
assetCount: row.assetCount,
videoCount: row.videoCount,
totalDurationMs: Number(row.totalDurationMs),
},
]),
);
}
async function collectionDto(db: MediaDb, row: MediaCollectionRow): Promise<MediaCollection> {
const stats = await collectionStats(db, [row.id]);
return mapMediaCollection(row, stats.get(row.id) ?? EMPTY_COLLECTION_STATS);
}
/** Assets ordered by capture time, undated last, with the span they cover. */
async function orderedAssets(db: MediaDb, spaceId: string, assetIds: string[]) {
const assets = await db.mediaAsset.findMany({
where: { spaceId, id: { in: [...new Set(assetIds)] } },
orderBy: [{ capturedAt: { sort: "asc", nulls: "last" } }, { id: "asc" }],
select: { id: true, capturedAt: true },
});
const dated = assets.flatMap((asset) => (asset.capturedAt ? [asset.capturedAt] : []));
return {
ids: assets.map((asset) => asset.id),
startAt: dated[0] ?? null,
endAt: dated.at(-1) ?? null,
};
}
async function writeItems(db: MediaDb, collectionId: string, assetIds: string[]): Promise<void> {
await db.mediaCollectionItem.deleteMany({ where: { collectionId } });
if (assetIds.length === 0) return;
await db.mediaCollectionItem.createMany({
data: [...new Set(assetIds)].map((assetId, position) => ({ collectionId, assetId, position })),
});
}
/** Sessions, themes and manual collections. */
export function createMediaCollectionRepos(prisma: PrismaClient) {
// -------------------------------------------------------------------------
// Collections
// -------------------------------------------------------------------------
async function requireCollection(db: MediaDb, spaceId: string, collectionId: string) {
const row = await db.mediaCollection.findFirst({ where: { id: collectionId, spaceId } });
if (!row) throw new IsolationError();
return row;
}
/**
* Apply a full clustering run for one kind. Collections are matched by cluster key;
* a title the user set survives, stale rule/model clusters are removed, and manual
* collections are never touched.
*/
async function replaceClusters(
spaceId: string,
kind: Exclude<MediaCollectionKind, "manual">,
clusters: MediaClusterInput[],
): Promise<ReplaceClustersResult> {
const keys = new Set<string>();
for (const cluster of clusters) {
if (keys.has(cluster.key)) throw new InvalidMediaInputError("Duplicate cluster key");
keys.add(cluster.key);
if (cluster.coverAssetId && !cluster.assetIds.includes(cluster.coverAssetId)) {
throw new InvalidMediaInputError("Cover asset is not in its cluster");
}
}
return prisma.$transaction(
async (tx) => {
await lockMediaKey(tx, `clusters:${spaceId}:${kind}`);
await assertAssetsInSpace(
tx,
spaceId,
clusters.flatMap((cluster) => cluster.assetIds),
);
const existing = await tx.mediaCollection.findMany({
where: { spaceId, kind, clusterKey: { not: null } },
});
const byKey = new Map(existing.map((row) => [row.clusterKey as string, row]));
const result: ReplaceClustersResult = { created: 0, updated: 0, deleted: 0 };
for (const cluster of clusters) {
const fields = {
startAt: cluster.startAt,
endAt: cluster.endAt,
centroidLat: cluster.centroid ? roundCoordinate(cluster.centroid.lat) : null,
centroidLon: cluster.centroid ? roundCoordinate(cluster.centroid.lon) : null,
coverAssetId: cluster.coverAssetId,
};
const current = byKey.get(cluster.key);
let collectionId: string;
if (current) {
const keepTitle = current.labelSource === "user";
await tx.mediaCollection.update({
where: { id: current.id },
data: {
...fields,
...(keepTitle ? {} : { title: cluster.title, labelSource: cluster.labelSource }),
},
});
collectionId = current.id;
result.updated += 1;
} else {
const created = await tx.mediaCollection.create({
data: {
...fields,
spaceId,
kind,
clusterKey: cluster.key,
title: cluster.title,
labelSource: cluster.labelSource,
},
select: { id: true },
});
collectionId = created.id;
result.created += 1;
}
await writeItems(tx, collectionId, cluster.assetIds);
}
const stale = existing.filter(
(row) => !keys.has(row.clusterKey as string) && row.labelSource !== "user",
);
if (stale.length > 0) {
const deleted = await tx.mediaCollection.deleteMany({
where: { spaceId, id: { in: stale.map((row) => row.id) } },
});
result.deleted = deleted.count;
}
return result;
},
{ timeout: 120_000 },
);
}
async function listCollections(
spaceId: string,
input: MediaPageInput & { kind?: MediaCollectionKind },
): Promise<MediaPage<MediaCollection>> {
const limit = pageLimit(input.limit);
const cursor = input.cursor ? decodeKeysetCursor(input.cursor) : null;
const keys = await prisma.$queryRaw<Array<{ id: string }>>(Prisma.sql`
SELECT c.id FROM media_collections c
WHERE c."spaceId" = ${spaceId}
${input.kind ? Prisma.sql`AND c.kind = ${input.kind}` : Prisma.empty}
AND ${keysetAfterSql(cursor, Prisma.sql`c."startAt"`, Prisma.sql`c.id`)}
ORDER BY c."startAt" DESC NULLS LAST, c.id DESC
LIMIT ${limit + 1}
`);
const ids = keys.map((key) => key.id);
const [rows, stats] = await Promise.all([
prisma.mediaCollection.findMany({ where: { spaceId, id: { in: ids } } }),
collectionStats(prisma, ids),
]);
const byId = new Map(rows.map((row) => [row.id, row]));
const ordered = ids.flatMap((id) => {
const row = byId.get(id);
return row ? [row] : [];
});
return toPage(
ordered,
limit,
(row) => ({ at: row.startAt, id: row.id }),
(row) => mapMediaCollection(row, stats.get(row.id) ?? EMPTY_COLLECTION_STATS),
);
}
async function getCollection(spaceId: string, collectionId: string) {
const row = await requireCollection(prisma, spaceId, collectionId);
const items = await prisma.mediaCollectionItem.findMany({
where: { collectionId, asset: { spaceId } },
orderBy: [{ position: "asc" }, { assetId: "asc" }],
include: { asset: true },
});
return {
collection: await collectionDto(prisma, row),
assets: items.map((item) => mapMediaAsset(item.asset)),
};
}
/** Asset ids of a collection in display order. */
async function collectionAssetIds(spaceId: string, collectionIds: string[]): Promise<string[]> {
const ids = [...new Set(collectionIds)];
if (ids.length === 0) return [];
const owned = await prisma.mediaCollection.count({ where: { spaceId, id: { in: ids } } });
if (owned !== ids.length) throw new IsolationError();
const items = await prisma.mediaCollectionItem.findMany({
where: { collectionId: { in: ids } },
orderBy: [{ position: "asc" }, { assetId: "asc" }],
select: { assetId: true, collectionId: true },
});
const order = new Map(ids.map((id, index) => [id, index]));
items.sort((a, b) => (order.get(a.collectionId) ?? 0) - (order.get(b.collectionId) ?? 0));
return [...new Set(items.map((item) => item.assetId))];
}
/** The user's title wins over every later clustering label. */
async function renameCollection(
spaceId: string,
collectionId: string,
title: string,
): Promise<MediaCollection> {
const result = await prisma.mediaCollection.updateMany({
where: { id: collectionId, spaceId },
data: { title, labelSource: "user" },
});
if (result.count !== 1) throw new IsolationError();
return collectionDto(prisma, await requireCollection(prisma, spaceId, collectionId));
}
/** Merge into the first collection; the others are deleted. */
async function mergeCollections(
spaceId: string,
collectionIds: string[],
): Promise<MediaCollection> {
const ids = [...new Set(collectionIds)];
if (ids.length < 2) throw new InvalidMediaInputError("Merge needs two collections");
return prisma.$transaction(async (tx) => {
const rows = await tx.mediaCollection.findMany({ where: { spaceId, id: { in: ids } } });
if (rows.length !== ids.length) throw new IsolationError();
const target = rows.find((row) => row.id === ids[0]) as MediaCollectionRow;
const items = await tx.mediaCollectionItem.findMany({
where: { collectionId: { in: ids } },
select: { assetId: true },
});
const assets = await orderedAssets(
tx,
spaceId,
items.map((item) => item.assetId),
);
await writeItems(tx, target.id, assets.ids);
await tx.mediaCollection.deleteMany({
where: { spaceId, id: { in: ids.filter((id) => id !== target.id) } },
});
const updated = await tx.mediaCollection.update({
where: { id: target.id },
data: {
startAt: assets.startAt,
endAt: assets.endAt,
labelSource: "user",
coverAssetId: target.coverAssetId ?? assets.ids[0] ?? null,
},
});
return collectionDto(tx, updated);
});
}
/** Split before `atAssetId`: it and every later asset move to a new collection. */
async function splitCollection(
spaceId: string,
collectionId: string,
atAssetId: string,
): Promise<MediaCollection[]> {
return prisma.$transaction(async (tx) => {
const row = await requireCollection(tx, spaceId, collectionId);
const items = await tx.mediaCollectionItem.findMany({
where: { collectionId },
orderBy: [{ position: "asc" }, { assetId: "asc" }],
select: { assetId: true },
});
const index = items.findIndex((item) => item.assetId === atAssetId);
if (index <= 0) throw new InvalidMediaInputError("Split asset must be inside the collection");
const first = await orderedAssets(
tx,
spaceId,
items.slice(0, index).map((item) => item.assetId),
);
const second = await orderedAssets(
tx,
spaceId,
items.slice(index).map((item) => item.assetId),
);
await writeItems(tx, collectionId, first.ids);
const kept = await tx.mediaCollection.update({
where: { id: collectionId },
data: {
startAt: first.startAt,
endAt: first.endAt,
labelSource: "user",
coverAssetId:
row.coverAssetId && first.ids.includes(row.coverAssetId)
? row.coverAssetId
: (first.ids[0] ?? null),
},
});
const created = await tx.mediaCollection.create({
data: {
spaceId,
kind: row.kind,
title: `${row.title} (2)`,
labelSource: "user",
startAt: second.startAt,
endAt: second.endAt,
centroidLat: row.centroidLat,
centroidLon: row.centroidLon,
coverAssetId:
row.coverAssetId && second.ids.includes(row.coverAssetId)
? row.coverAssetId
: (second.ids[0] ?? null),
},
});
await writeItems(tx, created.id, second.ids);
return [await collectionDto(tx, kept), await collectionDto(tx, created)];
});
}
async function createManualCollection(
spaceId: string,
title: string,
assetIds: string[],
): Promise<MediaCollection> {
return prisma.$transaction(async (tx) => {
await assertAssetsInSpace(tx, spaceId, assetIds);
const assets = await orderedAssets(tx, spaceId, assetIds);
const created = await tx.mediaCollection.create({
data: {
spaceId,
kind: "manual",
title,
labelSource: "user",
startAt: assets.startAt,
endAt: assets.endAt,
coverAssetId: assets.ids[0] ?? null,
},
});
await writeItems(tx, created.id, assets.ids);
return collectionDto(tx, created);
});
}
return {
replaceClusters,
listCollections,
getCollection,
collectionAssetIds,
renameCollection,
mergeCollections,
splitCollection,
createManualCollection,
};
}

View File

@ -0,0 +1,265 @@
import { randomUUID } from "node:crypto";
import type { MediaSourceItem } from "@rakazo/adapter-kit";
import type { BrandKit, TeaserWeights } from "@rakazo/contracts";
import { TEASER_FEATURES } from "@rakazo/contracts";
import { bootstrapUserSpace } from "./bootstrap-user.js";
import type { PrismaClient } from "./client.js";
import { createDb } from "./client.js";
import type { MediaRepos } from "./media.js";
/*
* Synthetic fixtures for the Media Studio Postgres tests. Nothing here describes a
* real person, place, or file.
*/
const signupEnv = { signupsEnabled: "true", signupAllowlist: "" };
export interface MediaTestDb {
prisma: PrismaClient;
newUser(): Promise<{ userId: string; spaceId: string }>;
cleanup(): Promise<void>;
}
export function openMediaTestDb(databaseUrl: string): MediaTestDb {
const db = createDb(databaseUrl, { poolMax: 8 });
const userIds: string[] = [];
return {
prisma: db.prisma,
async newUser() {
// bootstrapUserSpace derives the organization slug from the first 12 characters.
const id = `md${randomUUID().replaceAll("-", "")}`;
userIds.push(id);
await db.prisma.user.create({ data: { id, name: "Test", email: `${id}@rakazo.test` } });
const { spaceId } = await bootstrapUserSpace(db.prisma, { id }, signupEnv, {
claimDeploymentOwner: false,
});
return { userId: id, spaceId };
},
async cleanup() {
const memberships = await db.prisma.member.findMany({
where: { userId: { in: userIds } },
select: { organizationId: true },
});
await db.prisma.organization.deleteMany({
where: { id: { in: memberships.map((member) => member.organizationId) } },
});
await db.prisma.user.deleteMany({ where: { id: { in: userIds } } });
await db.prisma.$disconnect();
await db.pool.end();
},
};
}
export const BASE_TIME = Date.UTC(2026, 0, 1, 8, 0, 0);
export function sourceItem(
index: number,
overrides: Partial<MediaSourceItem> = {},
): MediaSourceItem {
return {
externalId: `clip-${String(index).padStart(5, "0")}.mp4`,
kind: index % 3 === 0 ? "photo" : "video",
capturedAt: new Date(BASE_TIME + index * 60_000),
durationMs: index % 3 === 0 ? null : 10_000,
width: 1920,
height: 1080,
fps: 30,
gpsLat: null,
gpsLon: null,
deviceModel: "Test Camera",
albumNames: [],
isFavorite: false,
fileName: `clip-${index}.mp4`,
sizeBytes: 1000 + index,
revision: "r1",
...overrides,
};
}
export function testEdl(assetId: string, profileId = "example-short-vertical") {
return {
edlVersion: 1,
output: {
profileId,
width: 1080,
height: 1920,
fps: 30,
durationTargetMs: 5000,
durationHardMinMs: 1000,
durationHardMaxMs: 60_000,
},
tracks: {
video: [{ id: "c1", assetId, inMs: 0, outMs: 5000, atMs: 0, reframe: "auto" }],
},
metadata: {},
};
}
export const testBrandKit: BrandKit = {
name: "Test Kit",
colors: {
primary: "#101010",
secondary: "#202020",
accent: "#303030",
text: "#f0f0f0",
background: "#000000",
},
fonts: [],
logoArtifactIds: [],
captionPresets: [],
musicBeds: [],
};
export const testTeaserWeights = Object.fromEntries(
TEASER_FEATURES.map((feature) => [feature, 1]),
) as TeaserWeights;
export function vectorBytes(values: number[]): Uint8Array {
return new Uint8Array(new Float32Array(values).buffer);
}
/** One of every media record in a space, linked the way the product links them. */
export async function seedMediaSpace(media: MediaRepos, spaceId: string, userId: string) {
const source = await media.createSource(spaceId, {
kind: "folder",
label: "Footage",
ownerUserId: userId,
config: { path: "/media/footage" },
});
const synced = await media.upsertSourceItems(spaceId, source.id, [
sourceItem(1, { albumNames: ["Holiday"] }),
sourceItem(2),
]);
const [assetId, secondAssetId] = synced.changedAssetIds as [string, string];
const analysis = await media.upsertAnalysis(spaceId, assetId, {
kind: "embeddings",
modelId: "test-embedder",
modelVersion: "1",
vectors: vectorBytes([1, 0, 0]),
});
await media.replaceSegments(spaceId, assetId, 1, [
{ kind: "shot", startMs: 0, endMs: 4000, labels: ["beach"], transcriptExcerpt: "hello" },
]);
const segments = await media.listSegments(spaceId, [assetId]);
const tracks = await media.replaceFaceTracks(spaceId, assetId, "faces-1", [
{
startMs: 0,
endMs: 3000,
confidenceMin: 0.9,
keyframesPath: `faces/${assetId}/t1.json`,
embedding: vectorBytes([0.1, 0.2, 0.3]),
},
]);
const [person] = await media.createPersonSuggestions(spaceId, [
{ centroid: vectorBytes([0.1, 0.2, 0.3]), trackIds: tracks.map((track) => track.id) },
]);
const mark = await media.createMark(spaceId, {
kind: "tattoo",
label: "arm",
personId: person?.id ?? null,
assetId,
prompt: { tMs: 1000, point: { x: 0.5, y: 0.5 } },
treatment: "blur",
});
await media.replaceClusters(spaceId, "session", [
{
key: "s1",
title: "Morning",
labelSource: "rule",
startAt: new Date(BASE_TIME),
endAt: new Date(BASE_TIME + 120_000),
centroid: null,
coverAssetId: assetId,
assetIds: [assetId, secondAssetId],
},
]);
const collection = (await media.listCollections(spaceId, {})).items[0];
const brandKit = await media.upsertBrandKit(spaceId, { kit: testBrandKit });
const project = await media.createProject(spaceId, userId, {
title: "Teaser",
goal: "teaser",
profileIds: ["example-short-vertical"],
collectionIds: collection ? [collection.id] : [],
brandKitId: brandKit.id,
});
const plan = await media.createPlanVersion(spaceId, {
projectId: project.id,
profileId: "example-short-vertical",
edl: testEdl(assetId),
createdBy: "agent",
rationale: null,
});
const render = await media.createRender(spaceId, {
planId: plan.id,
quality: "draft",
createdBy: userId,
});
await media.claimRender(spaceId, render.id);
await media.updateRender(spaceId, render.id, { status: "done", progress: 1 });
const queuedRender = await media.createRender(spaceId, {
planId: plan.id,
quality: "final",
createdBy: userId,
});
const publication = await media.createPublication(spaceId, userId, {
renderId: render.id,
destination: "bundle",
targetUrl: "https://example.com/offer",
trackingUrl: "https://example.com/offer?utm_content=a",
utm: { source: "shorts", medium: "video", campaign: "launch", content: "a" },
title: "Title",
caption: "Caption",
tags: ["test"],
thumbnailVariant: "t1",
scheduledAt: null,
});
const metric = await media.recordMetric(spaceId, {
publicationId: publication.id,
observedAt: new Date(BASE_TIME).toISOString(),
views: 100,
clicks: 10,
conversions: 1,
revenueCents: 999,
currency: "EUR",
source: "manual",
});
const mapping = await media.saveMetricMapping(spaceId, {
name: "Default",
delimiter: ",",
columns: { publication: "id", observedAt: "date", views: "views" },
publicationKey: "id",
revenueUnit: "major",
defaultCurrency: "EUR",
});
await media.putTeaserModel(spaceId, testTeaserWeights, 3);
const upload = await media.beginUpload(spaceId, {
assetId: secondAssetId,
userId,
sizeBytes: 10,
sha256: "a".repeat(64),
fileName: "clip.mp4",
chunkBytes: 4,
});
return {
sourceId: source.id,
assetId,
secondAssetId,
analysisId: analysis.id,
segmentId: segments[0]?.id as string,
trackId: tracks[0]?.id as string,
personId: person?.id as string,
markId: mark.id,
collectionId: collection?.id as string,
brandKitId: brandKit.id,
projectId: project.id,
planId: plan.id,
renderId: render.id,
queuedRenderId: queuedRender.id,
publicationId: publication.id,
metricId: metric.id,
mappingId: mapping.id as string,
uploadId: upload.id,
};
}
export type SeededMediaSpace = Awaited<ReturnType<typeof seedMediaSpace>>;

View File

@ -0,0 +1,428 @@
import { afterAll, beforeAll, describe, expect, it } from "vitest";
import type { MediaRepos } from "./media.js";
import { createMediaRepos } from "./media.js";
import type { MediaTestDb, SeededMediaSpace } from "./media-fixtures.js";
import {
openMediaTestDb,
seedMediaSpace,
sourceItem,
testBrandKit,
testEdl,
vectorBytes,
} from "./media-fixtures.js";
import { IsolationError } from "./scope.js";
const databaseUrl = process.env.DATABASE_URL;
const describePostgres =
process.env.VERIFY_DATABASE && databaseUrl ? describe.sequential : describe.skip;
describePostgres("media repositories: space isolation (PostgreSQL)", () => {
let db: MediaTestDb;
let media: MediaRepos;
let a: SeededMediaSpace;
let owner: { userId: string; spaceId: string };
let intruder: { userId: string; spaceId: string };
beforeAll(async () => {
db = openMediaTestDb(databaseUrl!);
media = createMediaRepos(db.prisma);
owner = await db.newUser();
intruder = await db.newUser();
a = await seedMediaSpace(media, owner.spaceId, owner.userId);
});
afterAll(async () => {
await db?.cleanup();
});
async function snapshot() {
const where = { spaceId: owner.spaceId };
const p = db.prisma;
return {
sources: await p.mediaSource.findMany({ where, orderBy: { id: "asc" } }),
assets: await p.mediaAsset.findMany({ where, orderBy: { id: "asc" } }),
segments: await p.mediaSegment.findMany({ where, orderBy: { id: "asc" } }),
analyses: await p.mediaAnalysis.findMany({ where, orderBy: { id: "asc" } }),
collections: await p.mediaCollection.findMany({ where, orderBy: { id: "asc" } }),
items: await p.mediaCollectionItem.findMany({
where: { collection: where },
orderBy: [{ collectionId: "asc" }, { assetId: "asc" }],
}),
people: await p.mediaPerson.findMany({ where, orderBy: { id: "asc" } }),
tracks: await p.mediaFaceTrack.findMany({ where, orderBy: { id: "asc" } }),
marks: await p.mediaMark.findMany({ where, orderBy: { id: "asc" } }),
kits: await p.mediaBrandKit.findMany({ where, orderBy: { id: "asc" } }),
projects: await p.mediaProject.findMany({ where, orderBy: { id: "asc" } }),
plans: await p.mediaEditPlan.findMany({ where, orderBy: { id: "asc" } }),
renders: await p.mediaRender.findMany({ where, orderBy: { id: "asc" } }),
publications: await p.mediaPublication.findMany({ where, orderBy: { id: "asc" } }),
metrics: await p.mediaMetric.findMany({ where, orderBy: { id: "asc" } }),
mappings: await p.mediaMetricMapping.findMany({ where, orderBy: { id: "asc" } }),
teaser: await p.mediaTeaserModel.findMany({ where }),
uploads: await p.mediaUpload.findMany({ where, orderBy: { id: "asc" } }),
};
}
it("rejects every read and write of another space's ids and leaves its rows untouched", async () => {
const before = await snapshot();
const b = intruder.spaceId;
const denied: Array<[string, () => Promise<unknown>]> = [
["getSource", () => media.getSource(b, a.sourceId)],
["updateSourceSync", () => media.updateSourceSync(b, a.sourceId, { status: "error" })],
["removeSource", () => media.removeSource(b, a.sourceId)],
["upsertSourceItems", () => media.upsertSourceItems(b, a.sourceId, [sourceItem(99)])],
["getAsset", () => media.getAsset(b, a.assetId)],
["getAssetRecord", () => media.getAssetRecord(b, a.assetId)],
[
"transitionIngestState",
() => media.transitionIngestState(b, a.assetId, { from: ["indexed"], to: "proxying" }),
],
[
"recordProxy",
() => media.recordProxy(b, a.assetId, { contentHash: "x", proxyPath: "p", thumbPath: "t" }),
],
["recordAnalysisVersion", () => media.recordAnalysisVersion(b, a.assetId, 9, 1)],
["requestUpload", () => media.requestUpload(b, [a.assetId])],
[
"upsertAnalysis",
() =>
media.upsertAnalysis(b, a.assetId, {
kind: "embeddings",
modelId: "m",
modelVersion: "1",
vectors: vectorBytes([9]),
}),
],
["latestAnalysis", () => media.latestAnalysis(b, a.assetId, "embeddings")],
[
"replaceSegments",
() => media.replaceSegments(b, a.assetId, 2, [{ kind: "shot", startMs: 0, endMs: 1 }]),
],
["setSegmentsPaidOnly", () => media.setSegmentsPaidOnly(b, [a.segmentId], true)],
[
"beginUpload",
() =>
media.beginUpload(b, {
assetId: a.assetId,
userId: intruder.userId,
sizeBytes: 1,
sha256: "b".repeat(64),
fileName: "x",
}),
],
["uploadStatus", () => media.uploadStatus(b, a.uploadId)],
["getUploadRecord", () => media.getUploadRecord(b, a.uploadId)],
["markChunkReceived", () => media.markChunkReceived(b, a.uploadId, 0)],
["completeUpload", () => media.completeUpload(b, a.uploadId, { storagePath: "x" })],
["failUpload", () => media.failUpload(b, a.uploadId)],
[
"replaceClusters",
() =>
media.replaceClusters(b, "session", [
{
key: "s1",
title: "Stolen",
labelSource: "rule",
startAt: null,
endAt: null,
centroid: null,
coverAssetId: null,
assetIds: [a.assetId],
},
]),
],
["getCollection", () => media.getCollection(b, a.collectionId)],
["collectionAssetIds", () => media.collectionAssetIds(b, [a.collectionId])],
["renameCollection", () => media.renameCollection(b, a.collectionId, "Mine")],
["mergeCollections", () => media.mergeCollections(b, [a.collectionId, `${a.collectionId}x`])],
["splitCollection", () => media.splitCollection(b, a.collectionId, a.secondAssetId)],
["createManualCollection", () => media.createManualCollection(b, "Mine", [a.assetId])],
["getPerson", () => media.getPerson(b, a.personId)],
[
"createPersonSuggestions",
() =>
media.createPersonSuggestions(b, [{ centroid: vectorBytes([1]), trackIds: [a.trackId] }]),
],
["updatePerson", () => media.updatePerson(b, a.personId, { displayName: "Mine" })],
["mergePeople", () => media.mergePeople(b, [a.personId, `${a.personId}x`])],
["assignTrack", () => media.assignTrack(b, a.trackId, null)],
[
"recordConsent (foreign person)",
() =>
media.recordConsent(
{ spaceId: b, recordedByUserId: intruder.userId },
{
personId: a.personId,
consentArtifactId: null,
consentRecordedAt: new Date().toISOString(),
ageVerificationMethod: "none",
ageVerifiedAt: null,
},
),
],
[
"recordConsent (foreign recorder)",
() =>
media.recordConsent(
{ spaceId: owner.spaceId, recordedByUserId: intruder.userId },
{
personId: a.personId,
consentArtifactId: null,
consentRecordedAt: new Date().toISOString(),
ageVerificationMethod: "none",
ageVerifiedAt: null,
},
),
],
["removePersonCompletely", () => media.removePersonCompletely(b, a.personId)],
["replaceFaceTracks", () => media.replaceFaceTracks(b, a.assetId, "faces-2", [])],
[
"createMark",
() =>
media.createMark(b, {
kind: "scar",
label: null,
personId: null,
assetId: a.assetId,
prompt: { tMs: 0 },
treatment: "blur",
}),
],
["getMark", () => media.getMark(b, a.markId)],
["updateMark", () => media.updateMark(b, a.markId, { treatment: "cover" })],
[
"recordMarkResult",
() =>
media.recordMarkResult(b, a.markId, {
qualityScore: 0,
suggestedFallback: "cover",
maskDir: "x",
}),
],
["removeMark", () => media.removeMark(b, a.markId)],
["upsertBrandKit", () => media.upsertBrandKit(b, { id: a.brandKitId, kit: testBrandKit })],
["getBrandKit", () => media.getBrandKit(b, a.brandKitId)],
["removeBrandKit", () => media.removeBrandKit(b, a.brandKitId)],
[
"createProject (foreign brand kit)",
() =>
media.createProject(b, intruder.userId, {
title: "x",
goal: "teaser",
profileIds: ["example-short-vertical"],
brandKitId: a.brandKitId,
}),
],
[
"createProject (foreign collection)",
() =>
media.createProject(b, intruder.userId, {
title: "x",
goal: "teaser",
profileIds: ["example-short-vertical"],
collectionIds: [a.collectionId],
}),
],
["updateProject", () => media.updateProject(b, a.projectId, { title: "Mine" })],
["getProject", () => media.getProject(b, a.projectId)],
["getProjectRecord", () => media.getProjectRecord(b, a.projectId)],
["removeProject", () => media.removeProject(b, a.projectId)],
[
"createPlanVersion",
() =>
media.createPlanVersion(b, {
projectId: a.projectId,
profileId: "example-short-vertical",
edl: testEdl(a.assetId),
createdBy: "agent",
rationale: null,
}),
],
["getPlan", () => media.getPlan(b, a.planId)],
["latestPlan", () => media.latestPlan(b, a.projectId, "example-short-vertical")],
["listPlanVersions", () => media.listPlanVersions(b, a.projectId, "example-short-vertical")],
[
"createRender",
() => media.createRender(b, { planId: a.planId, quality: "draft", createdBy: "x" }),
],
["getRender", () => media.getRender(b, a.renderId)],
["getRenderRecord", () => media.getRenderRecord(b, a.renderId)],
["claimRender", () => media.claimRender(b, a.queuedRenderId)],
["updateRender", () => media.updateRender(b, a.queuedRenderId, { progress: 0.5 })],
["cancelRender", () => media.cancelRender(b, a.queuedRenderId)],
[
"createPublication",
() =>
media.createPublication(b, intruder.userId, {
renderId: a.renderId,
destination: "bundle",
targetUrl: null,
trackingUrl: null,
utm: null,
title: null,
caption: null,
tags: [],
thumbnailVariant: null,
scheduledAt: null,
}),
],
["getPublication", () => media.getPublication(b, a.publicationId)],
["getPublicationRecord", () => media.getPublicationRecord(b, a.publicationId)],
["schedulePublication", () => media.schedulePublication(b, a.publicationId, new Date())],
["cancelPublication", () => media.cancelPublication(b, a.publicationId)],
["markPublicationBundled", () => media.markPublicationBundled(b, a.publicationId, null)],
["markPublicationPublished", () => media.markPublicationPublished(b, a.publicationId, null)],
["markPublicationFailed", () => media.markPublicationFailed(b, a.publicationId)],
[
"recordMetric",
() =>
media.recordMetric(b, {
publicationId: a.publicationId,
observedAt: new Date().toISOString(),
views: 1,
clicks: 0,
conversions: 0,
revenueCents: 0,
currency: "EUR",
source: "manual",
}),
],
[
"recordMetrics",
() =>
media.recordMetrics(b, [
{
publicationId: a.publicationId,
observedAt: new Date().toISOString(),
views: 1,
clicks: 0,
conversions: 0,
revenueCents: 0,
currency: "EUR",
source: "csv_import",
},
]),
],
["listMetrics", () => media.listMetrics(b, a.publicationId)],
[
"saveMetricMapping (foreign id)",
() =>
media.saveMetricMapping(b, {
id: a.mappingId,
name: "Stolen",
delimiter: ",",
columns: { publication: "id", observedAt: "date" },
publicationKey: "id",
revenueUnit: "major",
defaultCurrency: "EUR",
}),
],
["getMetricMapping", () => media.getMetricMapping(b, a.mappingId)],
["funnel (foreign project)", () => media.funnel(b, a.projectId)],
];
for (const [name, call] of denied) {
await expect(call(), name).rejects.toBeInstanceOf(IsolationError);
}
// Reads without ids return nothing of the other space.
expect(await media.listSources(b)).toEqual([]);
expect((await media.searchAssets(b, {})).items).toEqual([]);
expect((await media.searchAssets(b, { text: "clip" })).items).toEqual([]);
expect((await media.searchAssets(b, { personId: a.personId })).items).toEqual([]);
expect((await media.searchAssets(b, { collectionId: a.collectionId })).items).toEqual([]);
expect((await media.searchAssets(b, { sourceId: a.sourceId })).items).toEqual([]);
expect(await media.listAssetRecords(b, [a.assetId])).toEqual([]);
expect(await media.listAssetsForClustering(b)).toEqual([]);
expect(await media.assetsNeedingWork(b, "indexed", 10)).toEqual([]);
expect((await media.latestAnalysesByKind(b, "embeddings")).size).toBe(0);
expect(await media.listSegments(b, [a.assetId])).toEqual([]);
expect((await media.listCollections(b, {})).items).toEqual([]);
expect(await media.listPeople(b)).toEqual([]);
expect(await media.listPersonRecords(b, [a.personId])).toEqual([]);
expect(await media.personCentroids(b)).toEqual([]);
expect(await media.listFaceTracks(b, { assetId: a.assetId })).toEqual([]);
expect(await media.faceTrackRecords(b, [a.assetId])).toEqual([]);
expect(await media.faceTrackEmbeddings(b)).toEqual([]);
expect(await media.listMarks(b, { assetId: a.assetId })).toEqual([]);
expect(await media.marksForAsset(b, a.assetId)).toEqual([]);
expect(await media.listBrandKits(b)).toEqual([]);
expect((await media.listProjects(b)).items).toEqual([]);
expect(await media.latestPlans(b, a.projectId)).toEqual([]);
expect(await media.listRenders(b, a.projectId)).toEqual([]);
expect((await media.listPublications(b, { projectId: a.projectId })).items).toEqual([]);
expect((await media.resolvePublicationKeys(b, "id", [a.publicationId])).size).toBe(0);
expect(await media.listMetricMappings(b)).toEqual([]);
expect(await media.funnel(b)).toEqual([]);
expect(await media.publicationsWithMetricsCount(b)).toBe(0);
expect(await media.getTeaserModel(b)).toBeNull();
await media.resetTeaserModel(b);
expect(await snapshot()).toEqual(before);
// The owner still sees everything.
expect((await media.searchAssets(owner.spaceId, {})).items).toHaveLength(2);
expect(await media.getTeaserModel(owner.spaceId)).not.toBeNull();
});
it("resolves the owning space for worker jobs", async () => {
expect(await media.mediaSpaceOf("source", a.sourceId)).toBe(owner.spaceId);
expect(await media.mediaSpaceOf("asset", a.assetId)).toBe(owner.spaceId);
expect(await media.mediaSpaceOf("render", a.renderId)).toBe(owner.spaceId);
expect(await media.mediaSpaceOf("publication", a.publicationId)).toBe(owner.spaceId);
expect(await media.mediaSpaceOf("asset", "missing")).toBeNull();
});
it("deleting a space removes every media row of it and nothing else", async () => {
const other = await db.newUser();
const seeded = await seedMediaSpace(media, other.spaceId, other.userId);
expect(seeded.assetId).toBeTruthy();
const tables = [
"media_sources",
"media_assets",
"media_uploads",
"media_segments",
"media_analyses",
"media_collections",
"media_people",
"media_face_tracks",
"media_marks",
"media_brand_kits",
"media_projects",
"media_edit_plans",
"media_renders",
"media_publications",
"media_metrics",
"media_metric_mappings",
"media_teaser_models",
];
async function counts(spaceId: string) {
const out: Record<string, number> = {};
for (const table of tables) {
const rows = await db.prisma.$queryRawUnsafe<Array<{ count: number }>>(
`SELECT count(*)::int AS count FROM ${table} WHERE "spaceId" = $1`,
spaceId,
);
out[table] = rows[0]?.count ?? -1;
}
const items = await db.prisma.$queryRawUnsafe<Array<{ count: number }>>(
`SELECT count(*)::int AS count FROM media_collection_items ci
JOIN media_assets a ON a.id = ci."assetId" WHERE a."spaceId" = $1`,
spaceId,
);
out.media_collection_items = items[0]?.count ?? -1;
return out;
}
const populated = await counts(other.spaceId);
for (const table of tables) expect(populated[table], table).toBeGreaterThan(0);
const ownerBefore = await counts(owner.spaceId);
const itemCountBefore = await db.prisma.mediaCollectionItem.count();
await db.prisma.space.delete({ where: { id: other.spaceId } });
const after = await counts(other.spaceId);
for (const [table, count] of Object.entries(after)) expect(count, table).toBe(0);
expect(await db.prisma.mediaCollectionItem.count()).toBe(
itemCountBefore - (populated.media_collection_items ?? 0),
);
expect(await counts(owner.spaceId)).toEqual(ownerBefore);
});
});

View File

@ -0,0 +1,756 @@
import type { MediaSourceItem } from "@rakazo/adapter-kit";
import type {
MediaAnalysisKind,
MediaAsset,
MediaAssetQuery,
MediaFaceTrack,
MediaIngestState,
MediaSegment,
MediaSegmentKind,
MediaSource,
MediaSourceKind,
MediaSourceStatus,
TeaserFeature,
} from "@rakazo/contracts";
import { CLIENT_PUSHED_MEDIA_SOURCES } from "@rakazo/contracts";
import { roundCoordinate } from "@rakazo/media";
import type {
MediaAnalysis as MediaAnalysisRow,
MediaAsset as MediaAssetRow,
MediaSource as MediaSourceRow,
PrismaClient,
} from "./client.js";
import { Prisma } from "./client.js";
import type { MediaPage, MediaPageInput } from "./media-shared.js";
import {
decodeKeysetCursor,
InvalidMediaInputError,
keysetAfterSql,
likeContains,
lockMediaKey,
mapMediaAsset,
mapMediaFaceTrack,
mapMediaSegment,
mapMediaSource,
pageLimit,
toBytes,
toPage,
} from "./media-shared.js";
import { IsolationError } from "./scope.js";
export interface NewMediaSource {
kind: MediaSourceKind;
label: string;
ownerUserId: string;
/** Folder: { path }. Client-pushed: { deviceId }. Never secrets. */
config: Record<string, unknown>;
}
/** Source row for the sync worker, including its config and cursor. */
export interface MediaSourceRecord extends MediaSource {
spaceId: string;
ownerUserId: string;
config: Record<string, unknown>;
lastSyncCursor: string | null;
}
export interface MediaSourceSyncPatch {
status?: MediaSourceStatus;
lastSyncCursor?: string | null;
lastSyncedAt?: Date | null;
error?: string | null;
}
export interface UpsertSourceItemsResult {
created: number;
updated: number;
unchanged: number;
/** New assets and assets whose revision changed, in input order. */
changedAssetIds: string[];
}
export type MediaAssetSearch = Omit<MediaAssetQuery, "cursor" | "limit"> & MediaPageInput;
export interface MediaAssetDetail {
asset: MediaAsset;
segments: MediaSegment[];
faceTracks: MediaFaceTrack[];
transcript: string | null;
}
export interface MediaProxyResult {
contentHash: string;
proxyPath: string | null;
thumbPath: string | null;
spritePath?: string | null;
durationMs?: number | null;
width?: number | null;
height?: number | null;
fps?: number | null;
codec?: string | null;
orientation?: number | null;
}
export interface NewMediaAnalysis {
kind: MediaAnalysisKind;
modelId: string;
modelVersion: string;
data?: Prisma.InputJsonValue | null;
dataPath?: string | null;
/** Float32 little-endian vectors (see encodeVectors in @rakazo/media). */
vectors?: Uint8Array | null;
}
export interface NewMediaSegment {
kind: MediaSegmentKind;
startMs: number;
endMs: number;
scores?: Partial<Record<TeaserFeature, number>>;
teaserScore?: number | null;
transcriptExcerpt?: string | null;
labels?: string[];
paidOnly?: boolean;
}
export type MediaSpaceLookup = "source" | "asset" | "render" | "publication";
const EARTH_RADIUS_KM = 6371.0088;
const KM_PER_DEGREE = 111.32;
function sourceRecord(row: MediaSourceRow, assetCount: number): MediaSourceRecord {
const config =
row.config && typeof row.config === "object" && !Array.isArray(row.config)
? (row.config as Record<string, unknown>)
: {};
return {
...mapMediaSource(row, assetCount),
spaceId: row.spaceId,
ownerUserId: row.ownerUserId,
config,
lastSyncCursor: row.lastSyncCursor,
};
}
function rounded(value: number | null): number | null {
return value === null ? null : roundCoordinate(value);
}
function itemData(item: MediaSourceItem) {
return {
kind: item.kind,
revision: item.revision,
capturedAt: item.capturedAt,
durationMs: item.durationMs,
width: item.width,
height: item.height,
fps: item.fps,
gpsLat: rounded(item.gpsLat),
gpsLon: rounded(item.gpsLon),
deviceModel: item.deviceModel,
albumNames: item.albumNames,
isFavorite: item.isFavorite,
fileName: item.fileName,
sizeBytes: item.sizeBytes === null ? null : BigInt(item.sizeBytes),
};
}
/** Plain text of a stored transcript analysis (TranscribeResult words or a text field). */
function transcriptText(data: Prisma.JsonValue | null): string | null {
if (!data || typeof data !== "object" || Array.isArray(data)) return null;
if (typeof data.text === "string") return data.text;
if (!Array.isArray(data.words)) return null;
const words = data.words
.map((word) =>
word && typeof word === "object" && !Array.isArray(word) && typeof word.text === "string"
? word.text.trim()
: "",
)
.filter(Boolean);
return words.length > 0 ? words.join(" ") : null;
}
function nearSql(near: NonNullable<MediaAssetQuery["near"]>): Prisma.Sql {
const { lat, lon, radiusKm } = near;
const dLat = radiusKm / KM_PER_DEGREE;
const conditions: Prisma.Sql[] = [
Prisma.sql`a."gpsLat" IS NOT NULL AND a."gpsLon" IS NOT NULL`,
Prisma.sql`a."gpsLat" BETWEEN ${lat - dLat}::float8 AND ${lat + dLat}::float8`,
];
// Longitude prefilter only where it is a simple band; near the poles the haversine alone decides.
const maxAbsLat = Math.min(90, Math.abs(lat) + dLat);
const cos = Math.cos((maxAbsLat * Math.PI) / 180);
if (cos > 0.01) {
const dLon = radiusKm / (KM_PER_DEGREE * cos);
if (dLon < 180) {
const west = lon - dLon;
const east = lon + dLon;
if (west < -180) {
conditions.push(
Prisma.sql`(a."gpsLon" >= ${west + 360}::float8 OR a."gpsLon" <= ${east}::float8)`,
);
} else if (east > 180) {
conditions.push(
Prisma.sql`(a."gpsLon" >= ${west}::float8 OR a."gpsLon" <= ${east - 360}::float8)`,
);
} else {
conditions.push(Prisma.sql`a."gpsLon" BETWEEN ${west}::float8 AND ${east}::float8`);
}
}
}
conditions.push(Prisma.sql`
2 * ${EARTH_RADIUS_KM}::float8 * asin(least(1, sqrt(
power(sin(radians(a."gpsLat" - ${lat}::float8) / 2), 2)
+ cos(radians(${lat}::float8)) * cos(radians(a."gpsLat"))
* power(sin(radians(a."gpsLon" - ${lon}::float8) / 2), 2)
))) <= ${radiusKm}::float8`);
return Prisma.join(conditions, " AND ");
}
function textSql(spaceId: string, text: string): Prisma.Sql {
const pattern = likeContains(text);
return Prisma.sql`(
a."fileName" ILIKE ${pattern}
OR EXISTS (SELECT 1 FROM unnest(a."albumNames") AS album(name) WHERE album.name ILIKE ${pattern})
OR EXISTS (
SELECT 1 FROM media_collection_items ci
JOIN media_collections c ON c.id = ci."collectionId"
WHERE ci."assetId" = a.id AND c."spaceId" = ${spaceId} AND c.title ILIKE ${pattern}
)
OR EXISTS (
SELECT 1 FROM media_segments s
WHERE s."assetId" = a.id AND s."spaceId" = ${spaceId}
AND (
s."transcriptExcerpt" ILIKE ${pattern}
OR EXISTS (SELECT 1 FROM unnest(s.labels) AS label(name) WHERE label.name ILIKE ${pattern})
)
)
)`;
}
export function createMediaLibraryRepos(prisma: PrismaClient) {
async function requireAsset(
db: Prisma.TransactionClient | PrismaClient,
spaceId: string,
assetId: string,
): Promise<MediaAssetRow> {
const asset = await db.mediaAsset.findFirst({ where: { id: assetId, spaceId } });
if (!asset) throw new IsolationError();
return asset;
}
async function updateAsset(
spaceId: string,
assetId: string,
data: Prisma.MediaAssetUpdateManyMutationInput,
): Promise<MediaAsset> {
const result = await prisma.mediaAsset.updateMany({ where: { id: assetId, spaceId }, data });
if (result.count !== 1) throw new IsolationError();
return mapMediaAsset(await requireAsset(prisma, spaceId, assetId));
}
// -------------------------------------------------------------------------
// Sources
// -------------------------------------------------------------------------
async function createSource(spaceId: string, input: NewMediaSource): Promise<MediaSource> {
const row = await prisma.mediaSource.create({
data: {
spaceId,
kind: input.kind,
label: input.label,
ownerUserId: input.ownerUserId,
config: input.config as Prisma.InputJsonObject,
},
});
return mapMediaSource(row, 0);
}
async function listSources(spaceId: string): Promise<MediaSource[]> {
const rows = await prisma.mediaSource.findMany({
where: { spaceId },
orderBy: [{ createdAt: "asc" }, { id: "asc" }],
include: { _count: { select: { assets: true } } },
});
return rows.map((row) => mapMediaSource(row, row._count.assets));
}
async function getSource(spaceId: string, sourceId: string): Promise<MediaSourceRecord> {
const row = await prisma.mediaSource.findFirst({
where: { id: sourceId, spaceId },
include: { _count: { select: { assets: true } } },
});
if (!row) throw new IsolationError();
return sourceRecord(row, row._count.assets);
}
async function updateSourceSync(
spaceId: string,
sourceId: string,
patch: MediaSourceSyncPatch,
): Promise<MediaSourceRecord> {
const result = await prisma.mediaSource.updateMany({
where: { id: sourceId, spaceId },
data: {
...(patch.status !== undefined ? { status: patch.status } : {}),
...(patch.lastSyncCursor !== undefined ? { lastSyncCursor: patch.lastSyncCursor } : {}),
...(patch.lastSyncedAt !== undefined ? { lastSyncedAt: patch.lastSyncedAt } : {}),
...(patch.error !== undefined ? { error: patch.error } : {}),
},
});
if (result.count !== 1) throw new IsolationError();
return getSource(spaceId, sourceId);
}
/** Deletes the source and, by cascade, its assets and everything derived from them. */
async function removeSource(spaceId: string, sourceId: string): Promise<void> {
const result = await prisma.mediaSource.deleteMany({ where: { id: sourceId, spaceId } });
if (result.count !== 1) throw new IsolationError();
}
/**
* Worker entry point: jobs carry only a record id. Returns the owning space so every
* further call stays scoped, or null when the record no longer exists.
*/
async function mediaSpaceOf(kind: MediaSpaceLookup, id: string): Promise<string | null> {
const select = { spaceId: true } as const;
const row =
kind === "source"
? await prisma.mediaSource.findUnique({ where: { id }, select })
: kind === "asset"
? await prisma.mediaAsset.findUnique({ where: { id }, select })
: kind === "render"
? await prisma.mediaRender.findUnique({ where: { id }, select })
: await prisma.mediaPublication.findUnique({ where: { id }, select });
return row?.spaceId ?? null;
}
// -------------------------------------------------------------------------
// Assets
// -------------------------------------------------------------------------
/**
* Index one page of source items keyed by (sourceId, externalId). An item whose
* revision matches the stored one is unchanged; syncs of the same source are serialized.
*/
async function upsertSourceItems(
spaceId: string,
sourceId: string,
items: MediaSourceItem[],
): Promise<UpsertSourceItemsResult> {
const unique = new Map<string, MediaSourceItem>();
for (const item of items) unique.set(item.externalId, item);
return prisma.$transaction(
async (tx) => {
const source = await tx.mediaSource.findFirst({
where: { id: sourceId, spaceId },
select: { kind: true },
});
if (!source) throw new IsolationError();
await lockMediaKey(tx, `source:${sourceId}`);
const storage = (CLIENT_PUSHED_MEDIA_SOURCES as readonly string[]).includes(source.kind)
? "remote"
: "referenced";
const existing = await tx.mediaAsset.findMany({
where: { sourceId, externalId: { in: [...unique.keys()] } },
select: { id: true, externalId: true, revision: true },
});
const byExternalId = new Map(existing.map((row) => [row.externalId, row]));
const toCreate: MediaSourceItem[] = [];
const changed = new Map<string, string>();
let unchanged = 0;
for (const item of unique.values()) {
const current = byExternalId.get(item.externalId);
if (!current) {
toCreate.push(item);
} else if (current.revision === item.revision) {
unchanged += 1;
} else {
await tx.mediaAsset.update({ where: { id: current.id }, data: itemData(item) });
changed.set(item.externalId, current.id);
}
}
const updated = changed.size;
if (toCreate.length > 0) {
const created = await tx.mediaAsset.createManyAndReturn({
data: toCreate.map((item) => ({
...itemData(item),
spaceId,
sourceId,
externalId: item.externalId,
storage,
ingestState: storage === "remote" ? "awaiting_upload" : "indexed",
})),
select: { id: true, externalId: true },
});
for (const row of created) changed.set(row.externalId, row.id);
}
return {
created: toCreate.length,
updated,
unchanged,
changedAssetIds: [...unique.keys()].flatMap((externalId) => {
const id = changed.get(externalId);
return id ? [id] : [];
}),
};
},
{ timeout: 60_000 },
);
}
/**
* Keyset-paginated search, newest capture first, undated assets last. The cursor is
* the (capturedAt, id) of the previous page's last row, so inserts never shift pages.
*/
async function searchAssets(
spaceId: string,
query: MediaAssetSearch,
): Promise<MediaPage<MediaAsset>> {
const limit = pageLimit(query.limit);
const cursor = query.cursor ? decodeKeysetCursor(query.cursor) : null;
const conditions: Prisma.Sql[] = [Prisma.sql`a."spaceId" = ${spaceId}`];
if (query.sourceId) conditions.push(Prisma.sql`a."sourceId" = ${query.sourceId}`);
if (query.kinds && query.kinds.length > 0) {
conditions.push(Prisma.sql`a.kind IN (${Prisma.join(query.kinds)})`);
}
if (query.from) conditions.push(Prisma.sql`a."capturedAt" >= ${new Date(query.from)}`);
if (query.to) conditions.push(Prisma.sql`a."capturedAt" <= ${new Date(query.to)}`);
if (query.favoritesOnly) conditions.push(Prisma.sql`a."isFavorite" = TRUE`);
if (query.minQuality !== undefined) {
conditions.push(Prisma.sql`a."qualityScore" >= ${query.minQuality}::float8`);
}
if (query.near) conditions.push(nearSql(query.near));
if (query.personId) {
conditions.push(Prisma.sql`EXISTS (
SELECT 1 FROM media_face_tracks ft
WHERE ft."assetId" = a.id AND ft."spaceId" = ${spaceId} AND ft."personId" = ${query.personId}
)`);
}
if (query.collectionId) {
conditions.push(Prisma.sql`EXISTS (
SELECT 1 FROM media_collection_items ci
JOIN media_collections c ON c.id = ci."collectionId"
WHERE ci."assetId" = a.id AND c.id = ${query.collectionId} AND c."spaceId" = ${spaceId}
)`);
}
const text = query.text?.trim();
if (text) conditions.push(textSql(spaceId, text));
conditions.push(keysetAfterSql(cursor, Prisma.sql`a."capturedAt"`, Prisma.sql`a.id`));
const keys = await prisma.$queryRaw<Array<{ id: string }>>(Prisma.sql`
SELECT a.id FROM media_assets a
WHERE ${Prisma.join(conditions, " AND ")}
ORDER BY a."capturedAt" DESC NULLS LAST, a.id DESC
LIMIT ${limit + 1}
`);
const rows = await prisma.mediaAsset.findMany({
where: { spaceId, id: { in: keys.map((key) => key.id) } },
});
const byId = new Map(rows.map((row) => [row.id, row]));
const ordered = keys.flatMap((key) => {
const row = byId.get(key.id);
return row ? [row] : [];
});
return toPage(ordered, limit, (row) => ({ at: row.capturedAt, id: row.id }), mapMediaAsset);
}
async function getAsset(spaceId: string, assetId: string): Promise<MediaAssetDetail> {
const asset = await requireAsset(prisma, spaceId, assetId);
const [segments, faceTracks, transcript] = await Promise.all([
prisma.mediaSegment.findMany({
where: { assetId, spaceId },
orderBy: [{ startMs: "asc" }, { id: "asc" }],
}),
prisma.mediaFaceTrack.findMany({
where: { assetId, spaceId },
orderBy: [{ startMs: "asc" }, { id: "asc" }],
}),
prisma.mediaAnalysis.findFirst({
where: { assetId, spaceId, kind: "transcript" },
orderBy: [{ createdAt: "desc" }, { id: "desc" }],
select: { data: true },
}),
]);
return {
asset: mapMediaAsset(asset),
segments: segments.map(mapMediaSegment),
faceTracks: faceTracks.map(mapMediaFaceTrack),
transcript: transcriptText(transcript?.data ?? null),
};
}
/** Full row for workers: storage path, hashes, derived file paths. */
async function getAssetRecord(spaceId: string, assetId: string): Promise<MediaAssetRow> {
return requireAsset(prisma, spaceId, assetId);
}
async function listAssetRecords(spaceId: string, assetIds: string[]): Promise<MediaAssetRow[]> {
if (assetIds.length === 0) return [];
return prisma.mediaAsset.findMany({
where: { spaceId, id: { in: [...new Set(assetIds)] } },
orderBy: [{ capturedAt: { sort: "asc", nulls: "last" } }, { id: "asc" }],
});
}
/** Every asset of the space in capture order, with the fields clustering reads. */
async function listAssetsForClustering(spaceId: string) {
return prisma.mediaAsset.findMany({
where: { spaceId },
orderBy: [{ capturedAt: { sort: "asc", nulls: "last" } }, { id: "asc" }],
select: {
id: true,
kind: true,
capturedAt: true,
durationMs: true,
gpsLat: true,
gpsLon: true,
deviceModel: true,
albumNames: true,
contentHash: true,
},
});
}
/**
* Atomic ingest state change: only applies when the asset is currently in one of
* `from`, so two workers cannot both take the same step. Returns whether it applied.
*/
async function transitionIngestState(
spaceId: string,
assetId: string,
transition: { from: MediaIngestState[]; to: MediaIngestState; error?: string | null },
): Promise<boolean> {
const result = await prisma.mediaAsset.updateMany({
where: { id: assetId, spaceId, ingestState: { in: transition.from } },
data: {
ingestState: transition.to,
ingestError: transition.to === "failed" ? (transition.error ?? null) : null,
},
});
if (result.count === 1) return true;
await requireAsset(prisma, spaceId, assetId);
return false;
}
async function recordProxy(
spaceId: string,
assetId: string,
proxy: MediaProxyResult,
): Promise<MediaAsset> {
return updateAsset(spaceId, assetId, {
contentHash: proxy.contentHash,
proxyPath: proxy.proxyPath,
thumbPath: proxy.thumbPath,
...(proxy.spritePath !== undefined ? { spritePath: proxy.spritePath } : {}),
...(proxy.durationMs !== undefined ? { durationMs: proxy.durationMs } : {}),
...(proxy.width !== undefined ? { width: proxy.width } : {}),
...(proxy.height !== undefined ? { height: proxy.height } : {}),
...(proxy.fps !== undefined ? { fps: proxy.fps } : {}),
...(proxy.codec !== undefined ? { codec: proxy.codec } : {}),
...(proxy.orientation !== undefined ? { orientation: proxy.orientation } : {}),
ingestState: "proxied",
ingestError: null,
});
}
async function recordAnalysisVersion(
spaceId: string,
assetId: string,
analysisVersion: number,
qualityScore?: number | null,
): Promise<MediaAsset> {
return updateAsset(spaceId, assetId, {
analysisVersion,
...(qualityScore !== undefined ? { qualityScore } : {}),
ingestState: "analyzed",
ingestError: null,
});
}
/** Remote assets move to awaiting_upload; returns the ids that now wait for bytes. */
async function requestUpload(spaceId: string, assetIds: string[]): Promise<string[]> {
const ids = [...new Set(assetIds)];
const owned = await prisma.mediaAsset.count({ where: { spaceId, id: { in: ids } } });
if (owned !== ids.length) throw new IsolationError();
await prisma.mediaAsset.updateMany({
where: {
spaceId,
id: { in: ids },
storage: "remote",
ingestState: { in: ["indexed", "failed"] },
},
data: { ingestState: "awaiting_upload", ingestError: null },
});
const awaiting = await prisma.mediaAsset.findMany({
where: { spaceId, id: { in: ids }, ingestState: "awaiting_upload" },
select: { id: true },
});
const set = new Set(awaiting.map((row) => row.id));
return ids.filter((id) => set.has(id));
}
async function assetsNeedingWork(
spaceId: string,
state: MediaIngestState,
limit: number,
): Promise<MediaAssetRow[]> {
return prisma.mediaAsset.findMany({
where: { spaceId, ingestState: state },
orderBy: [{ createdAt: "asc" }, { id: "asc" }],
take: pageLimit(limit),
});
}
// -------------------------------------------------------------------------
// Analyses
// -------------------------------------------------------------------------
/** Insert or replace the result for (asset, kind, modelVersion); other versions stay. */
async function upsertAnalysis(
spaceId: string,
assetId: string,
analysis: NewMediaAnalysis,
): Promise<MediaAnalysisRow> {
await requireAsset(prisma, spaceId, assetId);
const data = {
modelId: analysis.modelId,
data: analysis.data ?? Prisma.DbNull,
dataPath: analysis.dataPath ?? null,
vectors: analysis.vectors ? toBytes(analysis.vectors) : null,
};
return prisma.mediaAnalysis.upsert({
where: {
assetId_kind_modelVersion: {
assetId,
kind: analysis.kind,
modelVersion: analysis.modelVersion,
},
},
create: {
...data,
spaceId,
assetId,
kind: analysis.kind,
modelVersion: analysis.modelVersion,
},
update: data,
});
}
async function latestAnalysis(
spaceId: string,
assetId: string,
kind: MediaAnalysisKind,
): Promise<MediaAnalysisRow | null> {
await requireAsset(prisma, spaceId, assetId);
return prisma.mediaAnalysis.findFirst({
where: { spaceId, assetId, kind },
orderBy: [{ createdAt: "desc" }, { id: "desc" }],
});
}
/** Latest analysis of one kind per asset across the space (e.g. embeddings for clustering). */
async function latestAnalysesByKind(
spaceId: string,
kind: MediaAnalysisKind,
): Promise<Map<string, MediaAnalysisRow>> {
const rows = await prisma.mediaAnalysis.findMany({
where: { spaceId, kind },
orderBy: [{ assetId: "asc" }, { createdAt: "desc" }, { id: "desc" }],
});
const latest = new Map<string, MediaAnalysisRow>();
for (const row of rows) if (!latest.has(row.assetId)) latest.set(row.assetId, row);
return latest;
}
// -------------------------------------------------------------------------
// Segments
// -------------------------------------------------------------------------
/** Replace every segment of the asset with the result of one analysis version. */
async function replaceSegments(
spaceId: string,
assetId: string,
analysisVersion: number,
segments: NewMediaSegment[],
): Promise<number> {
for (const segment of segments) {
if (segment.endMs <= segment.startMs || segment.startMs < 0) {
throw new InvalidMediaInputError("Segment range is empty");
}
}
return prisma.$transaction(async (tx) => {
await requireAsset(tx, spaceId, assetId);
await lockMediaKey(tx, `segments:${assetId}`);
await tx.mediaSegment.deleteMany({ where: { assetId, spaceId } });
const created = await tx.mediaSegment.createMany({
data: segments.map((segment) => ({
spaceId,
assetId,
analysisVersion,
kind: segment.kind,
startMs: segment.startMs,
endMs: segment.endMs,
scores: segment.scores ?? {},
teaserScore: segment.teaserScore ?? null,
transcriptExcerpt: segment.transcriptExcerpt ?? null,
labels: segment.labels ?? [],
paidOnly: segment.paidOnly ?? false,
})),
});
return created.count;
});
}
async function listSegments(spaceId: string, assetIds: string[]): Promise<MediaSegment[]> {
if (assetIds.length === 0) return [];
const rows = await prisma.mediaSegment.findMany({
where: { spaceId, assetId: { in: [...new Set(assetIds)] } },
orderBy: [{ assetId: "asc" }, { startMs: "asc" }, { id: "asc" }],
});
return rows.map(mapMediaSegment);
}
/** All or nothing: any id outside the space fails the whole call. */
async function setSegmentsPaidOnly(
spaceId: string,
segmentIds: string[],
paidOnly: boolean,
): Promise<number> {
const ids = [...new Set(segmentIds)];
return prisma.$transaction(async (tx) => {
const result = await tx.mediaSegment.updateMany({
where: { spaceId, id: { in: ids } },
data: { paidOnly },
});
if (result.count !== ids.length) throw new IsolationError();
return result.count;
});
}
return {
createSource,
listSources,
getSource,
updateSourceSync,
removeSource,
mediaSpaceOf,
upsertSourceItems,
searchAssets,
getAsset,
getAssetRecord,
listAssetRecords,
listAssetsForClustering,
transitionIngestState,
recordProxy,
recordAnalysisVersion,
requestUpload,
assetsNeedingWork,
upsertAnalysis,
latestAnalysis,
latestAnalysesByKind,
replaceSegments,
listSegments,
setSegmentsPaidOnly,
};
}

View File

@ -0,0 +1,598 @@
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,
};
}

View File

@ -0,0 +1,462 @@
import { afterAll, beforeAll, describe, expect, it } from "vitest";
import type { MediaRepos, NewMediaPublication } from "./media.js";
import { createMediaRepos } from "./media.js";
import type { MediaTestDb } from "./media-fixtures.js";
import {
BASE_TIME,
openMediaTestDb,
sourceItem,
testBrandKit,
testEdl,
testTeaserWeights,
} from "./media-fixtures.js";
import { InvalidMediaInputError, MediaStateConflictError } from "./media-shared.js";
import { IsolationError } from "./scope.js";
const databaseUrl = process.env.DATABASE_URL;
const describePostgres =
process.env.VERIFY_DATABASE && databaseUrl ? describe.sequential : describe.skip;
const PROFILE = "example-short-vertical";
describePostgres("media production, publishing and metrics (PostgreSQL)", () => {
let db: MediaTestDb;
let media: MediaRepos;
beforeAll(() => {
db = openMediaTestDb(databaseUrl!);
media = createMediaRepos(db.prisma);
});
afterAll(async () => {
await db?.cleanup();
});
async function newProject() {
const { spaceId, userId } = await db.newUser();
const source = await media.createSource(spaceId, {
kind: "emulator",
label: "Emulator",
ownerUserId: userId,
config: {},
});
const { changedAssetIds } = await media.upsertSourceItems(spaceId, source.id, [sourceItem(1)]);
const assetId = changedAssetIds[0] as string;
const project = await media.createProject(spaceId, userId, {
title: "Launch",
goal: "teaser",
profileIds: [PROFILE, "example-paid-video"],
assetIds: [assetId],
});
return { spaceId, userId, assetId, projectId: project.id };
}
async function doneRender(spaceId: string, userId: string, projectId: string, assetId: string) {
const plan = await media.createPlanVersion(spaceId, {
projectId,
profileId: PROFILE,
edl: testEdl(assetId),
createdBy: "user",
rationale: null,
});
const render = await media.createRender(spaceId, {
planId: plan.id,
quality: "final",
createdBy: userId,
});
await media.claimRender(spaceId, render.id);
await media.updateRender(spaceId, render.id, { status: "done", progress: 1 });
return render.id;
}
const publicationInput = (renderId: string, content: string): NewMediaPublication => ({
renderId,
destination: "bundle",
targetUrl: "https://example.com/offer",
trackingUrl: `https://example.com/offer?utm_content=${content}`,
utm: { source: "shorts", medium: "video", campaign: "launch", content },
title: null,
caption: null,
tags: [],
thumbnailVariant: `thumb-${content}`,
scheduledAt: null,
});
it("validates and stores brand kits", async () => {
const { spaceId } = await db.newUser();
await expect(
media.upsertBrandKit(spaceId, { kit: { ...testBrandKit, colors: { primary: "red" } } }),
).rejects.toBeInstanceOf(InvalidMediaInputError);
const kit = await media.upsertBrandKit(spaceId, { kit: testBrandKit });
const renamed = await media.upsertBrandKit(spaceId, {
id: kit.id,
kit: { ...testBrandKit, name: "Renamed" },
});
expect(renamed).toMatchObject({ id: kit.id, kit: { name: "Renamed" } });
expect(await media.listBrandKits(spaceId)).toHaveLength(1);
await media.removeBrandKit(spaceId, kit.id);
expect(await media.listBrandKits(spaceId)).toEqual([]);
});
it("creates, pages, updates and removes projects", async () => {
const { spaceId, userId } = await db.newUser();
await expect(
media.createProject(spaceId, userId, {
title: "Bad",
goal: "teaser",
profileIds: [PROFILE],
assetIds: ["missing"],
}),
).rejects.toBeInstanceOf(IsolationError);
const created = [];
for (let index = 0; index < 5; index++) {
created.push(
await media.createProject(spaceId, userId, {
title: `Project ${index}`,
goal: "highlight",
profileIds: [PROFILE, PROFILE],
}),
);
}
expect(created[0]?.profileIds).toEqual([PROFILE]);
const seen: string[] = [];
let cursor: string | null = null;
do {
const page = await media.listProjects(spaceId, { cursor, limit: 2 });
seen.push(...page.items.map((project) => project.title));
cursor = page.nextCursor;
} while (cursor);
expect(seen).toEqual(["Project 4", "Project 3", "Project 2", "Project 1", "Project 0"]);
const updated = await media.updateProject(spaceId, created[0]?.id as string, {
title: "Renamed",
status: "review",
});
expect(updated).toMatchObject({ title: "Renamed", status: "review" });
expect((await media.listProjects(spaceId, { limit: 1 })).items[0]?.id).toBe(created[0]?.id);
await media.removeProject(spaceId, created[0]?.id as string);
await expect(media.getProject(spaceId, created[0]?.id as string)).rejects.toBeInstanceOf(
IsolationError,
);
});
it("numbers concurrent plan versions without gaps or duplicates", async () => {
const { spaceId, assetId, projectId } = await newProject();
await expect(
media.createPlanVersion(spaceId, {
projectId,
profileId: PROFILE,
edl: { edlVersion: 1 },
createdBy: "agent",
rationale: null,
}),
).rejects.toBeInstanceOf(InvalidMediaInputError);
await expect(
media.createPlanVersion(spaceId, {
projectId,
profileId: "example-paid-video",
edl: testEdl(assetId),
createdBy: "agent",
rationale: null,
}),
).rejects.toBeInstanceOf(InvalidMediaInputError);
const plans = await Promise.all(
Array.from({ length: 12 }, (_, index) =>
media.createPlanVersion(spaceId, {
projectId,
profileId: PROFILE,
edl: testEdl(assetId),
createdBy: index % 2 ? "agent" : "user",
rationale: `try ${index}`,
}),
),
);
expect(plans.map((plan) => plan.version).sort((a, b) => a - b)).toEqual(
Array.from({ length: 12 }, (_, index) => index + 1),
);
await media.createPlanVersion(spaceId, {
projectId,
profileId: "example-paid-video",
edl: testEdl(assetId, "example-paid-video"),
createdBy: "agent",
rationale: null,
});
const latest = await media.latestPlans(spaceId, projectId);
expect(latest.map((plan) => [plan.profileId, plan.version])).toEqual([
["example-paid-video", 1],
[PROFILE, 12],
]);
expect((await media.latestPlan(spaceId, projectId, PROFILE))?.version).toBe(12);
expect(await media.listPlanVersions(spaceId, projectId, PROFILE)).toHaveLength(12);
expect(latest[1]?.edl.tracks.video[0]?.speed).toBe(1);
});
it("lets exactly one worker claim a render and never revives a canceled one", async () => {
const { spaceId, userId, assetId, projectId } = await newProject();
const plan = await media.createPlanVersion(spaceId, {
projectId,
profileId: PROFILE,
edl: testEdl(assetId),
createdBy: "agent",
rationale: null,
});
const render = await media.createRender(spaceId, {
planId: plan.id,
quality: "draft",
createdBy: userId,
});
expect(render).toMatchObject({ status: "queued", projectId, profileId: PROFILE, progress: 0 });
const claims = await Promise.all(
Array.from({ length: 5 }, () => media.claimRender(spaceId, render.id)),
);
expect(claims.filter((claim) => claim !== null)).toHaveLength(1);
const progressed = await media.updateRender(spaceId, render.id, { progress: 0.4 });
expect(progressed?.progress).toBe(0.4);
await expect(
media.updateRender(spaceId, render.id, { autoFixRound: 4 }),
).rejects.toBeInstanceOf(InvalidMediaInputError);
const canceled = await media.cancelRender(spaceId, render.id);
expect(canceled.status).toBe("canceled");
expect(await media.updateRender(spaceId, render.id, { status: "done" })).toBeNull();
expect((await media.getRender(spaceId, render.id)).status).toBe("canceled");
await expect(media.cancelRender(spaceId, render.id)).rejects.toBeInstanceOf(
MediaStateConflictError,
);
const second = await media.createRender(spaceId, {
planId: plan.id,
quality: "final",
createdBy: userId,
});
await media.claimRender(spaceId, second.id);
const gated = await media.updateRender(spaceId, second.id, {
status: "blocked",
progress: 1,
autoFixRound: 3,
outputs: [
{
kind: "video",
artifactId: null,
fileName: "out.mp4",
sizeBytes: 1234,
path: "renders/x/out.mp4",
},
],
gateReport: {
profileId: PROFILE,
passed: false,
checkedAt: new Date(BASE_TIME).toISOString(),
sampledFrames: 10,
issues: [{ check: "consent", severity: "block", code: "missing_consent", message: "x" }],
autoFixRound: 3,
},
ffprobe: {
durationMs: 5000,
width: 1080,
height: 1920,
fps: 30,
videoCodec: "h264",
audioCodec: "aac",
sizeBytes: 1234,
integratedLufs: -14,
truePeakDb: -1,
tags: {},
},
});
expect(gated).toMatchObject({
status: "blocked",
outputs: [{ kind: "video", fileName: "out.mp4", sizeBytes: 1234, artifactId: null }],
gateReport: { passed: false },
ffprobe: { width: 1080 },
});
expect(gated?.outputs[0]).not.toHaveProperty("path");
const record = await media.getRenderRecord(spaceId, second.id);
expect(record.outputs).toEqual([expect.objectContaining({ path: "renders/x/out.mp4" })]);
expect(record.autoFixRound).toBe(3);
await expect(media.cancelRender(spaceId, second.id)).rejects.toBeInstanceOf(
MediaStateConflictError,
);
expect((await media.listRenders(spaceId, projectId)).map((r) => r.id)).toEqual([
second.id,
render.id,
]);
const detail = await media.getProject(spaceId, projectId);
expect(detail.plans).toHaveLength(1);
expect(detail.renders).toHaveLength(2);
});
it("publishes only finished renders and moves publications through their states", async () => {
const { spaceId, userId, assetId, projectId } = await newProject();
const plan = await media.createPlanVersion(spaceId, {
projectId,
profileId: PROFILE,
edl: testEdl(assetId),
createdBy: "agent",
rationale: null,
});
const queued = await media.createRender(spaceId, {
planId: plan.id,
quality: "final",
createdBy: userId,
});
await expect(
media.createPublication(spaceId, userId, publicationInput(queued.id, "a")),
).rejects.toBeInstanceOf(MediaStateConflictError);
const renderId = await doneRender(spaceId, userId, projectId, assetId);
const draft = await media.createPublication(spaceId, userId, publicationInput(renderId, "a"));
expect(draft).toMatchObject({ status: "draft", profileId: PROFILE, utm: { content: "a" } });
const scheduledAt = new Date(BASE_TIME + 86_400_000);
const scheduled = await media.schedulePublication(spaceId, draft.id, scheduledAt);
expect(scheduled).toMatchObject({
status: "scheduled",
scheduledAt: scheduledAt.toISOString(),
});
const bundled = await media.markPublicationBundled(spaceId, draft.id, "artifact-bundle");
expect(bundled).toMatchObject({ status: "bundled", bundleArtifactId: "artifact-bundle" });
const published = await media.markPublicationPublished(
spaceId,
draft.id,
"https://video.example.com/v/1",
);
expect(published.status).toBe("published");
expect(published.publishedAt).not.toBeNull();
await expect(media.cancelPublication(spaceId, draft.id)).rejects.toBeInstanceOf(
MediaStateConflictError,
);
const other = await media.createPublication(spaceId, userId, {
...publicationInput(renderId, "b"),
scheduledAt,
});
expect(other.status).toBe("scheduled");
expect((await media.markPublicationFailed(spaceId, other.id)).status).toBe("failed");
expect((await media.cancelPublication(spaceId, other.id)).status).toBe("canceled");
const page1 = await media.listPublications(spaceId, { projectId, limit: 1 });
expect(page1.items.map((item) => item.id)).toEqual([other.id]);
const page2 = await media.listPublications(spaceId, { projectId, cursor: page1.nextCursor });
expect(page2.items.map((item) => item.id)).toEqual([draft.id]);
expect(page2.nextCursor).toBeNull();
const elsewhere = await newProject();
expect(
(await media.listPublications(spaceId, { projectId: elsewhere.projectId })).items,
).toEqual([]);
const byContent = await media.resolvePublicationKeys(spaceId, "utm_content", ["a", "b", "z"]);
expect(Object.fromEntries(byContent)).toEqual({ a: draft.id, b: other.id });
const byUrl = await media.resolvePublicationKeys(spaceId, "external_url", [
"https://video.example.com/v/1",
]);
expect(byUrl.get("https://video.example.com/v/1")).toBe(draft.id);
const byTracking = await media.resolvePublicationKeys(spaceId, "tracking_url", [
"https://example.com/offer?utm_content=b",
]);
expect([...byTracking.values()]).toEqual([other.id]);
});
it("counts the funnel from the latest observation per source without double counting", async () => {
const { spaceId, userId, assetId, projectId } = await newProject();
const renderId = await doneRender(spaceId, userId, projectId, assetId);
const a = await media.createPublication(spaceId, userId, publicationInput(renderId, "a"));
const b = await media.createPublication(spaceId, userId, publicationInput(renderId, "b"));
const day = (n: number) => new Date(BASE_TIME + n * 86_400_000).toISOString();
const report = [
{
publicationId: a.id,
observedAt: day(1),
views: 100,
clicks: 10,
conversions: 1,
revenueCents: 500,
},
{
publicationId: a.id,
observedAt: day(2),
views: 250,
clicks: 30,
conversions: 3,
revenueCents: 1500,
},
{
publicationId: b.id,
observedAt: day(2),
views: 40,
clicks: 4,
conversions: 0,
revenueCents: 0,
},
].map((row) => ({ ...row, currency: "EUR", source: "csv_import" as const }));
expect(await media.recordMetrics(spaceId, report)).toBe(3);
expect(await media.recordMetrics(spaceId, report)).toBe(3);
await media.recordMetric(spaceId, {
publicationId: a.id,
observedAt: day(1),
views: 0,
clicks: 0,
conversions: 2,
revenueCents: 1000,
currency: "EUR",
source: "manual",
});
expect(await media.listMetrics(spaceId, a.id)).toHaveLength(3);
const rows = await media.funnel(spaceId, projectId);
const byId = new Map(rows.map((row) => [row.publicationId, row]));
expect(byId.get(a.id)).toEqual({
publicationId: a.id,
projectId,
profileId: PROFILE,
variant: "a",
thumbnailVariant: "thumb-a",
views: 250,
clicks: 30,
conversions: 5,
revenueCents: 2500,
currency: "EUR",
});
expect(byId.get(b.id)).toMatchObject({ views: 40, clicks: 4, conversions: 0 });
expect(await media.funnel(spaceId)).toHaveLength(2);
expect(await media.publicationsWithMetricsCount(spaceId)).toBe(2);
await expect(media.recordMetric(spaceId, { ...report[0]!, views: -1 })).rejects.toBeInstanceOf(
InvalidMediaInputError,
);
});
it("saves CSV mappings and the teaser model", async () => {
const { spaceId } = await db.newUser();
const mapping = {
name: "Platform export",
delimiter: ";" as const,
columns: { publication: "Link", observedAt: "Date", views: "Views", revenue: "Revenue" },
publicationKey: "tracking_url" as const,
revenueUnit: "major" as const,
defaultCurrency: "USD",
};
const saved = await media.saveMetricMapping(spaceId, mapping);
expect(saved).toMatchObject({ ...mapping, id: expect.any(String) });
const again = await media.saveMetricMapping(spaceId, { ...mapping, delimiter: "," });
expect(again.id).toBe(saved.id);
const other = await media.saveMetricMapping(spaceId, { ...mapping, name: "Other" });
await expect(
media.saveMetricMapping(spaceId, { ...mapping, id: other.id, name: "Platform export" }),
).rejects.toBeInstanceOf(InvalidMediaInputError);
expect((await media.listMetricMappings(spaceId)).map((m) => m.name)).toEqual([
"Other",
"Platform export",
]);
expect((await media.getMetricMapping(spaceId, saved.id as string)).delimiter).toBe(",");
expect(await media.getTeaserModel(spaceId)).toBeNull();
const model = await media.putTeaserModel(spaceId, testTeaserWeights, 21);
expect(model).toMatchObject({ sampleCount: 21, weights: testTeaserWeights });
expect((await media.getTeaserModel(spaceId))?.sampleCount).toBe(21);
await media.resetTeaserModel(spaceId);
expect(await media.getTeaserModel(spaceId)).toBeNull();
});
});

View File

@ -0,0 +1,490 @@
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,
};
}

View File

@ -0,0 +1,497 @@
import type {
FunnelRow,
MediaMetric,
MediaPublication,
MediaPublicationStatus,
MetricCsvMapping,
TeaserWeights,
Utm,
} from "@rakazo/contracts";
import { MetricCsvMappingSchema, TeaserWeightsSchema } from "@rakazo/contracts";
import type { MediaPublication as MediaPublicationRow, PrismaClient } from "./client.js";
import { Prisma } from "./client.js";
import type { MediaDb, MediaPage, MediaPageInput } from "./media-shared.js";
import {
decodeKeysetCursor,
InvalidMediaInputError,
isUniqueViolation,
MediaStateConflictError,
mapMediaMetric,
mapMediaPublication,
mapMetricMapping,
pageLimit,
toPage,
} from "./media-shared.js";
import { IsolationError } from "./scope.js";
export interface NewMediaPublication {
renderId: string;
destination: string;
targetUrl: string | null;
trackingUrl: string | null;
utm: Utm | null;
title: string | null;
caption: string | null;
tags: string[];
thumbnailVariant: string | null;
scheduledAt: Date | null;
}
export type PublicationKey = MetricCsvMapping["publicationKey"];
export type NewMediaMetric = Omit<MediaMetric, "id">;
export interface TeaserModelRecord {
weights: TeaserWeights;
sampleCount: number;
updatedAt: string;
}
function assertMetric(metric: NewMediaMetric): void {
for (const value of [metric.views, metric.clicks, metric.conversions, metric.revenueCents]) {
if (!Number.isInteger(value) || value < 0) {
throw new InvalidMediaInputError("Metric values must be non-negative integers");
}
}
if (!/^[A-Z]{3}$/.test(metric.currency)) throw new InvalidMediaInputError("Invalid currency");
}
/** Publications, their metrics and the learned teaser weights. */
export function createMediaPublishingRepos(prisma: PrismaClient) {
async function requireRender(spaceId: string, renderId: string) {
const row = await prisma.mediaRender.findFirst({ where: { id: renderId, spaceId } });
if (!row) throw new IsolationError();
return row;
}
async function requireProject(spaceId: string, projectId: string): Promise<void> {
const row = await prisma.mediaProject.findFirst({
where: { id: projectId, spaceId },
select: { id: true },
});
if (!row) throw new IsolationError();
}
async function requirePublication(spaceId: string, publicationId: string) {
const row = await prisma.mediaPublication.findFirst({ where: { id: publicationId, spaceId } });
if (!row) throw new IsolationError();
return row;
}
/** Only a finished render that passed its gate can be published. */
async function createPublication(
spaceId: string,
createdBy: string,
input: NewMediaPublication,
): Promise<MediaPublication> {
const render = await requireRender(spaceId, input.renderId);
if (render.status !== "done") {
throw new MediaStateConflictError("Only a finished render can be published");
}
const row = await prisma.mediaPublication.create({
data: {
spaceId,
createdBy,
renderId: render.id,
profileId: render.profileId,
destination: input.destination,
status: input.scheduledAt ? "scheduled" : "draft",
scheduledAt: input.scheduledAt,
targetUrl: input.targetUrl,
trackingUrl: input.trackingUrl,
utm: input.utm === null ? Prisma.DbNull : (input.utm as Prisma.InputJsonObject),
title: input.title,
caption: input.caption,
tags: input.tags,
thumbnailVariant: input.thumbnailVariant,
},
});
return mapMediaPublication(row);
}
async function getPublication(spaceId: string, publicationId: string): Promise<MediaPublication> {
return mapMediaPublication(await requirePublication(spaceId, publicationId));
}
async function getPublicationRecord(
spaceId: string,
publicationId: string,
): Promise<MediaPublicationRow> {
return requirePublication(spaceId, publicationId);
}
async function listPublications(
spaceId: string,
input: MediaPageInput & { projectId?: string } = {},
): Promise<MediaPage<MediaPublication>> {
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.mediaPublication.findMany({
where: {
spaceId,
...(input.projectId ? { render: { projectId: input.projectId, spaceId } } : {}),
...(cursor?.at
? {
OR: [
{ createdAt: { lt: cursor.at } },
{ createdAt: cursor.at, id: { lt: cursor.id } },
],
}
: {}),
},
orderBy: [{ createdAt: "desc" }, { id: "desc" }],
take: limit + 1,
});
return toPage(rows, limit, (row) => ({ at: row.createdAt, id: row.id }), mapMediaPublication);
}
async function transitionPublication(
spaceId: string,
publicationId: string,
from: MediaPublicationStatus[],
data: Prisma.MediaPublicationUpdateManyMutationInput,
): Promise<MediaPublication> {
const result = await prisma.mediaPublication.updateMany({
where: { id: publicationId, spaceId, status: { in: from } },
data,
});
const row = await requirePublication(spaceId, publicationId);
if (result.count !== 1) {
throw new MediaStateConflictError(`Publication is already ${row.status}`);
}
return mapMediaPublication(row);
}
async function schedulePublication(
spaceId: string,
publicationId: string,
scheduledAt: Date,
): Promise<MediaPublication> {
return transitionPublication(spaceId, publicationId, ["draft", "scheduled", "failed"], {
status: "scheduled",
scheduledAt,
});
}
async function cancelPublication(
spaceId: string,
publicationId: string,
): Promise<MediaPublication> {
return transitionPublication(
spaceId,
publicationId,
["draft", "scheduled", "bundled", "failed"],
{ status: "canceled" },
);
}
async function markPublicationBundled(
spaceId: string,
publicationId: string,
bundleArtifactId: string | null,
): Promise<MediaPublication> {
return transitionPublication(
spaceId,
publicationId,
["draft", "scheduled", "bundled", "failed"],
{ status: "bundled", bundleArtifactId },
);
}
async function markPublicationPublished(
spaceId: string,
publicationId: string,
externalUrl: string | null,
publishedAt: Date = new Date(),
): Promise<MediaPublication> {
return transitionPublication(spaceId, publicationId, ["draft", "scheduled", "bundled"], {
status: "published",
externalUrl,
publishedAt,
});
}
async function markPublicationFailed(
spaceId: string,
publicationId: string,
): Promise<MediaPublication> {
return transitionPublication(spaceId, publicationId, ["draft", "scheduled", "bundled"], {
status: "failed",
});
}
/**
* Map CSV keys to publication ids of this space. Values that match nothing (or a
* publication in another space) are absent from the result.
*/
async function resolvePublicationKeys(
spaceId: string,
key: PublicationKey,
values: string[],
): Promise<Map<string, string>> {
const wanted = new Set(values.map((value) => value.trim()).filter(Boolean));
if (wanted.size === 0) return new Map();
const rows = await prisma.mediaPublication.findMany({
where: {
spaceId,
...(key === "id" ? { id: { in: [...wanted] } } : {}),
...(key === "tracking_url" ? { trackingUrl: { in: [...wanted] } } : {}),
...(key === "external_url" ? { externalUrl: { in: [...wanted] } } : {}),
},
select: { id: true, trackingUrl: true, externalUrl: true, utm: true },
});
const resolved = new Map<string, string>();
for (const row of rows) {
const utm = row.utm as { content?: unknown } | null;
const value =
key === "id"
? row.id
: key === "tracking_url"
? row.trackingUrl
: key === "external_url"
? row.externalUrl
: typeof utm?.content === "string"
? utm.content
: null;
if (value && wanted.has(value) && !resolved.has(value)) resolved.set(value, row.id);
}
return resolved;
}
// -------------------------------------------------------------------------
// Metrics
// -------------------------------------------------------------------------
async function upsertMetric(db: MediaDb, spaceId: string, metric: NewMediaMetric) {
assertMetric(metric);
const observedAt = new Date(metric.observedAt);
if (Number.isNaN(observedAt.getTime())) throw new InvalidMediaInputError("Invalid date");
const values = {
views: metric.views,
clicks: metric.clicks,
conversions: metric.conversions,
revenueCents: metric.revenueCents,
currency: metric.currency,
};
return db.mediaMetric.upsert({
where: {
publicationId_observedAt_source: {
publicationId: metric.publicationId,
observedAt,
source: metric.source,
},
},
create: {
...values,
spaceId,
publicationId: metric.publicationId,
observedAt,
source: metric.source,
},
update: values,
});
}
/** One observation; re-recording the same (publication, time, source) replaces it. */
async function recordMetric(spaceId: string, metric: NewMediaMetric): Promise<MediaMetric> {
await requirePublication(spaceId, metric.publicationId);
return mapMediaMetric(await upsertMetric(prisma, spaceId, metric));
}
/** Batch import, all or nothing. */
async function recordMetrics(spaceId: string, metrics: NewMediaMetric[]): Promise<number> {
if (metrics.length === 0) return 0;
return prisma.$transaction(
async (tx) => {
const ids = [...new Set(metrics.map((metric) => metric.publicationId))];
const owned = await tx.mediaPublication.count({ where: { spaceId, id: { in: ids } } });
if (owned !== ids.length) throw new IsolationError();
for (const metric of metrics) await upsertMetric(tx, spaceId, metric);
return metrics.length;
},
{ timeout: 60_000 },
);
}
async function listMetrics(spaceId: string, publicationId: string): Promise<MediaMetric[]> {
await requirePublication(spaceId, publicationId);
const rows = await prisma.mediaMetric.findMany({
where: { spaceId, publicationId },
orderBy: [{ observedAt: "asc" }, { source: "asc" }],
});
return rows.map(mapMediaMetric);
}
async function saveMetricMapping(
spaceId: string,
input: MetricCsvMapping,
): Promise<MetricCsvMapping> {
const parsed = MetricCsvMappingSchema.safeParse(input);
if (!parsed.success) throw new InvalidMediaInputError("Invalid CSV mapping");
const { id, ...mapping } = parsed.data;
const json = mapping as Prisma.InputJsonObject;
try {
if (id) {
const result = await prisma.mediaMetricMapping.updateMany({
where: { id, spaceId },
data: { name: mapping.name, mapping: json },
});
if (result.count !== 1) throw new IsolationError();
return getMetricMapping(spaceId, id);
}
const row = await prisma.mediaMetricMapping.upsert({
where: { spaceId_name: { spaceId, name: mapping.name } },
create: { spaceId, name: mapping.name, mapping: json },
update: { mapping: json },
});
return mapMetricMapping(row);
} catch (error) {
if (isUniqueViolation(error)) throw new InvalidMediaInputError("Mapping name already used");
throw error;
}
}
async function listMetricMappings(spaceId: string): Promise<MetricCsvMapping[]> {
const rows = await prisma.mediaMetricMapping.findMany({
where: { spaceId },
orderBy: [{ name: "asc" }, { id: "asc" }],
});
return rows.map(mapMetricMapping);
}
async function getMetricMapping(spaceId: string, id: string): Promise<MetricCsvMapping> {
const row = await prisma.mediaMetricMapping.findFirst({ where: { id, spaceId } });
if (!row) throw new IsolationError();
return mapMetricMapping(row);
}
/**
* Views → clicks → conversions → revenue per publication. Observations are
* cumulative snapshots, so only the latest one per (publication, source) counts;
* importing the same report twice never doubles the numbers.
*/
async function funnel(spaceId: string, projectId?: string): Promise<FunnelRow[]> {
if (projectId) await requireProject(spaceId, projectId);
const rows = await prisma.$queryRaw<
Array<{
publicationId: string;
projectId: string;
profileId: string;
variant: string | null;
thumbnailVariant: string | null;
views: bigint;
clicks: bigint;
conversions: bigint;
revenueCents: bigint;
currency: string | null;
}>
>(Prisma.sql`
WITH latest AS (
SELECT DISTINCT ON (m."publicationId", m.source)
m."publicationId", m.views, m.clicks, m.conversions, m."revenueCents", m.currency
FROM media_metrics m
WHERE m."spaceId" = ${spaceId}
ORDER BY m."publicationId", m.source, m."observedAt" DESC, m.id DESC
)
SELECT p.id AS "publicationId", r."projectId", p."profileId",
p.utm ->> 'content' AS variant, p."thumbnailVariant",
coalesce(sum(l.views), 0)::bigint AS views,
coalesce(sum(l.clicks), 0)::bigint AS clicks,
coalesce(sum(l.conversions), 0)::bigint AS conversions,
coalesce(sum(l."revenueCents"), 0)::bigint AS "revenueCents",
min(l.currency) AS currency
FROM media_publications p
JOIN media_renders r ON r.id = p."renderId" AND r."spaceId" = p."spaceId"
LEFT JOIN latest l ON l."publicationId" = p.id
WHERE p."spaceId" = ${spaceId}
${projectId ? Prisma.sql`AND r."projectId" = ${projectId}` : Prisma.empty}
GROUP BY p.id, r."projectId"
ORDER BY p."createdAt" DESC, p.id DESC
`);
return rows.map((row) => ({
publicationId: row.publicationId,
projectId: row.projectId,
profileId: row.profileId,
variant: row.variant,
thumbnailVariant: row.thumbnailVariant,
views: Number(row.views),
clicks: Number(row.clicks),
conversions: Number(row.conversions),
revenueCents: Number(row.revenueCents),
currency: row.currency ?? "EUR",
}));
}
/** Publications with at least one metric: the sample size for teaser learning. */
async function publicationsWithMetricsCount(spaceId: string): Promise<number> {
return prisma.mediaPublication.count({ where: { spaceId, metrics: { some: {} } } });
}
// -------------------------------------------------------------------------
// Teaser model
// -------------------------------------------------------------------------
async function getTeaserModel(spaceId: string): Promise<TeaserModelRecord | null> {
const row = await prisma.mediaTeaserModel.findUnique({ where: { spaceId } });
if (!row) return null;
const weights = TeaserWeightsSchema.safeParse(row.weights);
if (!weights.success) return null;
return {
weights: weights.data,
sampleCount: row.sampleCount,
updatedAt: row.updatedAt.toISOString(),
};
}
async function putTeaserModel(
spaceId: string,
weights: TeaserWeights,
sampleCount: number,
): Promise<TeaserModelRecord> {
const parsed = TeaserWeightsSchema.safeParse(weights);
if (!parsed.success || !Number.isInteger(sampleCount) || sampleCount < 0) {
throw new InvalidMediaInputError("Invalid teaser model");
}
const json = parsed.data as Prisma.InputJsonObject;
const row = await prisma.mediaTeaserModel.upsert({
where: { spaceId },
create: { spaceId, weights: json, sampleCount },
update: { weights: json, sampleCount },
});
return {
weights: parsed.data,
sampleCount: row.sampleCount,
updatedAt: row.updatedAt.toISOString(),
};
}
/** Back to default weights. */
async function resetTeaserModel(spaceId: string): Promise<void> {
await prisma.mediaTeaserModel.deleteMany({ where: { spaceId } });
}
return {
createPublication,
getPublication,
getPublicationRecord,
listPublications,
schedulePublication,
cancelPublication,
markPublicationBundled,
markPublicationPublished,
markPublicationFailed,
resolvePublicationKeys,
recordMetric,
recordMetrics,
listMetrics,
saveMetricMapping,
listMetricMappings,
getMetricMapping,
funnel,
publicationsWithMetricsCount,
getTeaserModel,
putTeaserModel,
resetTeaserModel,
};
}

View File

@ -0,0 +1,525 @@
import { createHash } from "node:crypto";
import type { MediaSourceItem } from "@rakazo/adapter-kit";
import type {
AgeVerificationMethod,
AnonymizeMode,
BrandKitRecord,
ClientMediaItem,
MarkTreatment,
MediaAsset,
MediaAssetKind,
MediaAssetStorage,
MediaCollection,
MediaCollectionKind,
MediaEditPlan,
MediaFaceTrack,
MediaIngestState,
MediaLabelSource,
MediaMark,
MediaMarkKind,
MediaMetric,
MediaMetricSource,
MediaPerson,
MediaProject,
MediaProjectGoal,
MediaProjectStatus,
MediaPublication,
MediaPublicationStatus,
MediaRender,
MediaRenderOutput,
MediaRenderQuality,
MediaRenderStatus,
MediaSegment,
MediaSegmentKind,
MediaSource,
MediaSourceKind,
MediaSourceStatus,
MediaUpload,
MetricCsvMapping,
TeaserFeature,
} from "@rakazo/contracts";
import {
BrandKitSchema,
EdlSchema,
FfprobeReportSchema,
GateReportSchema,
MediaMarkPromptSchema,
MediaRenderOutputSchema,
MetricCsvMappingSchema,
UtmSchema,
} from "@rakazo/contracts";
import type {
MediaAsset as MediaAssetRow,
MediaBrandKit as MediaBrandKitRow,
MediaCollection as MediaCollectionRow,
MediaEditPlan as MediaEditPlanRow,
MediaFaceTrack as MediaFaceTrackRow,
MediaMark as MediaMarkRow,
MediaMetricMapping as MediaMetricMappingRow,
MediaMetric as MediaMetricRow,
MediaPerson as MediaPersonRow,
MediaProject as MediaProjectRow,
MediaPublication as MediaPublicationRow,
MediaRender as MediaRenderRow,
MediaSegment as MediaSegmentRow,
MediaSource as MediaSourceRow,
MediaUpload as MediaUploadRow,
PrismaClient,
} from "./client.js";
import { Prisma } from "./client.js";
import { IsolationError } from "./scope.js";
/*
* Shared pieces of the Media Studio repositories: errors, keyset cursors, and
* the row → DTO mappers. Not-found and foreign-space access both surface as
* IsolationError (from scope.ts), like every other repository.
*/
/** Input the repository refuses: bad cursor, invalid EDL or brand kit, foreign ids. */
export class InvalidMediaInputError extends Error {
constructor(message: string) {
super(message);
this.name = "InvalidMediaInputError";
}
}
/** The record exists but its state does not allow the requested change. */
export class MediaStateConflictError extends Error {
constructor(message: string) {
super(message);
this.name = "MediaStateConflictError";
}
}
export type MediaDb = Prisma.TransactionClient | PrismaClient;
/** Throws IsolationError unless every id is an asset of the space. */
export async function assertAssetsInSpace(
db: MediaDb,
spaceId: string,
assetIds: string[],
): Promise<void> {
const ids = [...new Set(assetIds)];
if (ids.length === 0) return;
const owned = await db.mediaAsset.count({ where: { spaceId, id: { in: ids } } });
if (owned !== ids.length) throw new IsolationError();
}
export const MEDIA_PAGE_MAX = 500;
export const MEDIA_PAGE_DEFAULT = 100;
export interface MediaPage<T> {
items: T[];
nextCursor: string | null;
}
export interface MediaPageInput {
cursor?: string | null;
limit?: number;
}
export function pageLimit(limit: number | undefined): number {
if (limit === undefined || !Number.isFinite(limit)) return MEDIA_PAGE_DEFAULT;
return Math.min(MEDIA_PAGE_MAX, Math.max(1, Math.trunc(limit)));
}
/**
* Position after the last row of a page ordered by `(at DESC NULLS LAST, id DESC)`.
* `at` is null for rows without a timestamp, which sort after every dated row.
*/
export interface KeysetCursor {
at: Date | null;
id: string;
}
/** Opaque base64url token for a keyset position. */
export function encodeKeysetCursor(cursor: KeysetCursor): string {
return Buffer.from(JSON.stringify([cursor.at?.toISOString() ?? null, cursor.id])).toString(
"base64url",
);
}
export function decodeKeysetCursor(token: string): KeysetCursor {
let parsed: unknown;
try {
parsed = JSON.parse(Buffer.from(token, "base64url").toString("utf8"));
} catch {
throw new InvalidMediaInputError("Invalid cursor");
}
if (!Array.isArray(parsed) || parsed.length !== 2) {
throw new InvalidMediaInputError("Invalid cursor");
}
const [at, id] = parsed as unknown[];
if (typeof id !== "string" || id.length === 0 || id.length > 100) {
throw new InvalidMediaInputError("Invalid cursor");
}
if (at === null) return { at: null, id };
if (typeof at !== "string") throw new InvalidMediaInputError("Invalid cursor");
const date = new Date(at);
if (Number.isNaN(date.getTime())) throw new InvalidMediaInputError("Invalid cursor");
return { at: date, id };
}
/**
* SQL predicate for "after the cursor" under `ORDER BY at DESC NULLS LAST, id DESC`.
* `atColumn` and `idColumn` are trusted column references.
*/
export function keysetAfterSql(
cursor: KeysetCursor | null,
atColumn: Prisma.Sql,
idColumn: Prisma.Sql,
): Prisma.Sql {
if (!cursor) return Prisma.sql`TRUE`;
if (cursor.at === null) {
return Prisma.sql`(${atColumn} IS NULL AND ${idColumn} < ${cursor.id})`;
}
return Prisma.sql`(${atColumn} < ${cursor.at} OR (${atColumn} = ${cursor.at} AND ${idColumn} < ${cursor.id}) OR ${atColumn} IS NULL)`;
}
/** Slice a `limit + 1` result into a page with its next cursor. */
export function toPage<Row, Item>(
rows: Row[],
limit: number,
keyOf: (row: Row) => KeysetCursor,
map: (row: Row) => Item,
): MediaPage<Item> {
const hasMore = rows.length > limit;
const pageRows = hasMore ? rows.slice(0, limit) : rows;
const last = pageRows.at(-1);
return {
items: pageRows.map(map),
nextCursor: hasMore && last ? encodeKeysetCursor(keyOf(last)) : null,
};
}
/** Escape LIKE wildcards so user text matches literally. */
export function likeContains(text: string): string {
return `%${text.replace(/[\\%_]/g, (char) => `\\${char}`)}%`;
}
export function isUniqueViolation(error: unknown): boolean {
return Boolean(error && typeof error === "object" && "code" in error && error.code === "P2002");
}
/** Advisory lock held until the surrounding transaction ends. */
export async function lockMediaKey(tx: Prisma.TransactionClient, key: string): Promise<void> {
await tx.$queryRaw(Prisma.sql`
SELECT pg_advisory_xact_lock(hashtextextended(${`media:${key}`}, 0))::text AS "lock"
`);
}
/** Prisma Bytes columns want an ArrayBuffer-backed view; copy when the input is a slice. */
export function toBytes(value: Uint8Array): Uint8Array<ArrayBuffer> {
return new Uint8Array(value);
}
/**
* Change detector for a client-pushed item, which has no revision of its own:
* any metadata change produces a different value.
*/
export function clientMediaItemToSourceItem(item: ClientMediaItem): MediaSourceItem {
const canonical = JSON.stringify([
item.kind,
item.capturedAt,
item.durationMs,
item.width,
item.height,
item.fps,
item.gpsLat,
item.gpsLon,
item.deviceModel,
item.albumNames,
item.isFavorite,
item.fileName,
]);
return {
externalId: item.externalId,
kind: item.kind,
capturedAt: item.capturedAt ? new Date(item.capturedAt) : null,
durationMs: item.durationMs,
width: item.width,
height: item.height,
fps: item.fps,
gpsLat: item.gpsLat,
gpsLon: item.gpsLon,
deviceModel: item.deviceModel,
albumNames: item.albumNames,
isFavorite: item.isFavorite,
fileName: item.fileName,
sizeBytes: null,
revision: createHash("sha256").update(canonical).digest("hex").slice(0, 32),
};
}
// ---------------------------------------------------------------------------
// Mappers
// ---------------------------------------------------------------------------
function iso(value: Date | null): string | null {
return value ? value.toISOString() : null;
}
export function mapMediaSource(row: MediaSourceRow, assetCount: number): MediaSource {
return {
id: row.id,
kind: row.kind as MediaSourceKind,
label: row.label,
status: row.status as MediaSourceStatus,
assetCount,
lastSyncedAt: iso(row.lastSyncedAt),
error: row.error,
createdAt: row.createdAt.toISOString(),
};
}
export function mapMediaAsset(row: MediaAssetRow): MediaAsset {
return {
id: row.id,
sourceId: row.sourceId,
kind: row.kind as MediaAssetKind,
storage: row.storage as MediaAssetStorage,
capturedAt: iso(row.capturedAt),
durationMs: row.durationMs,
width: row.width,
height: row.height,
fps: row.fps,
gpsLat: row.gpsLat,
gpsLon: row.gpsLon,
deviceModel: row.deviceModel,
albumNames: row.albumNames,
isFavorite: row.isFavorite,
ingestState: row.ingestState as MediaIngestState,
hasProxy: row.proxyPath !== null,
hasThumbnail: row.thumbPath !== null,
qualityScore: row.qualityScore,
createdAt: row.createdAt.toISOString(),
};
}
function numberRecord(value: Prisma.JsonValue): Partial<Record<TeaserFeature, number>> {
if (!value || typeof value !== "object" || Array.isArray(value)) return {};
const out: Partial<Record<TeaserFeature, number>> = {};
for (const [key, score] of Object.entries(value)) {
if (typeof score === "number" && Number.isFinite(score)) {
out[key as TeaserFeature] = score;
}
}
return out;
}
export function mapMediaSegment(row: MediaSegmentRow): MediaSegment {
return {
id: row.id,
assetId: row.assetId,
kind: row.kind as MediaSegmentKind,
startMs: row.startMs,
endMs: row.endMs,
scores: numberRecord(row.scores),
teaserScore: row.teaserScore,
transcriptExcerpt: row.transcriptExcerpt,
labels: row.labels,
paidOnly: row.paidOnly,
};
}
export interface CollectionStats {
assetCount: number;
videoCount: number;
totalDurationMs: number;
}
export const EMPTY_COLLECTION_STATS: CollectionStats = {
assetCount: 0,
videoCount: 0,
totalDurationMs: 0,
};
export function mapMediaCollection(
row: MediaCollectionRow,
stats: CollectionStats,
): MediaCollection {
return {
id: row.id,
kind: row.kind as MediaCollectionKind,
title: row.title,
labelSource: row.labelSource as MediaLabelSource,
startAt: iso(row.startAt),
endAt: iso(row.endAt),
coverAssetId: row.coverAssetId,
assetCount: stats.assetCount,
videoCount: stats.videoCount,
totalDurationMs: stats.totalDurationMs,
};
}
export function mapMediaPerson(
row: MediaPersonRow,
faceTrackCount: number,
coverThumbAssetId: string | null,
): MediaPerson {
return {
id: row.id,
displayName: row.displayName,
isSelf: row.isSelf,
confirmed: row.confirmed,
anonymizeByDefault: row.anonymizeByDefault as AnonymizeMode,
consentRecordedAt: iso(row.consentRecordedAt),
consentArtifactId: row.consentArtifactId,
ageVerifiedAt: iso(row.ageVerifiedAt),
ageVerificationMethod: row.ageVerificationMethod as AgeVerificationMethod,
faceTrackCount,
coverThumbAssetId,
};
}
export function mapMediaFaceTrack(
row: Pick<
MediaFaceTrackRow,
"id" | "assetId" | "personId" | "startMs" | "endMs" | "confidenceMin"
>,
): MediaFaceTrack {
return {
id: row.id,
assetId: row.assetId,
personId: row.personId,
startMs: row.startMs,
endMs: row.endMs,
confidenceMin: row.confidenceMin,
};
}
export function mapMediaMark(row: MediaMarkRow): MediaMark {
const prompt = row.prompt === null ? null : MediaMarkPromptSchema.safeParse(row.prompt);
return {
id: row.id,
kind: row.kind as MediaMarkKind,
label: row.label,
personId: row.personId,
assetId: row.assetId,
prompt: prompt?.success ? prompt.data : null,
treatment: row.treatment as MarkTreatment,
qualityScore: row.qualityScore,
suggestedFallback: row.suggestedFallback as MarkTreatment | null,
createdAt: row.createdAt.toISOString(),
};
}
export function mapBrandKit(row: MediaBrandKitRow): BrandKitRecord {
return {
id: row.id,
kit: BrandKitSchema.parse(row.kit),
createdAt: row.createdAt.toISOString(),
updatedAt: row.updatedAt.toISOString(),
};
}
export function mapMediaProject(row: MediaProjectRow): MediaProject {
return {
id: row.id,
title: row.title,
goal: row.goal as MediaProjectGoal,
status: row.status as MediaProjectStatus,
brandKitId: row.brandKitId,
collectionIds: row.collectionIds,
profileIds: row.profileIds,
createdAt: row.createdAt.toISOString(),
updatedAt: row.updatedAt.toISOString(),
};
}
export function mapMediaEditPlan(row: MediaEditPlanRow): MediaEditPlan {
return {
id: row.id,
projectId: row.projectId,
profileId: row.profileId,
version: row.version,
edl: EdlSchema.parse(row.edl),
createdBy: row.createdBy === "agent" ? "agent" : "user",
rationale: row.rationale,
createdAt: row.createdAt.toISOString(),
};
}
/** Public render outputs; internal paths stored next to them are dropped. */
export function renderOutputsOf(value: Prisma.JsonValue): MediaRenderOutput[] {
if (!Array.isArray(value)) return [];
const outputs: MediaRenderOutput[] = [];
for (const entry of value) {
const parsed = MediaRenderOutputSchema.safeParse(entry);
if (parsed.success) outputs.push(parsed.data);
}
return outputs;
}
export function mapMediaRender(row: MediaRenderRow): MediaRender {
const gate = row.gateReport === null ? null : GateReportSchema.safeParse(row.gateReport);
const probe = row.ffprobe === null ? null : FfprobeReportSchema.safeParse(row.ffprobe);
return {
id: row.id,
planId: row.planId,
projectId: row.projectId,
profileId: row.profileId,
quality: row.quality as MediaRenderQuality,
status: row.status as MediaRenderStatus,
progress: Math.min(1, Math.max(0, row.progress)),
outputs: renderOutputsOf(row.outputs),
gateReport: gate?.success ? gate.data : null,
ffprobe: probe?.success ? probe.data : null,
error: row.error,
createdAt: row.createdAt.toISOString(),
updatedAt: row.updatedAt.toISOString(),
};
}
export function mapMediaUpload(row: MediaUploadRow): MediaUpload {
return {
id: row.id,
assetId: row.assetId,
sizeBytes: Number(row.sizeBytes),
chunkBytes: row.chunkBytes,
receivedChunks: [...row.receivedChunks].sort((a, b) => a - b),
status: row.status as MediaUpload["status"],
};
}
export function mapMediaPublication(row: MediaPublicationRow): MediaPublication {
const utm = row.utm === null ? null : UtmSchema.safeParse(row.utm);
return {
id: row.id,
renderId: row.renderId,
profileId: row.profileId,
destination: row.destination,
status: row.status as MediaPublicationStatus,
scheduledAt: iso(row.scheduledAt),
publishedAt: iso(row.publishedAt),
externalUrl: row.externalUrl,
targetUrl: row.targetUrl,
trackingUrl: row.trackingUrl,
utm: utm?.success ? utm.data : null,
title: row.title,
caption: row.caption,
tags: row.tags,
thumbnailVariant: row.thumbnailVariant,
bundleArtifactId: row.bundleArtifactId,
createdAt: row.createdAt.toISOString(),
};
}
export function mapMediaMetric(row: MediaMetricRow): MediaMetric {
return {
id: row.id,
publicationId: row.publicationId,
observedAt: row.observedAt.toISOString(),
views: row.views,
clicks: row.clicks,
conversions: row.conversions,
revenueCents: row.revenueCents,
currency: row.currency,
source: row.source as MediaMetricSource,
};
}
export function mapMetricMapping(row: MediaMetricMappingRow): MetricCsvMapping {
const mapping = MetricCsvMappingSchema.parse(row.mapping);
return { ...mapping, id: row.id, name: row.name };
}

View File

@ -0,0 +1,163 @@
import type { MediaUpload } from "@rakazo/contracts";
import { MEDIA_UPLOAD_CHUNK_BYTES } from "@rakazo/contracts";
import type { PrismaClient } from "./client.js";
import { Prisma } from "./client.js";
import {
InvalidMediaInputError,
lockMediaKey,
MediaStateConflictError,
mapMediaUpload,
} from "./media-shared.js";
import { IsolationError } from "./scope.js";
export interface BeginMediaUpload {
assetId: string;
userId: string;
sizeBytes: number;
sha256: string;
fileName: string;
chunkBytes?: number;
}
function chunkCountOf(row: { sizeBytes: bigint; chunkBytes: number }): number {
return Number((row.sizeBytes + BigInt(row.chunkBytes) - 1n) / BigInt(row.chunkBytes));
}
/** Resumable chunked uploads of originals from client-pushed sources. */
export function createMediaUploadRepos(prisma: PrismaClient) {
/** Opens an upload, or resumes the open one for the same asset and content. */
async function beginUpload(spaceId: string, input: BeginMediaUpload): Promise<MediaUpload> {
const chunkBytes = input.chunkBytes ?? MEDIA_UPLOAD_CHUNK_BYTES;
if (!Number.isInteger(input.sizeBytes) || input.sizeBytes <= 0) {
throw new InvalidMediaInputError("Upload size must be positive");
}
if (!Number.isInteger(chunkBytes) || chunkBytes <= 0) {
throw new InvalidMediaInputError("Chunk size must be positive");
}
return prisma.$transaction(async (tx) => {
const asset = await tx.mediaAsset.findFirst({
where: { id: input.assetId, spaceId },
select: { id: true },
});
if (!asset) throw new IsolationError();
await lockMediaKey(tx, `upload:${input.assetId}`);
const open = await tx.mediaUpload.findFirst({
where: {
spaceId,
assetId: input.assetId,
status: "open",
sha256: input.sha256,
sizeBytes: BigInt(input.sizeBytes),
chunkBytes,
},
orderBy: [{ createdAt: "desc" }, { id: "desc" }],
});
if (open) return mapMediaUpload(open);
const row = await tx.mediaUpload.create({
data: {
spaceId,
assetId: input.assetId,
userId: input.userId,
sizeBytes: BigInt(input.sizeBytes),
chunkBytes,
sha256: input.sha256,
fileName: input.fileName,
},
});
return mapMediaUpload(row);
});
}
async function requireUpload(spaceId: string, uploadId: string) {
const row = await prisma.mediaUpload.findFirst({ where: { id: uploadId, spaceId } });
if (!row) throw new IsolationError();
return row;
}
async function uploadStatus(spaceId: string, uploadId: string): Promise<MediaUpload> {
return mapMediaUpload(await requireUpload(spaceId, uploadId));
}
/** Full row (sha256, file name, owner) for the upload route. */
async function getUploadRecord(spaceId: string, uploadId: string) {
return requireUpload(spaceId, uploadId);
}
/** Idempotent: the index is appended once, atomically, however often it is reported. */
async function markChunkReceived(
spaceId: string,
uploadId: string,
index: number,
): Promise<MediaUpload> {
const row = await requireUpload(spaceId, uploadId);
if (!Number.isInteger(index) || index < 0 || index >= chunkCountOf(row)) {
throw new InvalidMediaInputError("Chunk index out of range");
}
if (row.status !== "open") throw new MediaStateConflictError("Upload is not open");
await prisma.$executeRaw(Prisma.sql`
UPDATE media_uploads
SET "receivedChunks" = array_append("receivedChunks", ${index}::int), "updatedAt" = now()
WHERE id = ${uploadId} AND "spaceId" = ${spaceId} AND status = 'open'
AND NOT (${index}::int = ANY("receivedChunks"))
`);
return uploadStatus(spaceId, uploadId);
}
/**
* Closes a fully received upload and points the asset at the stored original. The
* caller has verified the assembled file against `sha256` before calling.
*/
async function completeUpload(
spaceId: string,
uploadId: string,
stored: { storagePath: string },
): Promise<MediaUpload> {
return prisma.$transaction(async (tx) => {
const row = await tx.mediaUpload.findFirst({ where: { id: uploadId, spaceId } });
if (!row) throw new IsolationError();
if (row.status === "complete") return mapMediaUpload(row);
if (row.status !== "open") throw new MediaStateConflictError("Upload is not open");
const received = new Set(row.receivedChunks);
for (let index = 0; index < chunkCountOf(row); index++) {
if (!received.has(index)) throw new MediaStateConflictError("Upload is missing chunks");
}
const updated = await tx.mediaUpload.update({
where: { id: row.id },
data: { status: "complete" },
});
await tx.mediaAsset.update({
where: { id: row.assetId },
data: {
storage: "uploaded",
storagePath: stored.storagePath,
contentHash: row.sha256,
sizeBytes: row.sizeBytes,
ingestState: "indexed",
ingestError: null,
},
});
return mapMediaUpload(updated);
});
}
async function failUpload(spaceId: string, uploadId: string): Promise<MediaUpload> {
const result = await prisma.mediaUpload.updateMany({
where: { id: uploadId, spaceId, status: "open" },
data: { status: "failed" },
});
if (result.count !== 1) {
await requireUpload(spaceId, uploadId);
throw new MediaStateConflictError("Upload is not open");
}
return uploadStatus(spaceId, uploadId);
}
return {
beginUpload,
uploadStatus,
getUploadRecord,
markChunkReceived,
completeUpload,
failUpload,
};
}

View File

@ -0,0 +1,782 @@
import { afterAll, beforeAll, describe, expect, it } from "vitest";
import type { MediaRepos } from "./media.js";
import { createMediaRepos } from "./media.js";
import type { MediaTestDb } from "./media-fixtures.js";
import { BASE_TIME, openMediaTestDb, sourceItem, vectorBytes } from "./media-fixtures.js";
import { InvalidMediaInputError, MediaStateConflictError } from "./media-shared.js";
import { IsolationError } from "./scope.js";
const databaseUrl = process.env.DATABASE_URL;
const describePostgres =
process.env.VERIFY_DATABASE && databaseUrl ? describe.sequential : describe.skip;
describePostgres("media library and organization (PostgreSQL)", () => {
let db: MediaTestDb;
let media: MediaRepos;
beforeAll(() => {
db = openMediaTestDb(databaseUrl!);
media = createMediaRepos(db.prisma);
});
afterAll(async () => {
await db?.cleanup();
});
async function newLibrary(kind: "folder" | "ios_library" = "folder") {
const user = await db.newUser();
const source = await media.createSource(user.spaceId, {
kind,
label: "Library",
ownerUserId: user.userId,
config: kind === "folder" ? { path: "/media/library" } : { deviceId: "device-1" },
});
return { ...user, sourceId: source.id };
}
it("indexes source items once and reports only real changes on the next sync", async () => {
const { spaceId, sourceId } = await newLibrary();
const items = Array.from({ length: 1000 }, (_, index) =>
sourceItem(index, { gpsLat: 48.208_49, gpsLon: 16.372_08 }),
);
const first = await media.upsertSourceItems(spaceId, sourceId, items);
expect(first).toMatchObject({ created: 1000, updated: 0, unchanged: 0 });
expect(new Set(first.changedAssetIds).size).toBe(1000);
const second = await media.upsertSourceItems(spaceId, sourceId, items);
expect(second).toEqual({ created: 0, updated: 0, unchanged: 1000, changedAssetIds: [] });
const edited = items.map((item, index) =>
index < 3 ? { ...item, revision: "r2", isFavorite: true } : item,
);
const third = await media.upsertSourceItems(spaceId, sourceId, edited);
expect(third).toMatchObject({ created: 0, updated: 3, unchanged: 997 });
expect(third.changedAssetIds).toEqual(first.changedAssetIds.slice(0, 3));
const asset = await media.getAssetRecord(spaceId, first.changedAssetIds[0] as string);
expect(asset).toMatchObject({
gpsLat: 48.21,
gpsLon: 16.37,
isFavorite: true,
revision: "r2",
storage: "referenced",
ingestState: "indexed",
});
const sources = await media.listSources(spaceId);
expect(sources).toHaveLength(1);
expect(sources[0]).toMatchObject({ id: sourceId, assetCount: 1000, status: "idle" });
});
it("serializes concurrent syncs of one source without duplicate assets", async () => {
const { spaceId, sourceId } = await newLibrary();
const items = Array.from({ length: 50 }, (_, index) => sourceItem(index));
const results = await Promise.all([
media.upsertSourceItems(spaceId, sourceId, items),
media.upsertSourceItems(spaceId, sourceId, items),
]);
expect(results.map((result) => result.created).sort()).toEqual([0, 50]);
expect(await db.prisma.mediaAsset.count({ where: { sourceId } })).toBe(50);
});
it("marks client-pushed assets as remote and waiting for their upload", async () => {
const { spaceId, sourceId } = await newLibrary("ios_library");
const { changedAssetIds } = await media.upsertSourceItems(spaceId, sourceId, [sourceItem(1)]);
const asset = await media.getAssetRecord(spaceId, changedAssetIds[0] as string);
expect(asset).toMatchObject({ storage: "remote", ingestState: "awaiting_upload" });
});
it("keyset pagination covers 1,200 assets exactly once while new assets arrive", async () => {
const { spaceId, sourceId } = await newLibrary();
// Repeated timestamps and undated assets exercise the (capturedAt, id) tie-break.
const items = Array.from({ length: 1200 }, (_, index) =>
sourceItem(index, {
capturedAt: index % 10 === 0 ? null : new Date(BASE_TIME + Math.floor(index / 4) * 1000),
}),
);
const { changedAssetIds } = await media.upsertSourceItems(spaceId, sourceId, items);
const original = new Set(changedAssetIds);
const seen: string[] = [];
const order: Array<number | null> = [];
let cursor: string | null = null;
let page = 0;
do {
const result = await media.searchAssets(spaceId, { cursor, limit: 97 });
for (const asset of result.items) {
seen.push(asset.id);
order.push(asset.capturedAt ? Date.parse(asset.capturedAt) : null);
}
cursor = result.nextCursor;
page += 1;
// Newer, older and undated assets land between page reads.
await media.upsertSourceItems(spaceId, sourceId, [
sourceItem(10_000 + page * 3, { capturedAt: new Date(BASE_TIME + 10_000_000 + page) }),
sourceItem(10_001 + page * 3, { capturedAt: new Date(BASE_TIME - 10_000_000 - page) }),
sourceItem(10_002 + page * 3, { capturedAt: null }),
]);
} while (cursor);
expect(new Set(seen).size).toBe(seen.length);
for (const id of original) expect(seen).toContain(id);
// Order: capturedAt descending, undated last.
const firstNull = order.indexOf(null);
expect(order.slice(firstNull).every((value) => value === null)).toBe(true);
const dated = order.slice(0, firstNull) as number[];
for (let index = 1; index < dated.length; index++) {
expect(dated[index - 1]).toBeGreaterThanOrEqual(dated[index] as number);
}
await expect(media.searchAssets(spaceId, { cursor: "not-a-cursor" })).rejects.toBeInstanceOf(
InvalidMediaInputError,
);
});
it("filters search by kind, time, favorites, quality, place, text, person and collection", async () => {
const { spaceId, sourceId } = await newLibrary();
const { changedAssetIds } = await media.upsertSourceItems(spaceId, sourceId, [
sourceItem(1, {
kind: "video",
gpsLat: 48.2085,
gpsLon: 16.3721,
albumNames: ["Summer Trip"],
isFavorite: true,
}),
sourceItem(2, { kind: "photo", gpsLat: 48.137, gpsLon: 11.575, fileName: "beach_day.jpg" }),
sourceItem(3, { kind: "video", gpsLat: 48.25, gpsLon: 16.4 }),
sourceItem(4, { kind: "video", capturedAt: null, gpsLat: -16.5, gpsLon: 179.99 }),
sourceItem(5, { kind: "photo", gpsLat: -16.5, gpsLon: -179.99, fileName: "100%_done.jpg" }),
]);
const [vienna, munich, nearVienna, dateline, otherSide] = changedAssetIds as [
string,
string,
string,
string,
string,
];
const ids = async (query: Parameters<MediaRepos["searchAssets"]>[1]) =>
(await media.searchAssets(spaceId, query)).items.map((asset) => asset.id).sort();
expect(await ids({ kinds: ["photo"] })).toEqual([munich, otherSide].sort());
expect(await ids({ favoritesOnly: true })).toEqual([vienna]);
expect(
await ids({
from: new Date(BASE_TIME + 2 * 60_000).toISOString(),
to: new Date(BASE_TIME + 3 * 60_000).toISOString(),
}),
).toEqual([munich, nearVienna].sort());
expect(await ids({ near: { lat: 48.2082, lon: 16.3738, radiusKm: 2 } })).toEqual([vienna]);
expect(await ids({ near: { lat: 48.2082, lon: 16.3738, radiusKm: 10 } })).toEqual(
[vienna, nearVienna].sort(),
);
expect(await ids({ near: { lat: -16.5, lon: 180, radiusKm: 5 } })).toEqual(
[dateline, otherSide].sort(),
);
await media.recordAnalysisVersion(spaceId, munich, 1, 0.9);
await media.recordAnalysisVersion(spaceId, vienna, 1, 0.3);
expect(await ids({ minQuality: 0.5 })).toEqual([munich]);
expect(await ids({ text: "summer" })).toEqual([vienna]);
expect(await ids({ text: "BEACH_DAY" })).toEqual([munich]);
expect(await ids({ text: "100%" })).toEqual([otherSide]);
expect(await ids({ text: "%" })).toEqual([otherSide]);
await media.replaceSegments(spaceId, nearVienna, 1, [
{ kind: "shot", startMs: 0, endMs: 1000, labels: ["Sunset"], transcriptExcerpt: "welcome" },
]);
expect(await ids({ text: "sunset" })).toEqual([nearVienna]);
expect(await ids({ text: "WELCOME" })).toEqual([nearVienna]);
const collection = await media.createManualCollection(spaceId, "Harbour walk", [dateline]);
expect(await ids({ text: "harbour" })).toEqual([dateline]);
expect(await ids({ collectionId: collection.id })).toEqual([dateline]);
const [track] = await media.replaceFaceTracks(spaceId, munich, "faces-1", [
{ startMs: 0, endMs: 1000, confidenceMin: 0.8, keyframesPath: "k.json", embedding: null },
]);
const [person] = await media.createPersonSuggestions(spaceId, [
{ centroid: vectorBytes([1, 2]), trackIds: [track?.id as string] },
]);
expect(await ids({ personId: person?.id as string })).toEqual([munich]);
expect(await ids({ sourceId })).toHaveLength(5);
});
it("returns asset details and moves ingest state only from the expected state", async () => {
const { spaceId, sourceId } = await newLibrary();
const { changedAssetIds } = await media.upsertSourceItems(spaceId, sourceId, [sourceItem(1)]);
const assetId = changedAssetIds[0] as string;
const claims = await Promise.all([
media.transitionIngestState(spaceId, assetId, { from: ["indexed"], to: "proxying" }),
media.transitionIngestState(spaceId, assetId, { from: ["indexed"], to: "proxying" }),
]);
expect(claims.filter(Boolean)).toHaveLength(1);
expect((await media.assetsNeedingWork(spaceId, "proxying", 10)).map((row) => row.id)).toEqual([
assetId,
]);
const proxied = await media.recordProxy(spaceId, assetId, {
contentHash: "f".repeat(64),
proxyPath: "proxy.mp4",
thumbPath: "thumb.jpg",
durationMs: 12_000,
});
expect(proxied).toMatchObject({ ingestState: "proxied", hasProxy: true, hasThumbnail: true });
expect(
await media.transitionIngestState(spaceId, assetId, {
from: ["proxied"],
to: "failed",
error: "sidecar offline",
}),
).toBe(true);
expect(await media.getAssetRecord(spaceId, assetId)).toMatchObject({
ingestState: "failed",
ingestError: "sidecar offline",
durationMs: 12_000,
});
await media.upsertAnalysis(spaceId, assetId, {
kind: "transcript",
modelId: "whisper",
modelVersion: "1",
data: { language: "en", words: [{ text: "hello" }, { text: "there" }] },
});
await media.replaceSegments(spaceId, assetId, 1, [
{ kind: "scene", startMs: 2000, endMs: 4000, scores: { motion: 0.5 } },
{ kind: "shot", startMs: 0, endMs: 2000, teaserScore: 0.7 },
]);
await media.replaceFaceTracks(spaceId, assetId, "faces-1", [
{ startMs: 0, endMs: 500, confidenceMin: 0.6, keyframesPath: "a.json", embedding: null },
]);
const detail = await media.getAsset(spaceId, assetId);
expect(detail.transcript).toBe("hello there");
expect(detail.segments.map((segment) => segment.startMs)).toEqual([0, 2000]);
expect(detail.segments[1]?.scores).toEqual({ motion: 0.5 });
expect(detail.faceTracks).toHaveLength(1);
expect(await media.recordAnalysisVersion(spaceId, assetId, 2)).toMatchObject({
ingestState: "analyzed",
});
});
it("keeps each analysis model version and returns the latest per kind", async () => {
const { spaceId, sourceId } = await newLibrary();
const { changedAssetIds } = await media.upsertSourceItems(spaceId, sourceId, [sourceItem(1)]);
const assetId = changedAssetIds[0] as string;
const v1 = await media.upsertAnalysis(spaceId, assetId, {
kind: "embeddings",
modelId: "siglip",
modelVersion: "1",
vectors: vectorBytes([1, 2, 3]),
});
const again = await media.upsertAnalysis(spaceId, assetId, {
kind: "embeddings",
modelId: "siglip",
modelVersion: "1",
vectors: vectorBytes([4, 5, 6]),
});
expect(again.id).toBe(v1.id);
await new Promise((resolve) => setTimeout(resolve, 5));
await media.upsertAnalysis(spaceId, assetId, {
kind: "embeddings",
modelId: "siglip",
modelVersion: "2",
vectors: vectorBytes([7, 8, 9]),
});
expect(await db.prisma.mediaAnalysis.count({ where: { assetId } })).toBe(2);
const latest = await media.latestAnalysis(spaceId, assetId, "embeddings");
expect(latest?.modelVersion).toBe("2");
expect([...new Float32Array(new Uint8Array(latest?.vectors as Uint8Array).buffer)]).toEqual([
7, 8, 9,
]);
expect(
(await media.latestAnalysesByKind(spaceId, "embeddings")).get(assetId)?.modelVersion,
).toBe("2");
});
it("replaces segments atomically and flags paid-only segments all or nothing", async () => {
const { spaceId, sourceId } = await newLibrary();
const { changedAssetIds } = await media.upsertSourceItems(spaceId, sourceId, [sourceItem(1)]);
const assetId = changedAssetIds[0] as string;
await media.replaceSegments(spaceId, assetId, 1, [
{ kind: "shot", startMs: 0, endMs: 1000 },
{ kind: "shot", startMs: 1000, endMs: 2000 },
]);
await expect(
media.replaceSegments(spaceId, assetId, 2, [
{ kind: "shot", startMs: 0, endMs: 1000 },
{ kind: "shot", startMs: 1000, endMs: 1000 },
]),
).rejects.toBeInstanceOf(InvalidMediaInputError);
expect(await media.listSegments(spaceId, [assetId])).toHaveLength(2);
await media.replaceSegments(spaceId, assetId, 2, [{ kind: "scene", startMs: 0, endMs: 3000 }]);
const segments = await media.listSegments(spaceId, [assetId]);
expect(segments).toHaveLength(1);
const segmentId = segments[0]?.id as string;
await expect(
media.setSegmentsPaidOnly(spaceId, [segmentId, "missing"], true),
).rejects.toBeInstanceOf(IsolationError);
expect((await media.listSegments(spaceId, [assetId]))[0]?.paidOnly).toBe(false);
expect(await media.setSegmentsPaidOnly(spaceId, [segmentId], true)).toBe(1);
expect((await media.listSegments(spaceId, [assetId]))[0]?.paidOnly).toBe(true);
});
it("re-clustering keeps user titles and manual collections and drops stale clusters", async () => {
const { spaceId, sourceId } = await newLibrary();
const { changedAssetIds } = await media.upsertSourceItems(
spaceId,
sourceId,
Array.from({ length: 6 }, (_, index) => sourceItem(index + 1)),
);
const [a1, a2, a3, a4, a5, a6] = changedAssetIds as string[];
const cluster = (key: string, title: string, assetIds: string[]) => ({
key,
title,
labelSource: "rule" as const,
startAt: new Date(BASE_TIME),
endAt: new Date(BASE_TIME + 1000),
centroid: { lat: 48.20849, lon: 16.37208 },
coverAssetId: assetIds[0] ?? null,
assetIds,
});
expect(
await media.replaceClusters(spaceId, "session", [
cluster("s1", "Morning", [a1 as string, a2 as string]),
cluster("s2", "Noon", [a3 as string]),
cluster("s3", "Evening", [a4 as string]),
]),
).toEqual({ created: 3, updated: 0, deleted: 0 });
const manual = await media.createManualCollection(spaceId, "Favourites", [
a6 as string,
a1 as string,
]);
const first = (await media.listCollections(spaceId, { kind: "session" })).items;
const morning = first.find((collection) => collection.title === "Morning");
const evening = first.find((collection) => collection.title === "Evening");
await media.renameCollection(spaceId, morning?.id as string, "Market visit");
await media.renameCollection(spaceId, evening?.id as string, "Dinner");
expect(
await media.replaceClusters(spaceId, "session", [
cluster("s1", "Morning again", [a1 as string, a3 as string, a5 as string]),
cluster("s4", "Night", [a2 as string]),
]),
).toEqual({ created: 1, updated: 1, deleted: 1 });
const after = await media.listCollections(spaceId, {});
const byTitle = new Map(after.items.map((collection) => [collection.title, collection]));
expect([...byTitle.keys()].sort()).toEqual(
["Dinner", "Favourites", "Market visit", "Night"].sort(),
);
expect(byTitle.get("Market visit")).toMatchObject({
id: morning?.id,
labelSource: "user",
assetCount: 3,
videoCount: 2,
totalDurationMs: 20_000,
});
const manualAfter = await media.getCollection(spaceId, manual.id);
expect(manualAfter.collection).toMatchObject({ kind: "manual", title: "Favourites" });
expect(manualAfter.assets.map((asset) => asset.id)).toEqual([a1, a6]);
const stored = await db.prisma.mediaCollection.findUniqueOrThrow({
where: { id: morning?.id as string },
});
expect(stored).toMatchObject({ centroidLat: 48.21, centroidLon: 16.37 });
await expect(
media.replaceClusters(spaceId, "session", [cluster("x", "Bad", ["foreign-asset"])]),
).rejects.toBeInstanceOf(IsolationError);
});
it("lists, merges, splits and creates collections", async () => {
const { spaceId, sourceId } = await newLibrary();
const { changedAssetIds } = await media.upsertSourceItems(
spaceId,
sourceId,
Array.from({ length: 5 }, (_, index) => sourceItem(index + 1)),
);
const ids = changedAssetIds as string[];
const clusters = ids.map((assetId, index) => ({
key: `t${index}`,
title: `Theme ${index}`,
labelSource: "model" as const,
startAt: index === 4 ? null : new Date(BASE_TIME + index * 1000),
endAt: null,
centroid: null,
coverAssetId: assetId,
assetIds: [assetId],
}));
await media.replaceClusters(spaceId, "theme", clusters);
const seen: string[] = [];
let cursor: string | null = null;
do {
const page = await media.listCollections(spaceId, { kind: "theme", cursor, limit: 2 });
seen.push(...page.items.map((collection) => collection.title));
cursor = page.nextCursor;
} while (cursor);
expect(seen).toEqual(["Theme 3", "Theme 2", "Theme 1", "Theme 0", "Theme 4"]);
const all = (await media.listCollections(spaceId, { kind: "theme" })).items;
const merged = await media.mergeCollections(
spaceId,
all.slice(0, 3).map((collection) => collection.id),
);
expect(merged).toMatchObject({ id: all[0]?.id, assetCount: 3, labelSource: "user" });
expect((await media.listCollections(spaceId, { kind: "theme" })).items).toHaveLength(3);
const mergedAssets = (await media.getCollection(spaceId, merged.id)).assets.map(
(asset) => asset.id,
);
expect(mergedAssets).toEqual([ids[1], ids[2], ids[3]]);
await expect(
media.splitCollection(spaceId, merged.id, ids[1] as string),
).rejects.toBeInstanceOf(InvalidMediaInputError);
const [kept, split] = await media.splitCollection(spaceId, merged.id, ids[2] as string);
expect(kept).toMatchObject({ id: merged.id, assetCount: 1 });
expect(split).toMatchObject({ assetCount: 2, kind: "theme" });
expect(
await media.collectionAssetIds(spaceId, [split?.id as string, kept?.id as string]),
).toEqual([ids[2], ids[3], ids[1]]);
});
it("suggests, merges and assigns people and records consent only for a member", async () => {
const { spaceId, userId, sourceId } = await newLibrary();
const { changedAssetIds } = await media.upsertSourceItems(spaceId, sourceId, [
sourceItem(1),
sourceItem(2),
]);
const [assetA, assetB] = changedAssetIds as [string, string];
const tracksA = await media.replaceFaceTracks(spaceId, assetA, "faces-1", [
{
startMs: 0,
endMs: 1000,
confidenceMin: 0.9,
keyframesPath: "a1",
embedding: vectorBytes([1, 0]),
},
{
startMs: 0,
endMs: 1000,
confidenceMin: 0.7,
keyframesPath: "a2",
embedding: vectorBytes([0, 1]),
},
]);
const tracksB = await media.replaceFaceTracks(spaceId, assetB, "faces-1", [
{
startMs: 0,
endMs: 1000,
confidenceMin: 0.8,
keyframesPath: "b1",
embedding: vectorBytes([1, 0]),
},
]);
expect(await media.faceTrackEmbeddings(spaceId, { unassignedOnly: true })).toHaveLength(3);
const people = await media.createPersonSuggestions(spaceId, [
{
centroid: vectorBytes([1, 0]),
trackIds: [tracksA[0]?.id as string, tracksB[0]?.id as string],
},
{ centroid: vectorBytes([0, 1]), trackIds: [tracksA[1]?.id as string] },
]);
expect(people.map((person) => person.displayName)).toEqual(["Person 1", "Person 2"]);
expect(people[0]).toMatchObject({
faceTrackCount: 2,
confirmed: false,
coverThumbAssetId: assetA,
});
expect(await media.faceTrackEmbeddings(spaceId, { unassignedOnly: true })).toHaveLength(0);
expect(await media.personCentroids(spaceId)).toHaveLength(2);
const [first, second] = people as [(typeof people)[0], (typeof people)[0]];
await media.updatePerson(spaceId, first.id, { isSelf: true });
await media.updatePerson(spaceId, second.id, { isSelf: true, displayName: "Me" });
const listed = await media.listPeople(spaceId);
expect(listed.filter((person) => person.isSelf).map((person) => person.id)).toEqual([
second.id,
]);
const mark = await media.createMark(spaceId, {
kind: "tattoo",
label: null,
personId: second.id,
assetId: assetA,
prompt: { tMs: 0, box: { x: 0.1, y: 0.1, w: 0.2, h: 0.2 } },
treatment: "inpaint",
});
const merged = await media.mergePeople(spaceId, [first.id, second.id]);
expect(merged).toMatchObject({ id: first.id, faceTrackCount: 3, confirmed: true });
expect((await media.getMark(spaceId, mark.id)).personId).toBe(first.id);
expect(await media.listPeople(spaceId)).toHaveLength(1);
const reassigned = await media.assignTrack(spaceId, tracksB[0]?.id as string, null);
expect(reassigned.personId).toBeNull();
await expect(
media.assignTrack(spaceId, tracksB[0]?.id as string, "missing-person"),
).rejects.toBeInstanceOf(IsolationError);
const consent = {
personId: first.id,
consentArtifactId: "artifact-1",
consentRecordedAt: new Date(BASE_TIME).toISOString(),
ageVerificationMethod: "document_on_file" as const,
ageVerifiedAt: new Date(BASE_TIME).toISOString(),
};
await expect(
media.recordConsent({ spaceId, recordedByUserId: "" }, consent),
).rejects.toBeInstanceOf(InvalidMediaInputError);
await expect(
media.recordConsent({ spaceId, recordedByUserId: "not-a-member" }, consent),
).rejects.toBeInstanceOf(IsolationError);
await expect(
media.recordConsent(
{ spaceId, recordedByUserId: userId },
{ ...consent, ageVerifiedAt: null },
),
).rejects.toBeInstanceOf(InvalidMediaInputError);
const withConsent = await media.recordConsent({ spaceId, recordedByUserId: userId }, consent);
expect(withConsent).toMatchObject({
consentArtifactId: "artifact-1",
ageVerificationMethod: "document_on_file",
consentRecordedAt: consent.consentRecordedAt,
});
const record = await db.prisma.mediaPerson.findUniqueOrThrow({ where: { id: first.id } });
expect(record.consentRecordedBy).toBe(userId);
});
it("removing a person leaves no biometric bytes behind", async () => {
const { spaceId, sourceId } = await newLibrary();
const { changedAssetIds } = await media.upsertSourceItems(spaceId, sourceId, [sourceItem(1)]);
const assetId = changedAssetIds[0] as string;
const tracks = await media.replaceFaceTracks(spaceId, assetId, "faces-1", [
{
startMs: 0,
endMs: 1000,
confidenceMin: 0.9,
keyframesPath: "k1",
embedding: vectorBytes([1, 2]),
},
{
startMs: 0,
endMs: 1000,
confidenceMin: 0.9,
keyframesPath: "k2",
embedding: vectorBytes([3, 4]),
},
{
startMs: 0,
endMs: 1000,
confidenceMin: 0.9,
keyframesPath: "k3",
embedding: vectorBytes([5, 6]),
},
]);
const [person, bystander] = await media.createPersonSuggestions(spaceId, [
{
centroid: vectorBytes([2, 3]),
trackIds: [tracks[0]?.id as string, tracks[1]?.id as string],
},
{ centroid: vectorBytes([5, 6]), trackIds: [tracks[2]?.id as string] },
]);
const personId = person?.id as string;
const mark = await media.createMark(spaceId, {
kind: "birthmark",
label: null,
personId,
assetId,
prompt: { tMs: 0 },
treatment: "blur",
});
await media.recordMarkResult(spaceId, mark.id, {
qualityScore: 0.4,
suggestedFallback: "cover",
maskDir: "masks/m1",
});
const assetMark = await media.createMark(spaceId, {
kind: "license_plate",
label: null,
personId: null,
assetId,
prompt: { tMs: 0 },
treatment: "blur",
});
const removed = await media.removePersonCompletely(spaceId, personId);
expect(removed).toEqual({
deletedFaceTracks: 2,
deletedMarks: 1,
keyframesPaths: expect.arrayContaining(["k1", "k2"]),
maskDirs: ["masks/m1"],
});
const people = await db.prisma.$queryRaw<Array<{ id: string; centroid: Uint8Array | null }>>`
SELECT id, centroid FROM media_people WHERE "spaceId" = ${spaceId}`;
expect(people).toEqual([{ id: bystander?.id, centroid: expect.anything() }]);
const personRows = await db.prisma.$queryRaw<Array<{ count: number }>>`
SELECT count(*)::int AS count FROM media_people WHERE id = ${personId}`;
expect(personRows[0]?.count).toBe(0);
const linked = await db.prisma.$queryRaw<Array<{ count: number }>>`
SELECT count(*)::int AS count FROM media_face_tracks
WHERE "personId" = ${personId} OR id IN (${tracks[0]?.id}, ${tracks[1]?.id})`;
expect(linked[0]?.count).toBe(0);
const embeddings = await db.prisma.$queryRaw<Array<{ id: string }>>`
SELECT id FROM media_face_tracks WHERE "spaceId" = ${spaceId} AND embedding IS NOT NULL`;
expect(embeddings.map((row) => row.id)).toEqual([tracks[2]?.id]);
const marks = await db.prisma.$queryRaw<Array<{ id: string }>>`
SELECT id FROM media_marks WHERE "spaceId" = ${spaceId}`;
expect(marks.map((row) => row.id)).toEqual([assetMark.id]);
await expect(media.removePersonCompletely(spaceId, personId)).rejects.toBeInstanceOf(
IsolationError,
);
});
it("manages marks per asset and per person", async () => {
const { spaceId, sourceId } = await newLibrary();
const { changedAssetIds } = await media.upsertSourceItems(spaceId, sourceId, [
sourceItem(1),
sourceItem(2),
]);
const [assetA, assetB] = changedAssetIds as [string, string];
const [trackB] = await media.replaceFaceTracks(spaceId, assetB, "faces-1", [
{ startMs: 0, endMs: 1000, confidenceMin: 0.9, keyframesPath: "k", embedding: null },
]);
const [person] = await media.createPersonSuggestions(spaceId, [
{ centroid: vectorBytes([1]), trackIds: [trackB?.id as string] },
]);
const personMark = await media.createMark(spaceId, {
kind: "tattoo",
label: "left forearm",
personId: person?.id as string,
assetId: assetA,
prompt: { tMs: 500, point: { x: 0.2, y: 0.3 } },
treatment: "inpaint",
});
const plate = await media.createMark(spaceId, {
kind: "license_plate",
label: null,
personId: null,
assetId: assetB,
prompt: { tMs: 0, box: { x: 0, y: 0, w: 0.1, h: 0.1 } },
treatment: "blur",
});
expect((await media.listMarks(spaceId, { personId: person?.id })).map((m) => m.id)).toEqual([
personMark.id,
]);
expect((await media.listMarks(spaceId, { assetId: assetB })).map((m) => m.id)).toEqual([
plate.id,
]);
// Person marks follow the person into every asset they appear in.
expect((await media.marksForAsset(spaceId, assetB)).map((m) => m.id).sort()).toEqual(
[personMark.id, plate.id].sort(),
);
const updated = await media.updateMark(spaceId, plate.id, { treatment: "cover", label: "car" });
expect(updated).toMatchObject({ treatment: "cover", label: "car" });
const result = await media.recordMarkResult(spaceId, personMark.id, {
qualityScore: 0.2,
suggestedFallback: "cover",
maskDir: "masks/tattoo",
});
expect(result).toMatchObject({
qualityScore: 0.2,
suggestedFallback: "cover",
maskDir: "masks/tattoo",
prompt: { tMs: 500, point: { x: 0.2, y: 0.3 } },
});
expect(await media.removeMark(spaceId, personMark.id)).toEqual({ maskDir: "masks/tattoo" });
expect(await media.listMarks(spaceId)).toHaveLength(1);
});
it("uploads chunks idempotently, resumes, and completes only when every chunk arrived", async () => {
const { spaceId, userId, sourceId } = await newLibrary("ios_library");
const { changedAssetIds } = await media.upsertSourceItems(spaceId, sourceId, [sourceItem(1)]);
const assetId = changedAssetIds[0] as string;
const begin = {
assetId,
userId,
sizeBytes: 10,
sha256: "c".repeat(64),
fileName: "clip.mov",
chunkBytes: 4,
};
const upload = await media.beginUpload(spaceId, begin);
expect(upload).toMatchObject({
sizeBytes: 10,
chunkBytes: 4,
receivedChunks: [],
status: "open",
});
expect((await media.beginUpload(spaceId, begin)).id).toBe(upload.id);
await Promise.all(
Array.from({ length: 6 }, () => media.markChunkReceived(spaceId, upload.id, 1)),
);
await media.markChunkReceived(spaceId, upload.id, 0);
expect((await media.uploadStatus(spaceId, upload.id)).receivedChunks).toEqual([0, 1]);
await expect(media.markChunkReceived(spaceId, upload.id, 3)).rejects.toBeInstanceOf(
InvalidMediaInputError,
);
await expect(
media.completeUpload(spaceId, upload.id, { storagePath: "uploads/clip.mov" }),
).rejects.toBeInstanceOf(MediaStateConflictError);
await media.markChunkReceived(spaceId, upload.id, 2);
const done = await media.completeUpload(spaceId, upload.id, {
storagePath: "uploads/clip.mov",
});
expect(done).toMatchObject({ status: "complete", receivedChunks: [0, 1, 2] });
expect(await media.getAssetRecord(spaceId, assetId)).toMatchObject({
storage: "uploaded",
storagePath: "uploads/clip.mov",
contentHash: "c".repeat(64),
ingestState: "indexed",
});
await expect(media.markChunkReceived(spaceId, upload.id, 0)).rejects.toBeInstanceOf(
MediaStateConflictError,
);
expect((await media.beginUpload(spaceId, begin)).id).not.toBe(upload.id);
});
it("requests uploads only for remote assets", async () => {
const remote = await newLibrary("ios_library");
const { changedAssetIds } = await media.upsertSourceItems(remote.spaceId, remote.sourceId, [
sourceItem(1),
]);
const remoteId = changedAssetIds[0] as string;
await media.transitionIngestState(remote.spaceId, remoteId, {
from: ["awaiting_upload"],
to: "failed",
});
expect(await media.requestUpload(remote.spaceId, [remoteId])).toEqual([remoteId]);
const folder = await newLibrary();
const local = await media.upsertSourceItems(folder.spaceId, folder.sourceId, [sourceItem(1)]);
expect(await media.requestUpload(folder.spaceId, local.changedAssetIds)).toEqual([]);
});
it("updates and removes a source with everything indexed from it", async () => {
const { spaceId, sourceId } = await newLibrary();
const { changedAssetIds } = await media.upsertSourceItems(spaceId, sourceId, [sourceItem(1)]);
const synced = await media.updateSourceSync(spaceId, sourceId, {
status: "syncing",
lastSyncCursor: "page-2",
lastSyncedAt: new Date(BASE_TIME),
error: null,
});
expect(synced).toMatchObject({
status: "syncing",
lastSyncCursor: "page-2",
config: { path: "/media/library" },
assetCount: 1,
});
await media.replaceSegments(spaceId, changedAssetIds[0] as string, 1, [
{ kind: "shot", startMs: 0, endMs: 1 },
]);
await media.removeSource(spaceId, sourceId);
expect(await media.listSources(spaceId)).toEqual([]);
expect(await db.prisma.mediaAsset.count({ where: { sourceId } })).toBe(0);
expect(await db.prisma.mediaSegment.count({ where: { spaceId } })).toBe(0);
});
});

View File

@ -0,0 +1,272 @@
import type { ClientMediaItem } from "@rakazo/contracts";
import { describe, expect, it } from "vitest";
import type {
MediaAsset as MediaAssetRow,
MediaMark as MediaMarkRow,
MediaRender as MediaRenderRow,
MediaUpload as MediaUploadRow,
} from "./client.js";
import {
clientMediaItemToSourceItem,
decodeKeysetCursor,
encodeKeysetCursor,
InvalidMediaInputError,
likeContains,
mapMediaAsset,
mapMediaMark,
mapMediaRender,
mapMediaSegment,
mapMediaUpload,
pageLimit,
toPage,
} from "./media.js";
const at = new Date("2026-01-01T08:00:00.000Z");
describe("media keyset cursors", () => {
it("round-trips dated and undated positions as opaque base64url", () => {
for (const cursor of [
{ at, id: "asset-1" },
{ at: null, id: "asset-2" },
]) {
const token = encodeKeysetCursor(cursor);
expect(token).toMatch(/^[A-Za-z0-9_-]+$/);
expect(decodeKeysetCursor(token)).toEqual(cursor);
}
});
it("rejects tampered or malformed cursors", () => {
const bad = [
"not json",
Buffer.from("{}").toString("base64url"),
Buffer.from(JSON.stringify(["2026-01-01T00:00:00Z"])).toString("base64url"),
Buffer.from(JSON.stringify(["yesterday", "id"])).toString("base64url"),
Buffer.from(JSON.stringify([null, ""])).toString("base64url"),
Buffer.from(JSON.stringify([42, "id"])).toString("base64url"),
];
for (const token of bad) {
expect(() => decodeKeysetCursor(token), token).toThrow(InvalidMediaInputError);
}
});
it("slices a limit+1 result into a page with the last row as cursor", () => {
const rows = [
{ id: "c", at },
{ id: "b", at: null },
{ id: "a", at: null },
];
const page = toPage(
rows,
2,
(row) => ({ at: row.at, id: row.id }),
(row) => row.id,
);
expect(page.items).toEqual(["c", "b"]);
expect(decodeKeysetCursor(page.nextCursor as string)).toEqual({ at: null, id: "b" });
expect(
toPage(
rows,
3,
(row) => ({ at: row.at, id: row.id }),
(row) => row.id,
).nextCursor,
).toBe(null);
});
it("clamps page sizes and escapes LIKE wildcards", () => {
expect(pageLimit(undefined)).toBe(100);
expect(pageLimit(0)).toBe(1);
expect(pageLimit(10_000)).toBe(500);
expect(likeContains("50%_off\\")).toBe("%50\\%\\_off\\\\%");
});
});
describe("media DTO mappers", () => {
const asset: MediaAssetRow = {
id: "asset-1",
spaceId: "space-1",
sourceId: "source-1",
externalId: "a.mp4",
revision: "r1",
contentHash: "hash",
kind: "video",
storage: "referenced",
storagePath: "originals/a.mp4",
fileName: "a.mp4",
sizeBytes: 10n,
capturedAt: at,
durationMs: 1000,
width: 1920,
height: 1080,
fps: 30,
codec: "h264",
orientation: 0,
gpsLat: 48.21,
gpsLon: 16.37,
deviceModel: "Camera",
albumNames: ["Trip"],
isFavorite: true,
proxyPath: "proxy.mp4",
thumbPath: null,
spritePath: null,
qualityScore: 0.5,
analysisVersion: 1,
ingestState: "proxied",
ingestError: null,
createdAt: at,
updatedAt: at,
};
it("maps assets without leaking paths or hashes", () => {
const dto = mapMediaAsset(asset);
expect(dto).toEqual({
id: "asset-1",
sourceId: "source-1",
kind: "video",
storage: "referenced",
capturedAt: at.toISOString(),
durationMs: 1000,
width: 1920,
height: 1080,
fps: 30,
gpsLat: 48.21,
gpsLon: 16.37,
deviceModel: "Camera",
albumNames: ["Trip"],
isFavorite: true,
ingestState: "proxied",
hasProxy: true,
hasThumbnail: false,
qualityScore: 0.5,
createdAt: at.toISOString(),
});
expect(JSON.stringify(dto)).not.toContain("proxy.mp4");
expect(JSON.stringify(dto)).not.toContain("originals");
});
it("keeps only numeric segment scores", () => {
const segment = mapMediaSegment({
id: "seg-1",
spaceId: "space-1",
assetId: "asset-1",
kind: "shot",
startMs: 0,
endMs: 100,
scores: { motion: 0.4, faces: "many", sharpness: null },
teaserScore: null,
transcriptExcerpt: null,
labels: [],
paidOnly: true,
analysisVersion: 1,
createdAt: at,
});
expect(segment.scores).toEqual({ motion: 0.4 });
expect(segment.paidOnly).toBe(true);
});
it("drops internal render output paths and invalid reports", () => {
const row: MediaRenderRow = {
id: "render-1",
spaceId: "space-1",
projectId: "project-1",
planId: "plan-1",
profileId: "example-short-vertical",
quality: "draft",
status: "done",
progress: 1.5,
outputs: [
{ kind: "video", artifactId: null, fileName: "a.mp4", sizeBytes: 1, path: "/abs/a.mp4" },
{ kind: "nonsense" },
],
gateReport: { passed: "maybe" },
ffprobe: null,
autoFixRound: 0,
error: null,
createdBy: "user-1",
createdAt: at,
updatedAt: at,
};
const dto = mapMediaRender(row);
expect(dto.progress).toBe(1);
expect(dto.outputs).toEqual([
{ kind: "video", artifactId: null, fileName: "a.mp4", sizeBytes: 1 },
]);
expect(dto.gateReport).toBeNull();
});
it("sorts upload chunks and converts sizes", () => {
const row: MediaUploadRow = {
id: "upload-1",
spaceId: "space-1",
assetId: "asset-1",
userId: "user-1",
sizeBytes: 21n * 1024n * 1024n * 1024n,
chunkBytes: 8,
sha256: "a".repeat(64),
fileName: "a.mov",
receivedChunks: [3, 0, 2],
status: "open",
createdAt: at,
updatedAt: at,
};
expect(mapMediaUpload(row)).toEqual({
id: "upload-1",
assetId: "asset-1",
sizeBytes: 21 * 1024 * 1024 * 1024,
chunkBytes: 8,
receivedChunks: [0, 2, 3],
status: "open",
});
});
it("maps mark prompts and drops malformed ones", () => {
const row: MediaMarkRow = {
id: "mark-1",
spaceId: "space-1",
kind: "tattoo",
label: null,
personId: null,
assetId: "asset-1",
prompt: { tMs: 10, point: { x: 0.5, y: 0.5 } },
treatment: "blur",
maskDir: "masks/mark-1",
qualityScore: null,
suggestedFallback: null,
createdAt: at,
updatedAt: at,
};
expect(mapMediaMark(row).prompt).toEqual({ tMs: 10, point: { x: 0.5, y: 0.5 } });
expect(mapMediaMark({ ...row, prompt: { tMs: -1 } }).prompt).toBeNull();
expect(mapMediaMark(row)).not.toHaveProperty("maskDir");
});
});
describe("client media items", () => {
const item: ClientMediaItem = {
externalId: "local-1",
kind: "video",
capturedAt: at.toISOString(),
durationMs: 1000,
width: 1080,
height: 1920,
fps: 30,
gpsLat: 48.2085,
gpsLon: 16.3721,
deviceModel: "Phone",
albumNames: ["Trip"],
isFavorite: false,
fileName: "IMG_0001.MOV",
};
it("derives a revision that changes with any metadata change", () => {
const base = clientMediaItemToSourceItem(item);
expect(base).toMatchObject({ externalId: "local-1", capturedAt: at, sizeBytes: null });
expect(clientMediaItemToSourceItem({ ...item }).revision).toBe(base.revision);
expect(clientMediaItemToSourceItem({ ...item, isFavorite: true }).revision).not.toBe(
base.revision,
);
expect(clientMediaItemToSourceItem({ ...item, albumNames: [] }).revision).not.toBe(
base.revision,
);
});
});

32
packages/db/src/media.ts Normal file
View File

@ -0,0 +1,32 @@
import type { PrismaClient } from "./client.js";
import { createMediaCollectionRepos } from "./media-collections.js";
import { createMediaLibraryRepos } from "./media-library.js";
import { createMediaPeopleRepos } from "./media-people.js";
import { createMediaProjectRepos } from "./media-projects.js";
import { createMediaPublishingRepos } from "./media-publishing.js";
import { createMediaUploadRepos } from "./media-uploads.js";
export * from "./media-collections.js";
export * from "./media-library.js";
export * from "./media-people.js";
export * from "./media-projects.js";
export * from "./media-publishing.js";
export * from "./media-shared.js";
export * from "./media-uploads.js";
/*
* Media Studio persistence. Every function takes the space explicitly and never reads
* or writes another space's rows; a foreign or missing id is an IsolationError.
*/
export function createMediaRepos(prisma: PrismaClient) {
return {
...createMediaLibraryRepos(prisma),
...createMediaUploadRepos(prisma),
...createMediaCollectionRepos(prisma),
...createMediaPeopleRepos(prisma),
...createMediaProjectRepos(prisma),
...createMediaPublishingRepos(prisma),
};
}
export type MediaRepos = ReturnType<typeof createMediaRepos>;