diff --git a/.env.example b/.env.example index 5d588c8..5f8d9eb 100644 --- a/.env.example +++ b/.env.example @@ -236,3 +236,10 @@ EVOO_COCKPIT_CORS_ORIGIN= # Expo: English or Simplified Chinese (en | zh-CN). Overridden by SecureStore key # rakazo.uiLocale from Account → Language. # EXPO_PUBLIC_DEFAULT_UI_LOCALE=en + +# Optional Media Studio worker (local ffmpeg + ML sidecar, compose profile "media"). +# Without it, Media Studio stays hidden and media jobs are skipped. +# MEDIA_WORKER_URL=http://127.0.0.1:8790 +# MEDIA_WORKER_TOKEN=REPLACE_WITH_RANDOM_TOKEN +# Private platform profiles that override the shipped ones. Keep outside the repo. +# MEDIA_PROFILES_DIR=~/.evoo/media-profiles diff --git a/apps/api/package.json b/apps/api/package.json index 6fbddbe..f2ef08c 100644 --- a/apps/api/package.json +++ b/apps/api/package.json @@ -19,6 +19,7 @@ "@rakazo/core": "workspace:*", "@rakazo/db": "workspace:*", "@rakazo/logging": "workspace:*", + "@rakazo/media": "workspace:*", "@rakazo/memory": "workspace:*", "hono": "4.13.7" }, diff --git a/apps/api/src/media.ts b/apps/api/src/media.ts new file mode 100644 index 0000000..6b4ec4a --- /dev/null +++ b/apps/api/src/media.ts @@ -0,0 +1,113 @@ +import { implement, ORPCError } from "@orpc/server"; +import type { Actor } from "@rakazo/contracts"; +import { appContract } from "@rakazo/contracts"; +import type { PrismaClient } from "@rakazo/db"; + +export interface MediaRouterDeps { + prisma: PrismaClient; + dataDir: string; +} + +// Phase A scaffold: every procedure is replaced by its real implementation. +function notImplemented(): never { + throw new ORPCError("NOT_IMPLEMENTED", { message: "Media Studio is not available yet" }); +} + +export function createMediaRouter(_deps: MediaRouterDeps) { + const os = implement(appContract.media).$context<{ actor: Actor | null; signal?: AbortSignal }>(); + const authed = os.use(async ({ context, next }) => { + if (!context.actor) throw new ORPCError("UNAUTHORIZED"); + return next({ context: { ...context, actor: context.actor } }); + }); + + return os.router({ + capabilities: authed.capabilities.handler(notImplemented), + sources: { + list: authed.sources.list.handler(notImplemented), + createFolder: authed.sources.createFolder.handler(notImplemented), + registerClient: authed.sources.registerClient.handler(notImplemented), + pushItems: authed.sources.pushItems.handler(notImplemented), + sync: authed.sources.sync.handler(notImplemented), + remove: authed.sources.remove.handler(notImplemented), + }, + assets: { + search: authed.assets.search.handler(notImplemented), + get: authed.assets.get.handler(notImplemented), + setPaidOnly: authed.assets.setPaidOnly.handler(notImplemented), + requestUpload: authed.assets.requestUpload.handler(notImplemented), + }, + uploads: { + begin: authed.uploads.begin.handler(notImplemented), + status: authed.uploads.status.handler(notImplemented), + complete: authed.uploads.complete.handler(notImplemented), + }, + collections: { + list: authed.collections.list.handler(notImplemented), + get: authed.collections.get.handler(notImplemented), + rename: authed.collections.rename.handler(notImplemented), + merge: authed.collections.merge.handler(notImplemented), + split: authed.collections.split.handler(notImplemented), + createManual: authed.collections.createManual.handler(notImplemented), + recluster: authed.collections.recluster.handler(notImplemented), + }, + people: { + list: authed.people.list.handler(notImplemented), + update: authed.people.update.handler(notImplemented), + merge: authed.people.merge.handler(notImplemented), + assignTrack: authed.people.assignTrack.handler(notImplemented), + recordConsent: authed.people.recordConsent.handler(notImplemented), + remove: authed.people.remove.handler(notImplemented), + }, + marks: { + list: authed.marks.list.handler(notImplemented), + create: authed.marks.create.handler(notImplemented), + update: authed.marks.update.handler(notImplemented), + remove: authed.marks.remove.handler(notImplemented), + }, + brandKits: { + list: authed.brandKits.list.handler(notImplemented), + upsert: authed.brandKits.upsert.handler(notImplemented), + remove: authed.brandKits.remove.handler(notImplemented), + templates: authed.brandKits.templates.handler(notImplemented), + introVariants: authed.brandKits.introVariants.handler(notImplemented), + }, + profiles: { + list: authed.profiles.list.handler(notImplemented), + get: authed.profiles.get.handler(notImplemented), + }, + projects: { + list: authed.projects.list.handler(notImplemented), + get: authed.projects.get.handler(notImplemented), + create: authed.projects.create.handler(notImplemented), + update: authed.projects.update.handler(notImplemented), + remove: authed.projects.remove.handler(notImplemented), + autoPlan: authed.projects.autoPlan.handler(notImplemented), + }, + plans: { + submit: authed.plans.submit.handler(notImplemented), + }, + renders: { + start: authed.renders.start.handler(notImplemented), + get: authed.renders.get.handler(notImplemented), + cancel: authed.renders.cancel.handler(notImplemented), + thumbnails: authed.renders.thumbnails.handler(notImplemented), + }, + marketing: { + copy: authed.marketing.copy.handler(notImplemented), + }, + publications: { + list: authed.publications.list.handler(notImplemented), + create: authed.publications.create.handler(notImplemented), + bundle: authed.publications.bundle.handler(notImplemented), + markPublished: authed.publications.markPublished.handler(notImplemented), + cancel: authed.publications.cancel.handler(notImplemented), + }, + metrics: { + mappings: authed.metrics.mappings.handler(notImplemented), + saveMapping: authed.metrics.saveMapping.handler(notImplemented), + importCsv: authed.metrics.importCsv.handler(notImplemented), + record: authed.metrics.record.handler(notImplemented), + funnel: authed.metrics.funnel.handler(notImplemented), + }, + }); +} diff --git a/apps/api/src/router.ts b/apps/api/src/router.ts index 963a533..b69c2a9 100644 --- a/apps/api/src/router.ts +++ b/apps/api/src/router.ts @@ -166,6 +166,7 @@ import { renameMainConversation, } from "./main-conversation.js"; import { buildMcpUpdateMaterial } from "./mcp-material.js"; +import { createMediaRouter } from "./media.js"; import { disconnectMemoryProvider, getMemoryProviderConfig, @@ -526,6 +527,7 @@ export function createRouter(deps: RouterDeps) { }); return os.router({ + media: createMediaRouter({ prisma: deps.prisma, dataDir: deps.dataDir }), aiConsent: { status: authed.aiConsent.status.handler(({ context, input }) => aiConsentStatus(deps, context.actor, input), diff --git a/apps/mobile/package.json b/apps/mobile/package.json index d5bb46b..176c162 100644 --- a/apps/mobile/package.json +++ b/apps/mobile/package.json @@ -35,6 +35,7 @@ "expo-image-picker": "57.0.20", "expo-linking": "57.0.11", "expo-localization": "57.0.2", + "expo-media-library": "57.0.5", "expo-modules-core": "57.0.20", "expo-notifications": "57.0.21", "expo-router": "57.0.24", @@ -44,6 +45,7 @@ "expo-status-bar": "57.0.1", "expo-symbols": "57.0.3", "expo-updates": "57.0.24", + "expo-video": "57.0.5", "react": "19.2.3", "react-dom": "19.2.3", "react-native": "0.86.3", diff --git a/apps/web/package.json b/apps/web/package.json index 1f121dc..a9326b8 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -24,6 +24,7 @@ "@rakazo/core": "workspace:*", "@rakazo/ui-tokens": "workspace:*", "@rakazo/ui-web": "workspace:*", + "@tanstack/react-virtual": "3.14.13", "better-auth": "1.7.4", "lucide-react": "1.44.0", "react": "19.2.3", diff --git a/packages/adapter-kit/src/background-jobs.test.ts b/packages/adapter-kit/src/background-jobs.test.ts index 13880d7..df0f258 100644 --- a/packages/adapter-kit/src/background-jobs.test.ts +++ b/packages/adapter-kit/src/background-jobs.test.ts @@ -22,6 +22,12 @@ function handlers(): BackgroundJobHandlers { "messaging.deliver": vi.fn(async () => undefined), "cloud_agent.poll": vi.fn(async () => undefined), "profile.consolidate": vi.fn(async () => undefined), + "media.sync": vi.fn(async () => undefined), + "media.proxy": vi.fn(async () => undefined), + "media.analyze": vi.fn(async () => undefined), + "media.cluster": vi.fn(async () => undefined), + "media.render": vi.fn(async () => undefined), + "media.publish": vi.fn(async () => undefined), }; } diff --git a/packages/adapter-kit/src/background-jobs.ts b/packages/adapter-kit/src/background-jobs.ts index 45cad56..3f09390 100644 --- a/packages/adapter-kit/src/background-jobs.ts +++ b/packages/adapter-kit/src/background-jobs.ts @@ -1,3 +1,5 @@ +import type { MediaAnalysisKind } from "@rakazo/contracts"; +import { MediaAnalysisKindSchema } from "@rakazo/contracts"; import { z } from "zod"; import type { BackgroundJob, @@ -23,6 +25,15 @@ const payloadSchemas = { "messaging.deliver": z.object({ runId: z.string().min(1).optional() }), "cloud_agent.poll": z.object({ agentId: z.string().min(1) }), "profile.consolidate": z.object({ userId: z.string().min(1) }), + "media.sync": z.object({ sourceId: z.string().min(1) }), + "media.proxy": z.object({ assetId: z.string().min(1) }), + "media.analyze": z.object({ + assetId: z.string().min(1), + kinds: z.array(MediaAnalysisKindSchema).optional(), + }), + "media.cluster": z.object({ spaceId: z.string().min(1) }), + "media.render": z.object({ renderId: z.string().min(1) }), + "media.publish": z.object({ publicationId: z.string().min(1) }), } satisfies { [Name in BackgroundJobName]: z.ZodType }; export function parseBackgroundJob(name: string, payload: unknown): BackgroundJob { @@ -160,3 +171,41 @@ export function profileConsolidateJob(userId: string): BackgroundJob { replaceKey: profileConsolidateJobKey(userId), }; } + +export function mediaSyncJob(sourceId: string): BackgroundJob { + return { name: "media.sync", payload: { sourceId }, replaceKey: `media.sync:${sourceId}` }; +} + +export function mediaProxyJob(assetId: string): BackgroundJob { + return { name: "media.proxy", payload: { assetId }, replaceKey: `media.proxy:${assetId}` }; +} + +export function mediaAnalyzeJob(assetId: string, kinds?: MediaAnalysisKind[]): BackgroundJob { + return { + name: "media.analyze", + payload: kinds ? { assetId, kinds } : { assetId }, + replaceKey: `media.analyze:${assetId}`, + }; +} + +export function mediaClusterJob(spaceId: string, availableAt?: Date): BackgroundJob { + return { + name: "media.cluster", + payload: { spaceId }, + replaceKey: `media.cluster:${spaceId}`, + ...(availableAt ? { availableAt } : {}), + }; +} + +export function mediaRenderJob(renderId: string): BackgroundJob { + return { name: "media.render", payload: { renderId }, replaceKey: `media.render:${renderId}` }; +} + +export function mediaPublishJob(publicationId: string, availableAt?: Date): BackgroundJob { + return { + name: "media.publish", + payload: { publicationId }, + replaceKey: `media.publish:${publicationId}`, + ...(availableAt ? { availableAt } : {}), + }; +} diff --git a/packages/adapter-kit/src/index.ts b/packages/adapter-kit/src/index.ts index 43f7898..e39a95c 100644 --- a/packages/adapter-kit/src/index.ts +++ b/packages/adapter-kit/src/index.ts @@ -1,5 +1,6 @@ export * from "./background-jobs.js"; export * from "./cloud-agents.js"; export * from "./interfaces.js"; +export * from "./media.js"; export * from "./registry.js"; export * from "./types.js"; diff --git a/packages/adapter-kit/src/media.ts b/packages/adapter-kit/src/media.ts new file mode 100644 index 0000000..1674304 --- /dev/null +++ b/packages/adapter-kit/src/media.ts @@ -0,0 +1,325 @@ +import type { + AnonymizeMode, + BoxKeyframe, + BrandKit, + ContentCategory, + FfprobeReport, + MediaAssetKind, + MediaCapabilities, + MediaInferenceOperation, + MediaMarkPrompt, + MediaSourceKind, + MotionTemplateInfo, + MotionTemplateRef, + NormalizedBox, + NormalizedPoint, + PlatformProfile, + RenderPlan, +} from "@rakazo/contracts"; + +/* + * Media Studio provider boundaries. Every implementation (local sidecar, + * emulator, optional cloud adapters) satisfies the same contracts, which + * media-conformance tests exercise offline. + * + * Paths are absolute host paths inside the configured media root. Providers + * must reject any path outside that root. + */ + +// --------------------------------------------------------------------------- +// Sources +// --------------------------------------------------------------------------- + +export interface MediaSourceItem { + externalId: string; + kind: MediaAssetKind; + capturedAt: Date | null; + durationMs: number | null; + width: number | null; + height: number | null; + fps: number | null; + gpsLat: number | null; + gpsLon: number | null; + deviceModel: string | null; + albumNames: string[]; + isFavorite: boolean; + fileName: string | null; + sizeBytes: number | null; + /** Cheap change detector (mtime + size or provider revision); not a content hash. */ + revision: string; +} + +export interface MediaSourceListRequest { + config: Record; + cursor: string | null; + limit: number; + signal?: AbortSignal; +} + +export interface MediaSourceListResult { + items: MediaSourceItem[]; + nextCursor: string | null; +} + +/** Server-side sources the worker can enumerate and read directly. */ +export interface MediaSourceProvider { + readonly kind: Extract; + list(request: MediaSourceListRequest): Promise; + /** Absolute readable path of the original file. Throws when it escaped the source root. */ + resolve(config: Record, externalId: string): Promise; +} + +// --------------------------------------------------------------------------- +// Inference +// --------------------------------------------------------------------------- + +export class UnsupportedMediaOperationError extends Error { + constructor( + readonly operation: MediaInferenceOperation, + readonly reason: string, + ) { + super(`${operation} is not available: ${reason}`); + this.name = "UnsupportedMediaOperationError"; + } +} + +export interface MediaTimeSpan { + startMs: number; + endMs: number; +} + +export interface DetectScenesResult { + shots: Array; + scenes: MediaTimeSpan[]; +} + +export interface TranscriptWord { + startMs: number; + endMs: number; + text: string; + confidence: number; +} + +export interface TranscribeResult { + language: string | null; + words: TranscriptWord[]; +} + +export interface FaceObservation { + box: NormalizedBox; + confidence: number; + /** Five-point landmarks: eyes, nose, mouth corners. */ + landmarks?: NormalizedPoint[]; +} + +export interface DetectFacesResult { + frames: Array<{ tMs: number; faces: FaceObservation[] }>; +} + +export interface FaceTrackResult { + trackId: string; + keyframes: Array; + /** Identity embedding for local person clustering. Never leaves the host. */ + embedding: number[] | null; +} + +export interface TrackFacesResult { + sampleFps: number; + tracks: FaceTrackResult[]; +} + +export interface FaceLandmarksResult { + frames: Array<{ + tMs: number; + faces: Array<{ box: NormalizedBox; landmarks: NormalizedPoint[] }>; + }>; +} + +export interface EmbedFramesResult { + model: string; + dim: number; + vectors: number[][]; +} + +export interface ClassifyContentResult { + model: string; + frames: Array<{ tMs: number; scores: Partial> }>; +} + +export interface SegmentPromptedResult { + /** Directory of per-frame PNG masks named by timestamp in ms. */ + maskDir: string; + keyframes: Array; + score: number; +} + +export interface InpaintQuality { + /** 1 = no flicker inside the mask region between neighbouring frames. */ + temporalStability: number; + /** 1 = no visible seams at the mask edge. */ + edgeQuality: number; + score: number; +} + +export interface InpaintVideoResult { + outPath: string; + quality: InpaintQuality; +} + +export interface AudioFeaturesResult { + integratedLufs: number | null; + truePeakDb: number | null; + tempoBpm: number | null; + beatsMs: number[]; + energy: Array<{ tMs: number; value: number }>; + silences: MediaTimeSpan[]; +} + +export interface SaliencyResult { + points: Array<{ tMs: number; cx: number; cy: number; strength: number }>; +} + +export interface RedactionInstruction { + keyframes: BoxKeyframe[]; + mode: Exclude | "cover"; + paddingPct: number; + featherPct: number; + /** Solid and cover fill. */ + color?: string; + /** Landmarks per keyframe for partial modes. */ + landmarks?: Array<{ tMs: number; points: NormalizedPoint[] }>; + /** Mask directory for region marks instead of boxes. */ + maskDir?: string; +} + +interface MediaPathRequest { + path: string; + signal?: AbortSignal; +} + +/** + * Local-first ML operations. Callers check `capabilities()` and fall back + * (blur, cover, reframe) when an operation reports unavailable; providers + * throw UnsupportedMediaOperationError instead of guessing. + */ +export interface MediaInferenceProvider { + readonly id: string; + capabilities(signal?: AbortSignal): Promise; + detectScenes(request: MediaPathRequest): Promise; + transcribe(request: MediaPathRequest & { language?: string }): Promise; + detectFaces( + request: MediaPathRequest & { sampleFps: number; timesMs?: number[] }, + ): Promise; + trackFaces(request: MediaPathRequest & { sampleFps: number }): Promise; + faceLandmarks(request: MediaPathRequest & { timesMs: number[] }): Promise; + embedFrames(request: MediaPathRequest & { timesMs: number[] }): Promise; + classifyContent( + request: MediaPathRequest & { sampleFps: number; extraTimesMs?: number[] }, + ): Promise; + segmentPrompted( + request: MediaPathRequest & { prompt: MediaMarkPrompt; propagate: boolean; outDir: string }, + ): Promise; + inpaintVideo( + request: MediaPathRequest & { maskDir: string; outPath: string }, + ): Promise; + /** + * Apply face and region redactions frame-accurately and write a near-lossless + * intermediate. Fails closed: a frame it cannot process is an error, not a pass. + */ + redact( + request: MediaPathRequest & { redactions: RedactionInstruction[]; outPath: string }, + ): Promise<{ outPath: string; framesProcessed: number }>; + /** + * Replace a tracked face with a generated, non-existent identity derived from + * `seed`. Deliberately takes no target-face image. + */ + synthesizeIdentity( + request: MediaPathRequest & { track: BoxKeyframe[]; seed: number; outPath: string }, + ): Promise<{ outPath: string }>; + audioFeatures(request: MediaPathRequest): Promise; + saliency(request: MediaPathRequest & { timesMs: number[] }): Promise; +} + +// --------------------------------------------------------------------------- +// Rendering +// --------------------------------------------------------------------------- + +export interface MediaRenderProgress { + stepId: string; + /** Overall progress 0..1. */ + progress: number; +} + +export interface MediaRenderer { + readonly id: string; + /** Hardware encoders usable on this host, e.g. h264_vaapi, h264_videotoolbox. */ + hardwareEncoders(): Promise; + /** + * Run every ffmpeg step of the plan. Motion, inpaint, and synthetic identity + * steps must already be satisfied (their outPath exists) or the renderer throws. + */ + render( + plan: RenderPlan, + options: { signal?: AbortSignal; onProgress?: (progress: MediaRenderProgress) => void }, + ): Promise; + probe( + path: string, + options?: { measureLoudness?: boolean; signal?: AbortSignal }, + ): Promise; + /** Extract a still frame as PNG (EXIF-free). */ + extractFrame(input: { + path: string; + tMs: number; + outPath: string; + width?: number; + signal?: AbortSignal; + }): Promise; +} + +export interface MotionRenderRequest { + template: MotionTemplateRef; + brandKit: BrandKit; + /** Resolved font and logo files from the brand kit artifacts. */ + assets: { fonts: Array<{ family: string; path: string }>; logoPaths: string[] }; + width: number; + height: number; + fps: number; + outPath: string; + signal?: AbortSignal; +} + +export interface MotionGraphicsRenderer { + readonly id: string; + templates(): MotionTemplateInfo[]; + /** Render an overlay clip with alpha (MOV, qtrle or prores 4444). */ + render(request: MotionRenderRequest): Promise<{ path: string; hasAlpha: boolean }>; +} + +// --------------------------------------------------------------------------- +// Publishing +// --------------------------------------------------------------------------- + +export interface MediaPublishRequest { + bundleDir: string; + profile: PlatformProfile; + publicationId: string; + title: string | null; + caption: string | null; + tags: string[]; + signal?: AbortSignal; +} + +export interface MediaPublishResult { + status: "published" | "bundled"; + externalUrl: string | null; +} + +export interface MediaPublisher { + readonly id: string; + publish(request: MediaPublishRequest): Promise; +} + +export interface LinkShortener { + readonly id: string; + shorten(url: string, signal?: AbortSignal): Promise; +} diff --git a/packages/adapter-kit/src/registry.ts b/packages/adapter-kit/src/registry.ts index 19c0822..086efa2 100644 --- a/packages/adapter-kit/src/registry.ts +++ b/packages/adapter-kit/src/registry.ts @@ -36,4 +36,8 @@ export const slots = { web: "web", browser: "browser", cloudAgent: "cloud-agent", + mediaInference: "media-inference", + mediaRenderer: "media-renderer", + motionGraphics: "motion-graphics", + mediaPublisher: "media-publisher", } as const; diff --git a/packages/adapter-kit/src/types.ts b/packages/adapter-kit/src/types.ts index d77cb5e..7bf3883 100644 --- a/packages/adapter-kit/src/types.ts +++ b/packages/adapter-kit/src/types.ts @@ -1,4 +1,9 @@ -import type { ConnectionCatalogItem, ModelTier, SandboxKind } from "@rakazo/contracts"; +import type { + ConnectionCatalogItem, + MediaAnalysisKind, + ModelTier, + SandboxKind, +} from "@rakazo/contracts"; export interface AdapterContext { operationId: string; @@ -524,9 +529,22 @@ export interface BackgroundJobPayloads { "cloud_agent.poll": { agentId: string }; /** Learn durable user facts from conversations since the profile's last consolidation. */ "profile.consolidate": { userId: string }; + /** Enumerate a server-side media source incrementally (metadata only). */ + "media.sync": { sourceId: string }; + /** Hash the original and build proxy, thumbnails, and keyframes. */ + "media.proxy": { assetId: string }; + /** Run sidecar analyses; omitted kinds means every analysis not yet at the current version. */ + "media.analyze": { assetId: string; kinds?: MediaAnalysisKind[] }; + /** Rebuild sessions, themes, and person suggestions for a space. */ + "media.cluster": { spaceId: string }; + /** Render, probe, and gate one MediaRender. */ + "media.render": { renderId: string }; + /** Build the export bundle and publish a scheduled MediaPublication. */ + "media.publish": { publicationId: string }; } export type BackgroundJobName = keyof BackgroundJobPayloads; +export type MediaJobName = Extract; export type BackgroundJob = { [Name in BackgroundJobName]: { @@ -541,6 +559,8 @@ export type BackgroundJobHandlers = { [Name in BackgroundJobName]: (payload: BackgroundJobPayloads[Name]) => Promise; }; +export type MediaJobHandlers = Pick; + export interface SecretRecord { id: string; ciphertext: string; diff --git a/packages/adapters/package.json b/packages/adapters/package.json index c9b7855..d2e461d 100644 --- a/packages/adapters/package.json +++ b/packages/adapters/package.json @@ -29,6 +29,8 @@ "@rakazo/core": "workspace:*", "@rakazo/db": "workspace:*", "@rakazo/logging": "workspace:*", + "@rakazo/media": "workspace:*", + "@resvg/resvg-js": "2.6.2", "chat": "4.40.0", "chat-adapter-lark": "0.3.1", "chat-adapter-sendblue": "0.2.0", diff --git a/packages/adapters/src/background-job-handlers.ts b/packages/adapters/src/background-job-handlers.ts index af869ac..949c898 100644 --- a/packages/adapters/src/background-job-handlers.ts +++ b/packages/adapters/src/background-job-handlers.ts @@ -3,6 +3,7 @@ import type { AgentRuntime, BackgroundJobHandlers, JobPublisher, + MediaJobHandlers, MessagingSurface, SandboxProvider, } from "@rakazo/adapter-kit"; @@ -36,6 +37,8 @@ export function createBackgroundJobHandlers(deps: { deploymentModelKey?: string; messaging?: MessagingSurface; cloudAgent?: CloudAgentConnection | null; + /** Media Studio job handlers; absent when media is not configured for this worker. */ + media?: MediaJobHandlers; }): BackgroundJobHandlers { const deliverMessaging = async (runId?: string) => { if (!deps.messaging) return; @@ -53,6 +56,7 @@ export function createBackgroundJobHandlers(deps: { }; return { + ...(deps.media ?? mediaDisabledHandlers()), "run.continue": async (payload) => { await deps.executor.continueRun(payload.runId, deps.workerId); // Automatic messaging mirror: once the run's bot messages are durable, @@ -126,3 +130,17 @@ export function createBackgroundJobHandlers(deps: { }, }; } + +function mediaDisabledHandlers(): MediaJobHandlers { + const skip = async () => { + getLogger().warn("media job skipped: Media Studio is not configured on this worker"); + }; + return { + "media.sync": skip, + "media.proxy": skip, + "media.analyze": skip, + "media.cluster": skip, + "media.render": skip, + "media.publish": skip, + }; +} diff --git a/packages/adapters/src/wakeup-graphile-host.test.ts b/packages/adapters/src/wakeup-graphile-host.test.ts index b6cc385..4aecbd2 100644 --- a/packages/adapters/src/wakeup-graphile-host.test.ts +++ b/packages/adapters/src/wakeup-graphile-host.test.ts @@ -24,6 +24,12 @@ function handlers(): BackgroundJobHandlers { "messaging.deliver": vi.fn(async () => undefined), "cloud_agent.poll": vi.fn(async () => undefined), "profile.consolidate": vi.fn(async () => undefined), + "media.sync": vi.fn(async () => undefined), + "media.proxy": vi.fn(async () => undefined), + "media.analyze": vi.fn(async () => undefined), + "media.cluster": vi.fn(async () => undefined), + "media.render": vi.fn(async () => undefined), + "media.publish": vi.fn(async () => undefined), }; } diff --git a/packages/adapters/src/wakeup.postgres.test.ts b/packages/adapters/src/wakeup.postgres.test.ts index b164554..be759c7 100644 --- a/packages/adapters/src/wakeup.postgres.test.ts +++ b/packages/adapters/src/wakeup.postgres.test.ts @@ -29,6 +29,12 @@ function handlers(overrides: Partial = {}): BackgroundJob "messaging.deliver": vi.fn(async () => undefined), "cloud_agent.poll": vi.fn(async () => undefined), "profile.consolidate": vi.fn(async () => undefined), + "media.sync": vi.fn(async () => undefined), + "media.proxy": vi.fn(async () => undefined), + "media.analyze": vi.fn(async () => undefined), + "media.cluster": vi.fn(async () => undefined), + "media.render": vi.fn(async () => undefined), + "media.publish": vi.fn(async () => undefined), ...overrides, }; } diff --git a/packages/adapters/src/wakeup.test.ts b/packages/adapters/src/wakeup.test.ts index 3856671..b5eb13c 100644 --- a/packages/adapters/src/wakeup.test.ts +++ b/packages/adapters/src/wakeup.test.ts @@ -14,6 +14,12 @@ function handlers(): BackgroundJobHandlers { "messaging.deliver": vi.fn(async () => undefined), "cloud_agent.poll": vi.fn(async () => undefined), "profile.consolidate": vi.fn(async () => undefined), + "media.sync": vi.fn(async () => undefined), + "media.proxy": vi.fn(async () => undefined), + "media.analyze": vi.fn(async () => undefined), + "media.cluster": vi.fn(async () => undefined), + "media.render": vi.fn(async () => undefined), + "media.publish": vi.fn(async () => undefined), }; } diff --git a/packages/contracts/src/index.ts b/packages/contracts/src/index.ts index 3865e1b..ba2c70b 100644 --- a/packages/contracts/src/index.ts +++ b/packages/contracts/src/index.ts @@ -9,6 +9,7 @@ export * from "./inbox.js"; export * from "./integration-settings.js"; export * from "./local-settings.js"; export * from "./mcp.js"; +export * from "./media.js"; export * from "./model-tiers.js"; export * from "./openai-compatible-ui.js"; export * from "./project-goals.js"; diff --git a/packages/contracts/src/media.ts b/packages/contracts/src/media.ts new file mode 100644 index 0000000..428fa44 --- /dev/null +++ b/packages/contracts/src/media.ts @@ -0,0 +1,1518 @@ +import * as z from "zod"; +import { Id, IsoDate } from "./ids.js"; + +/* + * Media Studio contracts: the single source of truth for media DTOs, the edit + * decision list (EDL), platform profiles, brand kits, gate reports, render + * plans, and the media_* agent tool inputs. + */ + +// --------------------------------------------------------------------------- +// Enums +// --------------------------------------------------------------------------- + +export const MediaSourceKindSchema = z.enum(["apple_photos", "ios_library", "folder", "emulator"]); +export type MediaSourceKind = z.infer; + +/** Sources whose files live on a client device and are pushed to the server. */ +export const CLIENT_PUSHED_MEDIA_SOURCES = ["apple_photos", "ios_library"] as const; + +export const MediaSourceStatusSchema = z.enum(["idle", "syncing", "error", "unavailable"]); +export type MediaSourceStatus = z.infer; + +export const MediaAssetKindSchema = z.enum(["video", "photo", "live_photo"]); +export type MediaAssetKind = z.infer; + +/** Where the original bytes are: referenced in place, uploaded to DATA_DIR, or still on a device. */ +export const MediaAssetStorageSchema = z.enum(["referenced", "uploaded", "remote"]); +export type MediaAssetStorage = z.infer; + +export const MediaIngestStateSchema = z.enum([ + "indexed", + "awaiting_upload", + "proxying", + "proxied", + "analyzing", + "analyzed", + "failed", +]); +export type MediaIngestState = z.infer; + +export const MediaSegmentKindSchema = z.enum(["shot", "scene"]); +export type MediaSegmentKind = z.infer; + +export const MediaAnalysisKindSchema = z.enum([ + "scenes", + "transcript", + "faces", + "embeddings", + "classifier", + "audio", + "saliency", +]); +export type MediaAnalysisKind = z.infer; + +export const MediaCollectionKindSchema = z.enum(["session", "theme", "manual"]); +export type MediaCollectionKind = z.infer; + +export const MediaLabelSourceSchema = z.enum(["rule", "model", "user"]); +export type MediaLabelSource = z.infer; + +export const AnonymizeModeSchema = z.enum([ + "none", + "blur", + "pixelate", + "solid", + "sticker", + "partial_eyes", + "partial_upper", + "partial_lower", + "reframe_out", + "synthetic_identity", +]); +export type AnonymizeMode = z.infer; + +export const AgeVerificationMethodSchema = z.enum([ + "platform_verified", + "document_on_file", + "none", +]); +export type AgeVerificationMethod = z.infer; + +export const MediaMarkKindSchema = z.enum([ + "tattoo", + "birthmark", + "scar", + "jewelry", + "background_detail", + "license_plate", + "custom", +]); +export type MediaMarkKind = z.infer; + +export const MarkTreatmentSchema = z.enum(["inpaint", "blur", "cover", "reframe_out", "none"]); +export type MarkTreatment = z.infer; + +export const MediaProjectGoalSchema = z.enum([ + "teaser", + "full_release", + "trailer", + "highlight", + "story", + "custom", +]); +export type MediaProjectGoal = z.infer; + +export const MediaProjectStatusSchema = z.enum([ + "draft", + "planning", + "rendering", + "review", + "ready", + "archived", +]); +export type MediaProjectStatus = z.infer; + +export const MediaRenderStatusSchema = z.enum([ + "queued", + "rendering", + "gating", + "blocked", + "done", + "failed", + "canceled", +]); +export type MediaRenderStatus = z.infer; + +export const MediaRenderQualitySchema = z.enum(["draft", "final"]); +export type MediaRenderQuality = z.infer; + +export const MediaPublicationStatusSchema = z.enum([ + "draft", + "scheduled", + "bundled", + "published", + "failed", + "canceled", +]); +export type MediaPublicationStatus = z.infer; + +export const MediaMetricSourceSchema = z.enum(["csv_import", "manual", "connector"]); +export type MediaMetricSource = z.infer; + +export const PlatformProfileKindSchema = z.enum([ + "free_social", + "paid_video", + "own_site", + "messenger", +]); +export type PlatformProfileKind = z.infer; + +export const ContentCategorySchema = z.enum([ + "explicit_nudity", + "suggestive", + "violence", + "minors_risk", + "self_harm", + "drugs", + "weapons", +]); +export type ContentCategory = z.infer; +export const CONTENT_CATEGORIES = ContentCategorySchema.options; + +export const LinkPolicySchema = z.enum(["allowed", "bio_only", "forbidden"]); +export type LinkPolicy = z.infer; + +export const MusicPolicySchema = z.enum(["any", "licensed_only", "platform_library_only"]); +export type MusicPolicy = z.infer; + +export const TransitionKindSchema = z.enum(["cut", "crossfade", "dip", "whip", "match_cut"]); +export type TransitionKind = z.infer; + +export const CaptionModeSchema = z.enum(["burned", "sidecar", "both", "none"]); +export type CaptionMode = z.infer; + +// --------------------------------------------------------------------------- +// Primitives +// --------------------------------------------------------------------------- + +/** Milliseconds on a media timeline. */ +export const Ms = z.number().int().min(0); +/** A normalized coordinate in [0, 1] relative to frame width or height. */ +export const Unit = z.number().min(0).max(1); +/** Identifiers that may reach a filtergraph label or file name. */ +export const SafeId = z + .string() + .min(1) + .max(64) + .regex(/^[a-z0-9_-]+$/); + +export const NormalizedBoxSchema = z.object({ x: Unit, y: Unit, w: Unit, h: Unit }); +export type NormalizedBox = z.infer; + +export const NormalizedPointSchema = z.object({ x: Unit, y: Unit }); +export type NormalizedPoint = z.infer; + +/** Hex color for brand kits and render output. Never a UI token. */ +export const BrandColor = z.string().regex(/^#[0-9a-fA-F]{6}$/); + +export const BoxKeyframeSchema = z.object({ tMs: Ms, box: NormalizedBoxSchema }); +export type BoxKeyframe = z.infer; + +// --------------------------------------------------------------------------- +// Platform profiles +// --------------------------------------------------------------------------- + +export const AspectRatioSchema = z.enum(["9:16", "4:5", "1:1", "16:9", "4:3", "21:9"]); +export type AspectRatio = z.infer; + +export const PlatformProfileSchema = z + .object({ + id: SafeId, + name: z.string().min(1).max(80), + kind: PlatformProfileKindSchema, + sourceUrls: z.array(z.string().url()).default([]), + reviewedAt: z.string().date(), + /** True when at least one value could not be verified against official documentation. */ + unverified: z.boolean().default(false), + video: z.object({ + aspectRatios: z.array(AspectRatioSchema).min(1), + width: z.number().int().min(16).max(7680), + height: z.number().int().min(16).max(7680), + fps: z.number().min(1).max(120), + codec: z.enum(["h264", "hevc"]), + maxBitrateKbps: z.number().int().positive(), + maxFileSizeMb: z.number().positive(), + }), + duration: z.object({ + hardMinMs: Ms, + hardMaxMs: Ms, + recommended: z.array(Ms).default([]), + hookWindowMs: Ms.default(2000), + }), + safeZones: z.object({ + topPct: z.number().min(0).max(50), + bottomPct: z.number().min(0).max(50), + leftPct: z.number().min(0).max(50), + rightPct: z.number().min(0).max(50), + }), + captions: z.object({ + recommended: z.boolean(), + required: z.boolean().default(false), + maxLines: z.number().int().min(1).max(4).default(2), + }), + audio: z.object({ + targetLufs: z.number().min(-30).max(-5).default(-14), + truePeakDb: z.number().min(-9).max(0).default(-1), + music: MusicPolicySchema.default("licensed_only"), + }), + text: z.object({ + titleMaxChars: z.number().int().positive().optional(), + captionMaxChars: z.number().int().positive(), + hashtagLimit: z.number().int().min(0), + linkPolicy: LinkPolicySchema, + bannedPatterns: z + .array(z.object({ pattern: z.string().min(1).max(200), reason: z.string().min(1) })) + .default([]), + }), + content: z.object({ + /** Maximum allowed classifier score per category, 0..1. Missing categories are unrestricted. */ + thresholds: z.partialRecord(ContentCategorySchema, z.number().min(0).max(1)), + sampleFps: z.number().min(0.5).max(10).default(2), + blockOn: z.enum(["any_frame", "ratio"]).default("any_frame"), + /** With blockOn "ratio": fraction of sampled frames above threshold that blocks. */ + blockRatio: z.number().min(0).max(1).optional(), + }), + disclosure: z.object({ + aiAlteredLabelRequired: z.boolean(), + paidPartnershipLabel: z.boolean().default(false), + ageRestrictedFlag: z.boolean().default(false), + }), + consent: z.object({ + requirePerformerConsent: z.boolean(), + requireAgeVerification: z.boolean(), + }), + export: z.object({ + poster: z.boolean().default(true), + subtitles: z.boolean().default(true), + captionText: z.boolean().default(true), + uploadChecklist: z.boolean().default(true), + previewClipMs: Ms.optional(), + }), + }) + .superRefine((profile, ctx) => { + if (profile.duration.hardMinMs > profile.duration.hardMaxMs) { + ctx.addIssue({ code: "custom", path: ["duration"], message: "hardMinMs > hardMaxMs" }); + } + }); +export type PlatformProfile = z.infer; + +/** Profile summary as the API exposes it. */ +export const PlatformProfileSummarySchema = z.object({ + id: SafeId, + name: z.string(), + kind: PlatformProfileKindSchema, + origin: z.enum(["builtin", "private"]), + unverified: z.boolean(), + stale: z.boolean(), + aspectRatios: z.array(AspectRatioSchema), + hardMinMs: Ms, + hardMaxMs: Ms, + recommendedMs: z.array(Ms), +}); +export type PlatformProfileSummary = z.infer; + +// --------------------------------------------------------------------------- +// Brand kits +// --------------------------------------------------------------------------- + +export const WatermarkSchema = z.object({ + artifactId: Id.optional(), + position: z.enum(["top_left", "top_right", "bottom_left", "bottom_right", "center"]), + opacity: z.number().min(0).max(1), + /** Width relative to frame width. */ + scale: z.number().min(0.02).max(0.5), +}); +export type Watermark = z.infer; + +export const CaptionStylePresetSchema = z.object({ + id: SafeId, + name: z.string().min(1).max(40), + template: z.enum(["clean", "karaoke", "bold_social"]), + fontFamily: z.string().min(1).max(80), + sizePct: z.number().min(2).max(12), + textColor: BrandColor, + highlightColor: BrandColor.optional(), + backgroundColor: BrandColor.optional(), + uppercase: z.boolean().default(false), +}); +export type CaptionStylePreset = z.infer; + +export const MotionTemplateRefSchema = z.object({ + templateId: SafeId, + params: z.record(z.string(), z.union([z.string().max(200), z.number(), z.boolean()])).default({}), + durationMs: Ms, +}); +export type MotionTemplateRef = z.infer; + +export const BrandKitSchema = z.object({ + name: z.string().min(1).max(60), + colors: z.object({ + primary: BrandColor, + secondary: BrandColor, + accent: BrandColor, + text: BrandColor, + background: BrandColor, + }), + fonts: z + .array( + z.object({ + family: z.string().min(1).max(80), + artifactId: Id, + role: z.enum(["heading", "body", "caption"]), + license: z.string().max(200).optional(), + }), + ) + .default([]), + logoArtifactIds: z.array(Id).max(4).default([]), + lutArtifactId: Id.optional(), + watermark: WatermarkSchema.optional(), + captionPresets: z.array(CaptionStylePresetSchema).default([]), + musicBeds: z + .array( + z.object({ + artifactId: Id, + title: z.string().min(1).max(80), + license: z.string().min(1).max(200), + commercialUse: z.boolean(), + }), + ) + .default([]), + voicePreset: z.object({ provider: z.string(), voiceId: z.string() }).optional(), + intro: MotionTemplateRefSchema.optional(), + outro: MotionTemplateRefSchema.optional(), + endCard: MotionTemplateRefSchema.optional(), + tone: z.string().max(200).optional(), + audience: z.string().max(200).optional(), +}); +export type BrandKit = z.infer; + +// --------------------------------------------------------------------------- +// Edit decision list +// --------------------------------------------------------------------------- + +export const ReframeKeyframeSchema = z.object({ + tMs: Ms, + /** Crop center, normalized to source frame. */ + cx: Unit, + cy: Unit, + /** 1 = tightest crop that still fills the output aspect; > 1 zooms in. */ + scale: z.number().min(1).max(4), +}); +export type ReframeKeyframe = z.infer; + +export const ColorAdjustSchema = z.object({ + exposure: z.number().min(-2).max(2).default(0), + temperature: z.number().min(-1).max(1).default(0), + saturation: z.number().min(0).max(2).default(1), + contrast: z.number().min(0.5).max(2).default(1), + useBrandLut: z.boolean().default(false), +}); +export type ColorAdjust = z.infer; + +export const EdlVideoClipSchema = z.object({ + id: SafeId, + assetId: Id, + inMs: Ms, + outMs: Ms, + /** Position on the output timeline. */ + atMs: Ms, + speed: z.number().min(0.25).max(4).default(1), + reframe: z.union([z.literal("auto"), z.literal("none"), z.array(ReframeKeyframeSchema).min(1)]), + color: ColorAdjustSchema.optional(), + stabilize: z.boolean().default(false), + muted: z.boolean().default(false), + segmentId: Id.optional(), +}); +export type EdlVideoClip = z.infer; + +export const EdlTransitionSchema = z.object({ + fromClipId: SafeId, + toClipId: SafeId, + kind: TransitionKindSchema, + durationMs: Ms.max(2000), +}); +export type EdlTransition = z.infer; + +export const EdlAudioClipSchema = z.object({ + id: SafeId, + kind: z.enum(["music", "voiceover", "sfx"]), + artifactId: Id, + inMs: Ms, + outMs: Ms, + atMs: Ms, + gainDb: z.number().min(-40).max(12).default(0), + fadeInMs: Ms.default(0), + fadeOutMs: Ms.default(0), + /** Lower this clip under speech from video clips. */ + duckUnderSpeech: z.boolean().default(false), + /** Snap cuts to this clip's beat grid. */ + beatGridMs: z.array(Ms).optional(), +}); +export type EdlAudioClip = z.infer; + +export const EdlCaptionsSchema = z.object({ + mode: CaptionModeSchema, + presetId: SafeId.optional(), + maxCharsPerLine: z.number().int().min(10).max(60).default(32), + wordHighlight: z.boolean().default(false), + language: z.string().min(2).max(10).optional(), + /** Vertical anchor; the compiler keeps the block inside the profile safe zone. */ + position: z.enum(["lower", "middle", "upper"]).default("lower"), +}); +export type EdlCaptions = z.infer; + +export const EdlOverlaySchema = z.object({ + id: SafeId, + kind: z.enum([ + "text", + "lower_third", + "logo", + "watermark", + "cta_end_card", + "progress_bar", + "motion", + ]), + atMs: Ms, + durationMs: Ms, + /** Plain text only. The compiler escapes it; it never reaches a shell. */ + text: z.string().max(200).optional(), + box: NormalizedBoxSchema.optional(), + motion: MotionTemplateRefSchema.optional(), +}); +export type EdlOverlay = z.infer; + +export const FaceRedactionSchema = z.object({ + id: SafeId, + target: z.union([ + z.object({ trackId: Id }), + z.object({ personId: Id }), + z.object({ unassignedFaces: z.literal(true) }), + ]), + mode: AnonymizeModeSchema.exclude(["none"]), + paddingPct: z.number().min(0).max(100).default(20), + featherPct: z.number().min(0).max(50).default(10), + minSizePct: z.number().min(0).max(50).default(2), +}); +export type FaceRedaction = z.infer; + +export const RegionRedactionSchema = z.object({ + id: SafeId, + clipId: SafeId, + keyframes: z.array(BoxKeyframeSchema).min(1), + treatment: MarkTreatmentSchema.exclude(["none", "inpaint"]), +}); +export type RegionRedaction = z.infer; + +export const VoiceDisguiseSchema = z.object({ + method: z.enum(["pitch", "formant"]), + semitones: z.number().min(-12).max(12), +}); +export type VoiceDisguise = z.infer; + +export const AiAlterationSchema = z.enum([ + "synthetic_identity", + "inpaint", + "voice_disguise", + "generated_voiceover", +]); +export type AiAlteration = z.infer; + +export const EDL_VERSION = 1; + +export const EdlSchema = z.object({ + edlVersion: z.literal(EDL_VERSION), + output: z.object({ + profileId: SafeId, + width: z.number().int().min(16).max(7680), + height: z.number().int().min(16).max(7680), + fps: z.number().min(1).max(120), + durationTargetMs: Ms, + durationHardMinMs: Ms, + durationHardMaxMs: Ms, + }), + tracks: z.object({ + video: z.array(EdlVideoClipSchema).min(1).max(400), + audio: z.array(EdlAudioClipSchema).max(40).default([]), + }), + transitions: z.array(EdlTransitionSchema).max(400).default([]), + targetLufs: z.number().min(-30).max(-5).optional(), + captions: EdlCaptionsSchema.optional(), + overlays: z.array(EdlOverlaySchema).max(100).default([]), + intro: MotionTemplateRefSchema.optional(), + outro: MotionTemplateRefSchema.optional(), + redactions: z + .object({ + faces: z.array(FaceRedactionSchema).max(50).default([]), + marks: z + .array(z.object({ markId: Id, treatment: MarkTreatmentSchema.exclude(["none"]) })) + .max(100) + .default([]), + regions: z.array(RegionRedactionSchema).max(100).default([]), + voice: VoiceDisguiseSchema.optional(), + }) + .default({ faces: [], marks: [], regions: [] }), + metadata: z.object({ + title: z.string().max(200).optional(), + description: z.string().max(5000).optional(), + tags: z.array(z.string().max(60)).max(50).default([]), + aiAltered: z.boolean().default(false), + aiAlterations: z.array(AiAlterationSchema).default([]), + }), +}); +export type Edl = z.infer; + +/** Machine-readable EDL validation error so an agent can correct its plan. */ +export const EdlErrorSchema = z.object({ + code: z.enum([ + "schema", + "unknown_asset", + "unknown_clip", + "unknown_artifact", + "unknown_mark", + "unknown_person", + "unknown_track", + "unknown_template", + "range_out_of_asset", + "range_empty", + "overlap", + "duration_below_min", + "duration_above_max", + "overlay_outside_safe_zone", + "paid_only_segment", + "profile_mismatch", + "music_license", + ]), + path: z.string(), + message: z.string(), +}); +export type EdlError = z.infer; + +// --------------------------------------------------------------------------- +// Render plan (compiled from an EDL; executed by MediaRenderer) +// --------------------------------------------------------------------------- + +export const RenderStepSchema = z.discriminatedUnion("kind", [ + z.object({ + kind: z.literal("ffmpeg"), + id: SafeId, + /** argv after the ffmpeg binary. Never joined into a shell string. */ + argv: z.array(z.string()), + produces: z.array(z.string()), + /** Output timeline length for progress reporting. */ + durationMs: Ms, + }), + z.object({ + kind: z.literal("motion"), + id: SafeId, + template: MotionTemplateRefSchema, + width: z.number().int(), + height: z.number().int(), + fps: z.number(), + outPath: z.string(), + }), + z.object({ + kind: z.literal("inpaint"), + id: SafeId, + markId: Id, + inputPath: z.string(), + outPath: z.string(), + }), + z.object({ + kind: z.literal("write_file"), + id: SafeId, + path: z.string(), + content: z.string(), + }), + z.object({ + kind: z.literal("synthetic_identity"), + id: SafeId, + personId: Id, + seed: z.number().int(), + inputPath: z.string(), + outPath: z.string(), + }), +]); +export type RenderStep = z.infer; + +export const RenderOutputKindSchema = z.enum(["video", "poster", "subtitles_srt", "subtitles_vtt"]); +export type RenderOutputKind = z.infer; + +export const RenderPlanSchema = z.object({ + quality: MediaRenderQualitySchema, + workDir: z.string(), + steps: z.array(RenderStepSchema), + outputs: z.array(z.object({ kind: RenderOutputKindSchema, path: z.string() })), +}); +export type RenderPlan = z.infer; + +export const FfprobeReportSchema = z.object({ + durationMs: Ms, + width: z.number().int(), + height: z.number().int(), + fps: z.number(), + videoCodec: z.string(), + audioCodec: z.string().nullable(), + sizeBytes: z.number().int(), + integratedLufs: z.number().nullable(), + truePeakDb: z.number().nullable(), + /** Container and stream tags exactly as found, for metadata-clean checks. */ + tags: z.record(z.string(), z.string()), +}); +export type FfprobeReport = z.infer; + +// --------------------------------------------------------------------------- +// Gate +// --------------------------------------------------------------------------- + +export const GateCheckSchema = z.enum([ + "technical", + "content", + "anonymization", + "consent", + "text", + "disclosure", + "brand", +]); +export type GateCheck = z.infer; + +export const GateIssueSchema = z.object({ + check: GateCheckSchema, + severity: z.enum(["block", "warn"]), + code: z.string().min(1).max(64), + message: z.string(), + atMs: Ms.optional(), + endMs: Ms.optional(), + category: ContentCategorySchema.optional(), + score: z.number().optional(), + personId: Id.optional(), + markId: Id.optional(), +}); +export type GateIssue = z.infer; + +export const GateReportSchema = z.object({ + profileId: SafeId, + passed: z.boolean(), + checkedAt: IsoDate, + sampledFrames: z.number().int().min(0), + issues: z.array(GateIssueSchema), + autoFixRound: z.number().int().min(0).max(3).default(0), +}); +export type GateReport = z.infer; + +export const MAX_AUTO_FIX_ROUNDS = 3; + +// --------------------------------------------------------------------------- +// Teaser scoring +// --------------------------------------------------------------------------- + +export const TEASER_FEATURES = [ + "motion", + "sharpness", + "faces", + "gaze", + "audioPeak", + "speechHook", + "novelty", + "beatProximity", + "aesthetics", +] as const; +export const TeaserFeatureSchema = z.enum(TEASER_FEATURES); +export type TeaserFeature = z.infer; + +export const TeaserWeightsSchema = z.record(TeaserFeatureSchema, z.number()); +export type TeaserWeights = z.infer; + +/** Minimum publications with metrics before learned weights replace defaults. */ +export const TEASER_LEARNING_MIN_SAMPLES = 20; + +// --------------------------------------------------------------------------- +// DTOs +// --------------------------------------------------------------------------- + +export const MediaSourceSchema = z.object({ + id: Id, + kind: MediaSourceKindSchema, + label: z.string(), + status: MediaSourceStatusSchema, + assetCount: z.number().int(), + lastSyncedAt: IsoDate.nullable(), + error: z.string().nullable(), + createdAt: IsoDate, +}); +export type MediaSource = z.infer; + +export const MediaAssetSchema = z.object({ + id: Id, + sourceId: Id, + kind: MediaAssetKindSchema, + storage: MediaAssetStorageSchema, + capturedAt: IsoDate.nullable(), + durationMs: Ms.nullable(), + width: z.number().int().nullable(), + height: z.number().int().nullable(), + fps: z.number().nullable(), + /** Rounded to about 1 km. */ + gpsLat: z.number().nullable(), + gpsLon: z.number().nullable(), + deviceModel: z.string().nullable(), + albumNames: z.array(z.string()), + isFavorite: z.boolean(), + ingestState: MediaIngestStateSchema, + hasProxy: z.boolean(), + hasThumbnail: z.boolean(), + qualityScore: z.number().nullable(), + createdAt: IsoDate, +}); +export type MediaAsset = z.infer; + +export const MediaSegmentSchema = z.object({ + id: Id, + assetId: Id, + kind: MediaSegmentKindSchema, + startMs: Ms, + endMs: Ms, + scores: z.partialRecord(TeaserFeatureSchema, z.number()), + teaserScore: z.number().nullable(), + transcriptExcerpt: z.string().nullable(), + labels: z.array(z.string()), + paidOnly: z.boolean(), +}); +export type MediaSegment = z.infer; + +export const MediaCollectionSchema = z.object({ + id: Id, + kind: MediaCollectionKindSchema, + title: z.string(), + labelSource: MediaLabelSourceSchema, + startAt: IsoDate.nullable(), + endAt: IsoDate.nullable(), + coverAssetId: Id.nullable(), + assetCount: z.number().int(), + videoCount: z.number().int(), + totalDurationMs: Ms, +}); +export type MediaCollection = z.infer; + +export const MediaPersonSchema = z.object({ + id: Id, + displayName: z.string(), + isSelf: z.boolean(), + confirmed: z.boolean(), + anonymizeByDefault: AnonymizeModeSchema, + consentRecordedAt: IsoDate.nullable(), + consentArtifactId: Id.nullable(), + ageVerifiedAt: IsoDate.nullable(), + ageVerificationMethod: AgeVerificationMethodSchema, + faceTrackCount: z.number().int(), + coverThumbAssetId: Id.nullable(), +}); +export type MediaPerson = z.infer; + +export const MediaFaceTrackSchema = z.object({ + id: Id, + assetId: Id, + personId: Id.nullable(), + startMs: Ms, + endMs: Ms, + confidenceMin: z.number(), +}); +export type MediaFaceTrack = z.infer; + +export const MediaMarkPromptSchema = z.object({ + tMs: Ms, + box: NormalizedBoxSchema.optional(), + point: NormalizedPointSchema.optional(), +}); +export type MediaMarkPrompt = z.infer; + +export const MediaMarkSchema = z.object({ + id: Id, + kind: MediaMarkKindSchema, + label: z.string().nullable(), + personId: Id.nullable(), + assetId: Id.nullable(), + prompt: MediaMarkPromptSchema.nullable(), + treatment: MarkTreatmentSchema, + qualityScore: z.number().nullable(), + suggestedFallback: MarkTreatmentSchema.nullable(), + createdAt: IsoDate, +}); +export type MediaMark = z.infer; + +export const BrandKitRecordSchema = z.object({ + id: Id, + kit: BrandKitSchema, + createdAt: IsoDate, + updatedAt: IsoDate, +}); +export type BrandKitRecord = z.infer; + +export const MediaProjectSchema = z.object({ + id: Id, + title: z.string(), + goal: MediaProjectGoalSchema, + status: MediaProjectStatusSchema, + brandKitId: Id.nullable(), + collectionIds: z.array(Id), + profileIds: z.array(SafeId), + createdAt: IsoDate, + updatedAt: IsoDate, +}); +export type MediaProject = z.infer; + +export const MediaEditPlanSchema = z.object({ + id: Id, + projectId: Id, + profileId: SafeId, + version: z.number().int().min(1), + edl: EdlSchema, + createdBy: z.enum(["agent", "user"]), + rationale: z.string().nullable(), + createdAt: IsoDate, +}); +export type MediaEditPlan = z.infer; + +export const MediaRenderOutputSchema = z.object({ + kind: z.enum(["video", "poster", "subtitles_srt", "subtitles_vtt", "thumbnail", "bundle"]), + /** Small outputs are artifacts; videos are streamed from the media file route. */ + artifactId: Id.nullable(), + fileName: z.string(), + sizeBytes: z.number().int(), +}); +export type MediaRenderOutput = z.infer; + +export const MediaRenderSchema = z.object({ + id: Id, + planId: Id, + projectId: Id, + profileId: SafeId, + quality: MediaRenderQualitySchema, + status: MediaRenderStatusSchema, + progress: z.number().min(0).max(1), + outputs: z.array(MediaRenderOutputSchema), + gateReport: GateReportSchema.nullable(), + ffprobe: FfprobeReportSchema.nullable(), + error: z.string().nullable(), + createdAt: IsoDate, + updatedAt: IsoDate, +}); +export type MediaRender = z.infer; + +export const UtmSchema = z.object({ + source: z.string().min(1).max(60), + medium: z.string().min(1).max(60), + campaign: z.string().min(1).max(100), + content: z.string().max(100).optional(), + term: z.string().max(100).optional(), +}); +export type Utm = z.infer; + +export const MediaPublicationSchema = z.object({ + id: Id, + renderId: Id, + profileId: SafeId, + destination: z.string(), + status: MediaPublicationStatusSchema, + scheduledAt: IsoDate.nullable(), + publishedAt: IsoDate.nullable(), + externalUrl: z.string().nullable(), + targetUrl: z.string().nullable(), + trackingUrl: z.string().nullable(), + utm: UtmSchema.nullable(), + title: z.string().nullable(), + caption: z.string().nullable(), + tags: z.array(z.string()), + thumbnailVariant: z.string().nullable(), + bundleArtifactId: Id.nullable(), + createdAt: IsoDate, +}); +export type MediaPublication = z.infer; + +export const MediaMetricSchema = z.object({ + id: Id, + publicationId: Id, + observedAt: IsoDate, + views: z.number().int().min(0), + clicks: z.number().int().min(0), + conversions: z.number().int().min(0), + revenueCents: z.number().int().min(0), + currency: z.string().length(3), + source: MediaMetricSourceSchema, +}); +export type MediaMetric = z.infer; + +export const MetricCsvMappingSchema = z.object({ + id: Id.optional(), + name: z.string().min(1).max(60), + delimiter: z.enum([",", ";", "\t"]).default(","), + columns: z.object({ + publication: z.string().min(1), + observedAt: z.string().min(1), + views: z.string().optional(), + clicks: z.string().optional(), + conversions: z.string().optional(), + revenue: z.string().optional(), + currency: z.string().optional(), + }), + /** How the publication column identifies a publication. */ + publicationKey: z.enum(["id", "tracking_url", "utm_content", "external_url"]), + revenueUnit: z.enum(["cents", "major"]).default("major"), + defaultCurrency: z.string().length(3).default("EUR"), +}); +export type MetricCsvMapping = z.infer; + +export const FunnelRowSchema = z.object({ + publicationId: Id, + projectId: Id, + profileId: SafeId, + variant: z.string().nullable(), + thumbnailVariant: z.string().nullable(), + views: z.number().int(), + clicks: z.number().int(), + conversions: z.number().int(), + revenueCents: z.number().int(), + currency: z.string(), +}); +export type FunnelRow = z.infer; + +export const MotionTemplateInfoSchema = z.object({ + id: SafeId, + kind: z.enum(["intro", "outro", "lower_third", "caption", "end_card"]), + name: z.string(), + minDurationMs: Ms, + maxDurationMs: Ms, + params: z.array( + z.object({ + name: z.string(), + type: z.enum(["string", "number", "boolean", "color"]), + required: z.boolean(), + }), + ), +}); +export type MotionTemplateInfo = z.infer; + +export const IntroVariantSchema = z.object({ + id: Id, + template: MotionTemplateRefSchema, + rationale: z.string(), + previewArtifactId: Id.nullable(), +}); +export type IntroVariant = z.infer; + +export const MarketingCopySchema = z.object({ + profileId: SafeId, + language: z.string().min(2).max(10), + title: z.string().max(200), + hook: z.string().max(200), + caption: z.string().max(5000), + hashtags: z.array(z.string().max(60)).max(50), + description: z.string().max(5000).optional(), + chapters: z.array(z.object({ atMs: Ms, title: z.string().max(100) })).optional(), + altText: z.string().max(500).optional(), + issues: z.array(GateIssueSchema), +}); +export type MarketingCopy = z.infer; + +export const MediaInferenceOperationSchema = z.enum([ + "detectScenes", + "transcribe", + "detectFaces", + "trackFaces", + "faceLandmarks", + "embedFrames", + "classifyContent", + "segmentPrompted", + "inpaintVideo", + "redact", + "synthesizeIdentity", + "audioFeatures", + "saliency", +]); +export type MediaInferenceOperation = z.infer; + +export const MediaCapabilitiesSchema = z.object({ + configured: z.boolean(), + operations: z.record( + MediaInferenceOperationSchema, + z.object({ + available: z.boolean(), + experimental: z.boolean(), + model: z.string().nullable(), + license: z.string().nullable(), + commercialUse: z.boolean().nullable(), + }), + ), + accelerator: z.enum(["cpu", "cuda", "rocm", "metal", "none"]), + hardwareEncoders: z.array(z.string()), +}); +export type MediaCapabilities = z.infer; + +/** Metadata a client-pushed source (Apple Photos, iOS library) reports per asset. */ +export const ClientMediaItemSchema = z.object({ + externalId: z.string().min(1).max(200), + kind: MediaAssetKindSchema, + capturedAt: IsoDate.nullable(), + durationMs: Ms.nullable(), + width: z.number().int().nullable(), + height: z.number().int().nullable(), + fps: z.number().nullable(), + gpsLat: z.number().min(-90).max(90).nullable(), + gpsLon: z.number().min(-180).max(180).nullable(), + deviceModel: z.string().max(100).nullable(), + albumNames: z.array(z.string().max(200)).max(50).default([]), + isFavorite: z.boolean().default(false), + fileName: z.string().max(255).nullable(), +}); +export type ClientMediaItem = z.infer; + +/** Upload chunks are sent as raw bytes to the media upload route. */ +export const MEDIA_UPLOAD_CHUNK_BYTES = 8 * 1024 * 1024; +export const MEDIA_UPLOAD_MAX_BYTES = 20 * 1024 * 1024 * 1024; + +export const MediaUploadSchema = z.object({ + id: Id, + assetId: Id, + sizeBytes: z.number().int().positive(), + chunkBytes: z.number().int().positive(), + receivedChunks: z.array(z.number().int().min(0)), + status: z.enum(["open", "complete", "failed"]), +}); +export type MediaUpload = z.infer; + +// --------------------------------------------------------------------------- +// RPC inputs +// --------------------------------------------------------------------------- + +export const PageInput = z.object({ + cursor: z.string().max(200).nullable().default(null), + limit: z.number().int().min(1).max(500).default(100), +}); + +export const MediaAssetQuerySchema = PageInput.extend({ + text: z.string().max(200).optional(), + kinds: z.array(MediaAssetKindSchema).optional(), + from: IsoDate.optional(), + to: IsoDate.optional(), + near: z + .object({ lat: z.number(), lon: z.number(), radiusKm: z.number().positive().max(500) }) + .optional(), + personId: Id.optional(), + collectionId: Id.optional(), + sourceId: Id.optional(), + minQuality: z.number().min(0).max(1).optional(), + favoritesOnly: z.boolean().optional(), +}); +export type MediaAssetQuery = z.infer; + +export const ConsentInputSchema = z.object({ + personId: Id, + consentArtifactId: Id.nullable(), + consentRecordedAt: IsoDate, + ageVerificationMethod: AgeVerificationMethodSchema, + ageVerifiedAt: IsoDate.nullable(), +}); +export type ConsentInput = z.infer; + +export const CreateMediaProjectInput = z.object({ + title: z.string().trim().min(1).max(120), + goal: MediaProjectGoalSchema, + collectionIds: z.array(Id).max(50).default([]), + assetIds: z.array(Id).max(2000).default([]), + profileIds: z.array(SafeId).min(1).max(12), + brandKitId: Id.nullable().default(null), +}); +export type CreateMediaProjectInputType = z.infer; + +export const UpdateMediaProjectInput = z.object({ + projectId: Id, + title: z.string().trim().min(1).max(120).optional(), + goal: MediaProjectGoalSchema.optional(), + collectionIds: z.array(Id).max(50).optional(), + profileIds: z.array(SafeId).min(1).max(12).optional(), + brandKitId: Id.nullable().optional(), + status: MediaProjectStatusSchema.optional(), +}); + +export const CreateMarkInput = z.object({ + kind: MediaMarkKindSchema, + label: z.string().max(80).nullable().default(null), + personId: Id.nullable().default(null), + assetId: Id, + prompt: MediaMarkPromptSchema, + treatment: MarkTreatmentSchema, +}); + +const ok = z.object({ ok: z.literal(true) }); +const page = (item: T) => + z.object({ items: z.array(item), nextCursor: z.string().nullable() }); + +/** + * Plain-object RPC definitions. rpc.ts wraps them with `oc` so this module + * stays free of the oRPC dependency and can be imported by packages/media. + */ +export const mediaRpcShapes = { + capabilities: { input: z.object({}).default({}), output: MediaCapabilitiesSchema }, + sources: { + list: { input: z.object({}).default({}), output: z.array(MediaSourceSchema) }, + createFolder: { + input: z.object({ + label: z.string().trim().min(1).max(60), + path: z.string().min(1).max(1000), + }), + output: MediaSourceSchema, + }, + registerClient: { + input: z.object({ + kind: z.enum(CLIENT_PUSHED_MEDIA_SOURCES), + label: z.string().trim().min(1).max(60), + deviceId: z.string().min(1).max(200), + }), + output: MediaSourceSchema, + }, + pushItems: { + input: z.object({ + sourceId: Id, + items: z.array(ClientMediaItemSchema).min(1).max(500), + /** Opaque change token from the device for the next incremental push. */ + syncToken: z.string().max(2000).nullable(), + complete: z.boolean(), + }), + output: z.object({ + created: z.number().int(), + updated: z.number().int(), + unchanged: z.number().int(), + }), + }, + sync: { input: z.object({ sourceId: Id }), output: ok }, + remove: { input: z.object({ sourceId: Id }), output: ok }, + }, + assets: { + search: { input: MediaAssetQuerySchema, output: page(MediaAssetSchema) }, + get: { + input: z.object({ assetId: Id }), + output: z.object({ + asset: MediaAssetSchema, + segments: z.array(MediaSegmentSchema), + faceTracks: z.array(MediaFaceTrackSchema), + transcript: z.string().nullable(), + }), + }, + setPaidOnly: { + input: z.object({ segmentIds: z.array(Id).min(1).max(500), paidOnly: z.boolean() }), + output: ok, + }, + requestUpload: { + input: z.object({ assetIds: z.array(Id).min(1).max(2000) }), + output: z.object({ awaiting: z.array(Id) }), + }, + }, + uploads: { + begin: { + input: z.object({ + assetId: Id, + sizeBytes: z.number().int().positive().max(MEDIA_UPLOAD_MAX_BYTES), + sha256: z.string().regex(/^[0-9a-f]{64}$/), + fileName: z.string().min(1).max(255), + }), + output: MediaUploadSchema, + }, + status: { input: z.object({ uploadId: Id }), output: MediaUploadSchema }, + complete: { input: z.object({ uploadId: Id }), output: MediaUploadSchema }, + }, + collections: { + list: { + input: PageInput.extend({ kind: MediaCollectionKindSchema.optional() }), + output: page(MediaCollectionSchema), + }, + get: { + input: z.object({ collectionId: Id }), + output: z.object({ collection: MediaCollectionSchema, assets: z.array(MediaAssetSchema) }), + }, + rename: { + input: z.object({ collectionId: Id, title: z.string().trim().min(1).max(120) }), + output: MediaCollectionSchema, + }, + merge: { + input: z.object({ collectionIds: z.array(Id).min(2).max(20) }), + output: MediaCollectionSchema, + }, + split: { + input: z.object({ collectionId: Id, atAssetId: Id }), + output: z.array(MediaCollectionSchema), + }, + createManual: { + input: z.object({ + title: z.string().trim().min(1).max(120), + assetIds: z.array(Id).min(1).max(2000), + }), + output: MediaCollectionSchema, + }, + recluster: { input: z.object({}).default({}), output: ok }, + }, + people: { + list: { input: z.object({}).default({}), output: z.array(MediaPersonSchema) }, + update: { + input: z.object({ + personId: Id, + displayName: z.string().trim().min(1).max(60).optional(), + isSelf: z.boolean().optional(), + confirmed: z.boolean().optional(), + anonymizeByDefault: AnonymizeModeSchema.optional(), + }), + output: MediaPersonSchema, + }, + merge: { + input: z.object({ personIds: z.array(Id).min(2).max(20) }), + output: MediaPersonSchema, + }, + assignTrack: { + input: z.object({ trackId: Id, personId: Id.nullable() }), + output: MediaFaceTrackSchema, + }, + /** User-only. Agent tools never reach this procedure. */ + recordConsent: { input: ConsentInputSchema, output: MediaPersonSchema }, + /** Deletes the person and every biometric record tied to them. */ + remove: { input: z.object({ personId: Id }), output: ok }, + }, + marks: { + list: { + input: z.object({ personId: Id.optional(), assetId: Id.optional() }), + output: z.array(MediaMarkSchema), + }, + create: { input: CreateMarkInput, output: MediaMarkSchema }, + update: { + input: z.object({ + markId: Id, + treatment: MarkTreatmentSchema.optional(), + label: z.string().max(80).nullable().optional(), + personId: Id.nullable().optional(), + }), + output: MediaMarkSchema, + }, + remove: { input: z.object({ markId: Id }), output: ok }, + }, + brandKits: { + list: { input: z.object({}).default({}), output: z.array(BrandKitRecordSchema) }, + upsert: { + input: z.object({ id: Id.optional(), kit: BrandKitSchema }), + output: BrandKitRecordSchema, + }, + remove: { input: z.object({ id: Id }), output: ok }, + templates: { input: z.object({}).default({}), output: z.array(MotionTemplateInfoSchema) }, + introVariants: { + input: z.object({ id: Id, count: z.number().int().min(1).max(5).default(3) }), + output: z.array(IntroVariantSchema), + }, + }, + profiles: { + list: { input: z.object({}).default({}), output: z.array(PlatformProfileSummarySchema) }, + get: { input: z.object({ profileId: SafeId }), output: PlatformProfileSchema }, + }, + projects: { + list: { input: PageInput, output: page(MediaProjectSchema) }, + get: { + input: z.object({ projectId: Id }), + output: z.object({ + project: MediaProjectSchema, + plans: z.array(MediaEditPlanSchema), + renders: z.array(MediaRenderSchema), + }), + }, + create: { input: CreateMediaProjectInput, output: MediaProjectSchema }, + update: { input: UpdateMediaProjectInput, output: MediaProjectSchema }, + remove: { input: z.object({ projectId: Id }), output: ok }, + /** Deterministic first-cut plans per profile (agent refines them through media_plan). */ + autoPlan: { + input: z.object({ projectId: Id, profileIds: z.array(SafeId).max(12).optional() }), + output: z.array(MediaEditPlanSchema), + }, + }, + plans: { + submit: { + input: z.object({ + projectId: Id, + edl: z.unknown(), + rationale: z.string().max(4000).nullable().default(null), + }), + output: z.union([ + z.object({ ok: z.literal(true), plan: MediaEditPlanSchema }), + z.object({ ok: z.literal(false), errors: z.array(EdlErrorSchema) }), + ]), + }, + }, + renders: { + start: { + input: z.object({ planId: Id, quality: MediaRenderQualitySchema }), + output: MediaRenderSchema, + }, + get: { input: z.object({ renderId: Id }), output: MediaRenderSchema }, + cancel: { input: z.object({ renderId: Id }), output: MediaRenderSchema }, + thumbnails: { + input: z.object({ renderId: Id, count: z.number().int().min(1).max(6).default(3) }), + output: z.array(MediaRenderOutputSchema), + }, + }, + marketing: { + copy: { + input: z.object({ renderId: Id, language: z.string().min(2).max(10).default("en") }), + output: MarketingCopySchema, + }, + }, + publications: { + list: { + input: PageInput.extend({ projectId: Id.optional() }), + output: page(MediaPublicationSchema), + }, + create: { + input: z.object({ + renderId: Id, + destination: z.string().min(1).max(60).default("bundle"), + targetUrl: z.string().url().max(2000).nullable().default(null), + utm: UtmSchema.nullable().default(null), + title: z.string().max(200).nullable().default(null), + caption: z.string().max(5000).nullable().default(null), + tags: z.array(z.string().max(60)).max(50).default([]), + thumbnailVariant: z.string().max(40).nullable().default(null), + scheduledAt: IsoDate.nullable().default(null), + }), + output: MediaPublicationSchema, + }, + bundle: { input: z.object({ publicationId: Id }), output: MediaPublicationSchema }, + markPublished: { + input: z.object({ publicationId: Id, externalUrl: z.string().url().max(2000).nullable() }), + output: MediaPublicationSchema, + }, + cancel: { input: z.object({ publicationId: Id }), output: MediaPublicationSchema }, + }, + metrics: { + mappings: { input: z.object({}).default({}), output: z.array(MetricCsvMappingSchema) }, + saveMapping: { input: MetricCsvMappingSchema, output: MetricCsvMappingSchema }, + importCsv: { + input: z.object({ mappingId: Id, csv: z.string().min(1).max(5_000_000) }), + output: z.object({ + imported: z.number().int(), + skipped: z.array(z.object({ row: z.number().int(), reason: z.string() })), + }), + }, + record: { + input: MediaMetricSchema.omit({ id: true, source: true }), + output: MediaMetricSchema, + }, + funnel: { + input: z.object({ projectId: Id.optional() }), + output: z.array(FunnelRowSchema), + }, + }, +} as const; + +// --------------------------------------------------------------------------- +// Agent tools +// --------------------------------------------------------------------------- + +export const MEDIA_TOOL_NAMES = [ + "media_search", + "media_collections", + "media_project", + "media_plan", + "media_render", + "media_gate_report", + "media_people", + "media_marks", + "media_brand_kit", + "media_copy", + "media_thumbnails", + "media_publish", + "media_metrics", +] as const; +export type MediaToolName = (typeof MEDIA_TOOL_NAMES)[number]; + +const help = { help: z.boolean().optional() }; + +export const MediaToolInputSchemas = { + media_search: MediaAssetQuerySchema.extend({ + ...help, + themeText: z.string().max(200).optional(), + }), + media_collections: z.object({ + ...help, + action: z.enum(["list", "get", "rename", "merge", "split"]), + kind: MediaCollectionKindSchema.optional(), + collectionId: Id.optional(), + collectionIds: z.array(Id).max(20).optional(), + atAssetId: Id.optional(), + title: z.string().max(120).optional(), + }), + media_project: z.object({ + ...help, + action: z.enum(["create", "update", "get", "list", "auto_plan"]), + projectId: Id.optional(), + title: z.string().max(120).optional(), + goal: MediaProjectGoalSchema.optional(), + collectionIds: z.array(Id).max(50).optional(), + assetIds: z.array(Id).max(2000).optional(), + profileIds: z.array(SafeId).max(12).optional(), + brandKitId: Id.nullable().optional(), + }), + media_plan: z.object({ + ...help, + projectId: Id.optional(), + edl: z.unknown().optional(), + rationale: z.string().max(4000).optional(), + }), + media_render: z.object({ + ...help, + action: z.enum(["start", "status", "cancel"]), + planId: Id.optional(), + renderId: Id.optional(), + quality: MediaRenderQualitySchema.optional(), + }), + media_gate_report: z.object({ ...help, renderId: Id.optional() }), + media_people: z.object({ + ...help, + action: z.enum(["list", "update", "merge"]), + personId: Id.optional(), + personIds: z.array(Id).max(20).optional(), + displayName: z.string().max(60).optional(), + anonymizeByDefault: AnonymizeModeSchema.optional(), + }), + media_marks: z.object({ + ...help, + action: z.enum(["list", "create", "update", "remove"]), + markId: Id.optional(), + kind: MediaMarkKindSchema.optional(), + personId: Id.nullable().optional(), + assetId: Id.optional(), + prompt: MediaMarkPromptSchema.optional(), + treatment: MarkTreatmentSchema.optional(), + label: z.string().max(80).optional(), + }), + media_brand_kit: z.object({ + ...help, + action: z.enum(["list", "upsert", "intro_variants", "templates"]), + id: Id.optional(), + kit: BrandKitSchema.optional(), + count: z.number().int().min(1).max(5).optional(), + }), + media_copy: z.object({ + ...help, + renderId: Id.optional(), + language: z.string().max(10).optional(), + }), + media_thumbnails: z.object({ + ...help, + renderId: Id.optional(), + count: z.number().int().min(1).max(6).optional(), + }), + media_publish: z.object({ + ...help, + action: z.enum(["create", "bundle", "list", "cancel"]), + renderId: Id.optional(), + publicationId: Id.optional(), + projectId: Id.optional(), + targetUrl: z.string().url().max(2000).optional(), + utm: UtmSchema.optional(), + title: z.string().max(200).optional(), + caption: z.string().max(5000).optional(), + tags: z.array(z.string().max(60)).max(50).optional(), + thumbnailVariant: z.string().max(40).optional(), + scheduledAt: IsoDate.optional(), + destination: z.string().max(60).optional(), + }), + media_metrics: z.object({ + ...help, + action: z.enum(["funnel", "record"]), + projectId: Id.optional(), + metric: MediaMetricSchema.omit({ id: true, source: true }).optional(), + }), +} satisfies Record; diff --git a/packages/contracts/src/rpc.ts b/packages/contracts/src/rpc.ts index 6640d4c..638a943 100644 --- a/packages/contracts/src/rpc.ts +++ b/packages/contracts/src/rpc.ts @@ -82,6 +82,7 @@ import { IntegrationProviderConfigSchema, IntegrationSetupStateSchema, } from "./integration-settings.js"; +import { mediaRpcShapes } from "./media.js"; import { ModelTierMappingSchema, SetModelTierInput } from "./model-tiers.js"; import { CreateProjectGoalInput, @@ -157,6 +158,10 @@ const threadSendInput = threadTarget } }); +function rpc(shape: { input: I; output: O }) { + return oc.input(shape.input).output(shape.output); +} + export const appContract = { aiConsent: { status: oc.input(AiConsentQuerySchema).output(AiConsentStatusSchema), @@ -861,6 +866,96 @@ export const appContract = { put: oc.input(AgentSecretInputSchema).output(AgentSecretSchema), remove: oc.input(z.object({ id: Id })).output(z.object({ ok: z.literal(true) })), }, + media: { + capabilities: rpc(mediaRpcShapes.capabilities), + sources: { + list: rpc(mediaRpcShapes.sources.list), + createFolder: rpc(mediaRpcShapes.sources.createFolder), + registerClient: rpc(mediaRpcShapes.sources.registerClient), + pushItems: rpc(mediaRpcShapes.sources.pushItems), + sync: rpc(mediaRpcShapes.sources.sync), + remove: rpc(mediaRpcShapes.sources.remove), + }, + assets: { + search: rpc(mediaRpcShapes.assets.search), + get: rpc(mediaRpcShapes.assets.get), + setPaidOnly: rpc(mediaRpcShapes.assets.setPaidOnly), + requestUpload: rpc(mediaRpcShapes.assets.requestUpload), + }, + uploads: { + begin: rpc(mediaRpcShapes.uploads.begin), + status: rpc(mediaRpcShapes.uploads.status), + complete: rpc(mediaRpcShapes.uploads.complete), + }, + collections: { + list: rpc(mediaRpcShapes.collections.list), + get: rpc(mediaRpcShapes.collections.get), + rename: rpc(mediaRpcShapes.collections.rename), + merge: rpc(mediaRpcShapes.collections.merge), + split: rpc(mediaRpcShapes.collections.split), + createManual: rpc(mediaRpcShapes.collections.createManual), + recluster: rpc(mediaRpcShapes.collections.recluster), + }, + people: { + list: rpc(mediaRpcShapes.people.list), + update: rpc(mediaRpcShapes.people.update), + merge: rpc(mediaRpcShapes.people.merge), + assignTrack: rpc(mediaRpcShapes.people.assignTrack), + recordConsent: rpc(mediaRpcShapes.people.recordConsent), + remove: rpc(mediaRpcShapes.people.remove), + }, + marks: { + list: rpc(mediaRpcShapes.marks.list), + create: rpc(mediaRpcShapes.marks.create), + update: rpc(mediaRpcShapes.marks.update), + remove: rpc(mediaRpcShapes.marks.remove), + }, + brandKits: { + list: rpc(mediaRpcShapes.brandKits.list), + upsert: rpc(mediaRpcShapes.brandKits.upsert), + remove: rpc(mediaRpcShapes.brandKits.remove), + templates: rpc(mediaRpcShapes.brandKits.templates), + introVariants: rpc(mediaRpcShapes.brandKits.introVariants), + }, + profiles: { + list: rpc(mediaRpcShapes.profiles.list), + get: rpc(mediaRpcShapes.profiles.get), + }, + projects: { + list: rpc(mediaRpcShapes.projects.list), + get: rpc(mediaRpcShapes.projects.get), + create: rpc(mediaRpcShapes.projects.create), + update: rpc(mediaRpcShapes.projects.update), + remove: rpc(mediaRpcShapes.projects.remove), + autoPlan: rpc(mediaRpcShapes.projects.autoPlan), + }, + plans: { + submit: rpc(mediaRpcShapes.plans.submit), + }, + renders: { + start: rpc(mediaRpcShapes.renders.start), + get: rpc(mediaRpcShapes.renders.get), + cancel: rpc(mediaRpcShapes.renders.cancel), + thumbnails: rpc(mediaRpcShapes.renders.thumbnails), + }, + marketing: { + copy: rpc(mediaRpcShapes.marketing.copy), + }, + publications: { + list: rpc(mediaRpcShapes.publications.list), + create: rpc(mediaRpcShapes.publications.create), + bundle: rpc(mediaRpcShapes.publications.bundle), + markPublished: rpc(mediaRpcShapes.publications.markPublished), + cancel: rpc(mediaRpcShapes.publications.cancel), + }, + metrics: { + mappings: rpc(mediaRpcShapes.metrics.mappings), + saveMapping: rpc(mediaRpcShapes.metrics.saveMapping), + importCsv: rpc(mediaRpcShapes.metrics.importCsv), + record: rpc(mediaRpcShapes.metrics.record), + funnel: rpc(mediaRpcShapes.metrics.funnel), + }, + }, }; export type AppContract = typeof appContract; diff --git a/packages/db/prisma/migrations/20261003172425_media_studio/migration.sql b/packages/db/prisma/migrations/20261003172425_media_studio/migration.sql new file mode 100644 index 0000000..39e254e --- /dev/null +++ b/packages/db/prisma/migrations/20261003172425_media_studio/migration.sql @@ -0,0 +1,513 @@ +-- CreateTable +CREATE TABLE "media_sources" ( + "id" TEXT NOT NULL, + "spaceId" TEXT NOT NULL, + "ownerUserId" TEXT NOT NULL, + "kind" TEXT NOT NULL, + "label" TEXT NOT NULL, + "config" JSONB NOT NULL DEFAULT '{}', + "status" TEXT NOT NULL DEFAULT 'idle', + "error" TEXT, + "lastSyncCursor" TEXT, + "lastSyncedAt" TIMESTAMP(3), + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "media_sources_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "media_assets" ( + "id" TEXT NOT NULL, + "spaceId" TEXT NOT NULL, + "sourceId" TEXT NOT NULL, + "externalId" TEXT NOT NULL, + "revision" TEXT, + "contentHash" TEXT, + "kind" TEXT NOT NULL, + "storage" TEXT NOT NULL, + "storagePath" TEXT, + "fileName" TEXT, + "sizeBytes" BIGINT, + "capturedAt" TIMESTAMP(3), + "durationMs" INTEGER, + "width" INTEGER, + "height" INTEGER, + "fps" DOUBLE PRECISION, + "codec" TEXT, + "orientation" INTEGER, + "gpsLat" DOUBLE PRECISION, + "gpsLon" DOUBLE PRECISION, + "deviceModel" TEXT, + "albumNames" TEXT[] DEFAULT ARRAY[]::TEXT[], + "isFavorite" BOOLEAN NOT NULL DEFAULT false, + "proxyPath" TEXT, + "thumbPath" TEXT, + "spritePath" TEXT, + "qualityScore" DOUBLE PRECISION, + "analysisVersion" INTEGER NOT NULL DEFAULT 0, + "ingestState" TEXT NOT NULL DEFAULT 'indexed', + "ingestError" TEXT, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "media_assets_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "media_uploads" ( + "id" TEXT NOT NULL, + "spaceId" TEXT NOT NULL, + "assetId" TEXT NOT NULL, + "userId" TEXT NOT NULL, + "sizeBytes" BIGINT NOT NULL, + "chunkBytes" INTEGER NOT NULL, + "sha256" TEXT NOT NULL, + "fileName" TEXT NOT NULL, + "receivedChunks" INTEGER[] DEFAULT ARRAY[]::INTEGER[], + "status" TEXT NOT NULL DEFAULT 'open', + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "media_uploads_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "media_segments" ( + "id" TEXT NOT NULL, + "spaceId" TEXT NOT NULL, + "assetId" TEXT NOT NULL, + "kind" TEXT NOT NULL, + "startMs" INTEGER NOT NULL, + "endMs" INTEGER NOT NULL, + "scores" JSONB NOT NULL DEFAULT '{}', + "teaserScore" DOUBLE PRECISION, + "transcriptExcerpt" TEXT, + "labels" TEXT[] DEFAULT ARRAY[]::TEXT[], + "paidOnly" BOOLEAN NOT NULL DEFAULT false, + "analysisVersion" INTEGER NOT NULL, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "media_segments_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "media_analyses" ( + "id" TEXT NOT NULL, + "spaceId" TEXT NOT NULL, + "assetId" TEXT NOT NULL, + "kind" TEXT NOT NULL, + "modelId" TEXT NOT NULL, + "modelVersion" TEXT NOT NULL, + "data" JSONB, + "dataPath" TEXT, + "vectors" BYTEA, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "media_analyses_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "media_collections" ( + "id" TEXT NOT NULL, + "spaceId" TEXT NOT NULL, + "kind" TEXT NOT NULL, + "title" TEXT NOT NULL, + "labelSource" TEXT NOT NULL DEFAULT 'rule', + "startAt" TIMESTAMP(3), + "endAt" TIMESTAMP(3), + "centroidLat" DOUBLE PRECISION, + "centroidLon" DOUBLE PRECISION, + "coverAssetId" TEXT, + "clusterKey" TEXT, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "media_collections_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "media_collection_items" ( + "collectionId" TEXT NOT NULL, + "assetId" TEXT NOT NULL, + "position" INTEGER NOT NULL, + + CONSTRAINT "media_collection_items_pkey" PRIMARY KEY ("collectionId","assetId") +); + +-- CreateTable +CREATE TABLE "media_people" ( + "id" TEXT NOT NULL, + "spaceId" TEXT NOT NULL, + "displayName" TEXT NOT NULL, + "isSelf" BOOLEAN NOT NULL DEFAULT false, + "confirmed" BOOLEAN NOT NULL DEFAULT false, + "anonymizeByDefault" TEXT NOT NULL DEFAULT 'none', + "centroid" BYTEA, + "consentArtifactId" TEXT, + "consentRecordedAt" TIMESTAMP(3), + "consentRecordedBy" TEXT, + "ageVerifiedAt" TIMESTAMP(3), + "ageVerificationMethod" TEXT NOT NULL DEFAULT 'none', + "syntheticSeed" INTEGER, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "media_people_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "media_face_tracks" ( + "id" TEXT NOT NULL, + "spaceId" TEXT NOT NULL, + "assetId" TEXT NOT NULL, + "personId" TEXT, + "startMs" INTEGER NOT NULL, + "endMs" INTEGER NOT NULL, + "confidenceMin" DOUBLE PRECISION NOT NULL, + "keyframesPath" TEXT NOT NULL, + "embedding" BYTEA, + "modelVersion" TEXT NOT NULL, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "media_face_tracks_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "media_marks" ( + "id" TEXT NOT NULL, + "spaceId" TEXT NOT NULL, + "kind" TEXT NOT NULL, + "label" TEXT, + "personId" TEXT, + "assetId" TEXT, + "prompt" JSONB, + "treatment" TEXT NOT NULL, + "maskDir" TEXT, + "qualityScore" DOUBLE PRECISION, + "suggestedFallback" TEXT, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "media_marks_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "media_brand_kits" ( + "id" TEXT NOT NULL, + "spaceId" TEXT NOT NULL, + "kit" JSONB NOT NULL, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "media_brand_kits_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "media_projects" ( + "id" TEXT NOT NULL, + "spaceId" TEXT NOT NULL, + "createdBy" TEXT NOT NULL, + "title" TEXT NOT NULL, + "goal" TEXT NOT NULL, + "status" TEXT NOT NULL DEFAULT 'draft', + "brandKitId" TEXT, + "collectionIds" TEXT[] DEFAULT ARRAY[]::TEXT[], + "assetIds" TEXT[] DEFAULT ARRAY[]::TEXT[], + "profileIds" TEXT[] DEFAULT ARRAY[]::TEXT[], + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "media_projects_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "media_edit_plans" ( + "id" TEXT NOT NULL, + "spaceId" TEXT NOT NULL, + "projectId" TEXT NOT NULL, + "profileId" TEXT NOT NULL, + "version" INTEGER NOT NULL, + "edl" JSONB NOT NULL, + "createdBy" TEXT NOT NULL, + "rationale" TEXT, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "media_edit_plans_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "media_renders" ( + "id" TEXT NOT NULL, + "spaceId" TEXT NOT NULL, + "projectId" TEXT NOT NULL, + "planId" TEXT NOT NULL, + "profileId" TEXT NOT NULL, + "quality" TEXT NOT NULL, + "status" TEXT NOT NULL DEFAULT 'queued', + "progress" DOUBLE PRECISION NOT NULL DEFAULT 0, + "outputs" JSONB NOT NULL DEFAULT '[]', + "gateReport" JSONB, + "ffprobe" JSONB, + "autoFixRound" INTEGER NOT NULL DEFAULT 0, + "error" TEXT, + "createdBy" TEXT NOT NULL, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "media_renders_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "media_publications" ( + "id" TEXT NOT NULL, + "spaceId" TEXT NOT NULL, + "renderId" TEXT NOT NULL, + "profileId" TEXT NOT NULL, + "destination" TEXT NOT NULL, + "status" TEXT NOT NULL DEFAULT 'draft', + "scheduledAt" TIMESTAMP(3), + "publishedAt" TIMESTAMP(3), + "externalUrl" TEXT, + "targetUrl" TEXT, + "trackingUrl" TEXT, + "utm" JSONB, + "title" TEXT, + "caption" TEXT, + "tags" TEXT[] DEFAULT ARRAY[]::TEXT[], + "thumbnailVariant" TEXT, + "bundleArtifactId" TEXT, + "createdBy" TEXT NOT NULL, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "media_publications_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "media_metrics" ( + "id" TEXT NOT NULL, + "spaceId" TEXT NOT NULL, + "publicationId" TEXT NOT NULL, + "observedAt" TIMESTAMP(3) NOT NULL, + "views" INTEGER NOT NULL DEFAULT 0, + "clicks" INTEGER NOT NULL DEFAULT 0, + "conversions" INTEGER NOT NULL DEFAULT 0, + "revenueCents" INTEGER NOT NULL DEFAULT 0, + "currency" TEXT NOT NULL DEFAULT 'EUR', + "source" TEXT NOT NULL, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "media_metrics_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "media_metric_mappings" ( + "id" TEXT NOT NULL, + "spaceId" TEXT NOT NULL, + "name" TEXT NOT NULL, + "mapping" JSONB NOT NULL, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "media_metric_mappings_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "media_teaser_models" ( + "spaceId" TEXT NOT NULL, + "weights" JSONB NOT NULL, + "sampleCount" INTEGER NOT NULL, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "media_teaser_models_pkey" PRIMARY KEY ("spaceId") +); + +-- CreateIndex +CREATE INDEX "media_sources_spaceId_createdAt_idx" ON "media_sources"("spaceId", "createdAt"); + +-- CreateIndex +CREATE INDEX "media_assets_spaceId_capturedAt_idx" ON "media_assets"("spaceId", "capturedAt"); + +-- CreateIndex +CREATE INDEX "media_assets_spaceId_kind_capturedAt_idx" ON "media_assets"("spaceId", "kind", "capturedAt"); + +-- CreateIndex +CREATE INDEX "media_assets_spaceId_ingestState_idx" ON "media_assets"("spaceId", "ingestState"); + +-- CreateIndex +CREATE INDEX "media_assets_contentHash_idx" ON "media_assets"("contentHash"); + +-- CreateIndex +CREATE UNIQUE INDEX "media_assets_sourceId_externalId_key" ON "media_assets"("sourceId", "externalId"); + +-- CreateIndex +CREATE INDEX "media_uploads_assetId_idx" ON "media_uploads"("assetId"); + +-- CreateIndex +CREATE INDEX "media_uploads_spaceId_status_idx" ON "media_uploads"("spaceId", "status"); + +-- CreateIndex +CREATE INDEX "media_segments_assetId_startMs_idx" ON "media_segments"("assetId", "startMs"); + +-- CreateIndex +CREATE INDEX "media_segments_spaceId_teaserScore_idx" ON "media_segments"("spaceId", "teaserScore"); + +-- CreateIndex +CREATE INDEX "media_analyses_spaceId_kind_idx" ON "media_analyses"("spaceId", "kind"); + +-- CreateIndex +CREATE UNIQUE INDEX "media_analyses_assetId_kind_modelVersion_key" ON "media_analyses"("assetId", "kind", "modelVersion"); + +-- CreateIndex +CREATE INDEX "media_collections_spaceId_kind_startAt_idx" ON "media_collections"("spaceId", "kind", "startAt"); + +-- CreateIndex +CREATE UNIQUE INDEX "media_collections_spaceId_kind_clusterKey_key" ON "media_collections"("spaceId", "kind", "clusterKey"); + +-- CreateIndex +CREATE INDEX "media_collection_items_assetId_idx" ON "media_collection_items"("assetId"); + +-- CreateIndex +CREATE INDEX "media_people_spaceId_idx" ON "media_people"("spaceId"); + +-- CreateIndex +CREATE INDEX "media_face_tracks_assetId_idx" ON "media_face_tracks"("assetId"); + +-- CreateIndex +CREATE INDEX "media_face_tracks_personId_idx" ON "media_face_tracks"("personId"); + +-- CreateIndex +CREATE INDEX "media_marks_spaceId_personId_idx" ON "media_marks"("spaceId", "personId"); + +-- CreateIndex +CREATE INDEX "media_marks_assetId_idx" ON "media_marks"("assetId"); + +-- CreateIndex +CREATE INDEX "media_brand_kits_spaceId_idx" ON "media_brand_kits"("spaceId"); + +-- CreateIndex +CREATE INDEX "media_projects_spaceId_updatedAt_idx" ON "media_projects"("spaceId", "updatedAt"); + +-- CreateIndex +CREATE UNIQUE INDEX "media_edit_plans_projectId_profileId_version_key" ON "media_edit_plans"("projectId", "profileId", "version"); + +-- CreateIndex +CREATE INDEX "media_renders_projectId_createdAt_idx" ON "media_renders"("projectId", "createdAt"); + +-- CreateIndex +CREATE INDEX "media_renders_spaceId_status_idx" ON "media_renders"("spaceId", "status"); + +-- CreateIndex +CREATE INDEX "media_publications_spaceId_createdAt_idx" ON "media_publications"("spaceId", "createdAt"); + +-- CreateIndex +CREATE INDEX "media_publications_renderId_idx" ON "media_publications"("renderId"); + +-- CreateIndex +CREATE INDEX "media_metrics_spaceId_observedAt_idx" ON "media_metrics"("spaceId", "observedAt"); + +-- CreateIndex +CREATE UNIQUE INDEX "media_metrics_publicationId_observedAt_source_key" ON "media_metrics"("publicationId", "observedAt", "source"); + +-- CreateIndex +CREATE UNIQUE INDEX "media_metric_mappings_spaceId_name_key" ON "media_metric_mappings"("spaceId", "name"); + +-- AddForeignKey +ALTER TABLE "media_sources" ADD CONSTRAINT "media_sources_spaceId_fkey" FOREIGN KEY ("spaceId") REFERENCES "spaces"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "media_assets" ADD CONSTRAINT "media_assets_spaceId_fkey" FOREIGN KEY ("spaceId") REFERENCES "spaces"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "media_assets" ADD CONSTRAINT "media_assets_sourceId_fkey" FOREIGN KEY ("sourceId") REFERENCES "media_sources"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "media_uploads" ADD CONSTRAINT "media_uploads_spaceId_fkey" FOREIGN KEY ("spaceId") REFERENCES "spaces"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "media_uploads" ADD CONSTRAINT "media_uploads_assetId_fkey" FOREIGN KEY ("assetId") REFERENCES "media_assets"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "media_segments" ADD CONSTRAINT "media_segments_spaceId_fkey" FOREIGN KEY ("spaceId") REFERENCES "spaces"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "media_segments" ADD CONSTRAINT "media_segments_assetId_fkey" FOREIGN KEY ("assetId") REFERENCES "media_assets"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "media_analyses" ADD CONSTRAINT "media_analyses_spaceId_fkey" FOREIGN KEY ("spaceId") REFERENCES "spaces"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "media_analyses" ADD CONSTRAINT "media_analyses_assetId_fkey" FOREIGN KEY ("assetId") REFERENCES "media_assets"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "media_collections" ADD CONSTRAINT "media_collections_spaceId_fkey" FOREIGN KEY ("spaceId") REFERENCES "spaces"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "media_collection_items" ADD CONSTRAINT "media_collection_items_collectionId_fkey" FOREIGN KEY ("collectionId") REFERENCES "media_collections"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "media_collection_items" ADD CONSTRAINT "media_collection_items_assetId_fkey" FOREIGN KEY ("assetId") REFERENCES "media_assets"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "media_people" ADD CONSTRAINT "media_people_spaceId_fkey" FOREIGN KEY ("spaceId") REFERENCES "spaces"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "media_face_tracks" ADD CONSTRAINT "media_face_tracks_spaceId_fkey" FOREIGN KEY ("spaceId") REFERENCES "spaces"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "media_face_tracks" ADD CONSTRAINT "media_face_tracks_assetId_fkey" FOREIGN KEY ("assetId") REFERENCES "media_assets"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "media_face_tracks" ADD CONSTRAINT "media_face_tracks_personId_fkey" FOREIGN KEY ("personId") REFERENCES "media_people"("id") ON DELETE SET NULL ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "media_marks" ADD CONSTRAINT "media_marks_spaceId_fkey" FOREIGN KEY ("spaceId") REFERENCES "spaces"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "media_marks" ADD CONSTRAINT "media_marks_personId_fkey" FOREIGN KEY ("personId") REFERENCES "media_people"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "media_marks" ADD CONSTRAINT "media_marks_assetId_fkey" FOREIGN KEY ("assetId") REFERENCES "media_assets"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "media_brand_kits" ADD CONSTRAINT "media_brand_kits_spaceId_fkey" FOREIGN KEY ("spaceId") REFERENCES "spaces"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "media_projects" ADD CONSTRAINT "media_projects_spaceId_fkey" FOREIGN KEY ("spaceId") REFERENCES "spaces"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "media_projects" ADD CONSTRAINT "media_projects_brandKitId_fkey" FOREIGN KEY ("brandKitId") REFERENCES "media_brand_kits"("id") ON DELETE SET NULL ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "media_edit_plans" ADD CONSTRAINT "media_edit_plans_spaceId_fkey" FOREIGN KEY ("spaceId") REFERENCES "spaces"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "media_edit_plans" ADD CONSTRAINT "media_edit_plans_projectId_fkey" FOREIGN KEY ("projectId") REFERENCES "media_projects"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "media_renders" ADD CONSTRAINT "media_renders_spaceId_fkey" FOREIGN KEY ("spaceId") REFERENCES "spaces"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "media_renders" ADD CONSTRAINT "media_renders_projectId_fkey" FOREIGN KEY ("projectId") REFERENCES "media_projects"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "media_renders" ADD CONSTRAINT "media_renders_planId_fkey" FOREIGN KEY ("planId") REFERENCES "media_edit_plans"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "media_publications" ADD CONSTRAINT "media_publications_spaceId_fkey" FOREIGN KEY ("spaceId") REFERENCES "spaces"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "media_publications" ADD CONSTRAINT "media_publications_renderId_fkey" FOREIGN KEY ("renderId") REFERENCES "media_renders"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "media_metrics" ADD CONSTRAINT "media_metrics_spaceId_fkey" FOREIGN KEY ("spaceId") REFERENCES "spaces"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "media_metrics" ADD CONSTRAINT "media_metrics_publicationId_fkey" FOREIGN KEY ("publicationId") REFERENCES "media_publications"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "media_metric_mappings" ADD CONSTRAINT "media_metric_mappings_spaceId_fkey" FOREIGN KEY ("spaceId") REFERENCES "spaces"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "media_teaser_models" ADD CONSTRAINT "media_teaser_models_spaceId_fkey" FOREIGN KEY ("spaceId") REFERENCES "spaces"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/packages/db/prisma/schema.prisma b/packages/db/prisma/schema.prisma index a6dc085..ebdd7b9 100644 --- a/packages/db/prisma/schema.prisma +++ b/packages/db/prisma/schema.prisma @@ -216,6 +216,23 @@ model Space { userFactVisibility UserFactSpace[] delegatedTasks DelegatedTask[] semanticMemoryItems SemanticMemoryItem[] + mediaSources MediaSource[] + mediaAssets MediaAsset[] + mediaUploads MediaUpload[] + mediaSegments MediaSegment[] + mediaAnalyses MediaAnalysis[] + mediaCollections MediaCollection[] + mediaPeople MediaPerson[] + mediaFaceTracks MediaFaceTrack[] + mediaMarks MediaMark[] + mediaBrandKits MediaBrandKit[] + mediaProjects MediaProject[] + mediaEditPlans MediaEditPlan[] + mediaRenders MediaRender[] + mediaPublications MediaPublication[] + mediaMetrics MediaMetric[] + mediaMetricMappings MediaMetricMapping[] + mediaTeaserModel MediaTeaserModel? @@unique([id, organizationId]) @@index([organizationId, createdAt]) @@ -1497,3 +1514,411 @@ model DelegatedTask { // CHECK constraint delegated_tasks_status_chk is created in SQL. @@map("delegated_tasks") } + +// --------------------------------------------------------------------------- +// Media Studio +// --------------------------------------------------------------------------- + +model MediaSource { + id String @id @default(cuid()) + spaceId String + space Space @relation(fields: [spaceId], references: [id], onDelete: Cascade) + ownerUserId String + kind String + label String + /// Folder: { path }. Client-pushed: { deviceId }. Never secrets. + config Json @default("{}") + status String @default("idle") + error String? + lastSyncCursor String? + lastSyncedAt DateTime? + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + assets MediaAsset[] + + @@index([spaceId, createdAt]) + @@map("media_sources") +} + +model MediaAsset { + id String @id @default(cuid()) + spaceId String + space Space @relation(fields: [spaceId], references: [id], onDelete: Cascade) + sourceId String + source MediaSource @relation(fields: [sourceId], references: [id], onDelete: Cascade) + externalId String + revision String? + contentHash String? + kind String + storage String + /// Uploaded originals: path relative to the media root. Referenced originals resolve through the source. + storagePath String? + fileName String? + sizeBytes BigInt? + capturedAt DateTime? + durationMs Int? + width Int? + height Int? + fps Float? + codec String? + orientation Int? + /// Rounded to two decimals (about 1 km). + gpsLat Float? + gpsLon Float? + deviceModel String? + albumNames String[] @default([]) + isFavorite Boolean @default(false) + proxyPath String? + thumbPath String? + spritePath String? + qualityScore Float? + analysisVersion Int @default(0) + ingestState String @default("indexed") + ingestError String? + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + segments MediaSegment[] + analyses MediaAnalysis[] + collectionItems MediaCollectionItem[] + faceTracks MediaFaceTrack[] + marks MediaMark[] + uploads MediaUpload[] + + @@unique([sourceId, externalId]) + @@index([spaceId, capturedAt]) + @@index([spaceId, kind, capturedAt]) + @@index([spaceId, ingestState]) + @@index([contentHash]) + @@map("media_assets") +} + +model MediaUpload { + id String @id @default(cuid()) + spaceId String + space Space @relation(fields: [spaceId], references: [id], onDelete: Cascade) + assetId String + asset MediaAsset @relation(fields: [assetId], references: [id], onDelete: Cascade) + userId String + sizeBytes BigInt + chunkBytes Int + sha256 String + fileName String + receivedChunks Int[] @default([]) + status String @default("open") + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + @@index([assetId]) + @@index([spaceId, status]) + @@map("media_uploads") +} + +model MediaSegment { + id String @id @default(cuid()) + spaceId String + space Space @relation(fields: [spaceId], references: [id], onDelete: Cascade) + assetId String + asset MediaAsset @relation(fields: [assetId], references: [id], onDelete: Cascade) + kind String + startMs Int + endMs Int + /// Partial> + scores Json @default("{}") + teaserScore Float? + transcriptExcerpt String? + labels String[] @default([]) + paidOnly Boolean @default(false) + analysisVersion Int + createdAt DateTime @default(now()) + + @@index([assetId, startMs]) + @@index([spaceId, teaserScore]) + @@map("media_segments") +} + +model MediaAnalysis { + id String @id @default(cuid()) + spaceId String + space Space @relation(fields: [spaceId], references: [id], onDelete: Cascade) + assetId String + asset MediaAsset @relation(fields: [assetId], references: [id], onDelete: Cascade) + kind String + modelId String + modelVersion String + /// Small results inline; large results as a path relative to the media root. + data Json? + dataPath String? + /// Float32 little-endian vectors for embeddings. + vectors Bytes? + createdAt DateTime @default(now()) + + @@unique([assetId, kind, modelVersion]) + @@index([spaceId, kind]) + @@map("media_analyses") +} + +model MediaCollection { + id String @id @default(cuid()) + spaceId String + space Space @relation(fields: [spaceId], references: [id], onDelete: Cascade) + kind String + title String + labelSource String @default("rule") + startAt DateTime? + endAt DateTime? + centroidLat Float? + centroidLon Float? + coverAssetId String? + /// Stable key so re-clustering updates instead of recreating user-renamed collections. + clusterKey String? + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + items MediaCollectionItem[] + + @@unique([spaceId, kind, clusterKey]) + @@index([spaceId, kind, startAt]) + @@map("media_collections") +} + +model MediaCollectionItem { + collectionId String + collection MediaCollection @relation(fields: [collectionId], references: [id], onDelete: Cascade) + assetId String + asset MediaAsset @relation(fields: [assetId], references: [id], onDelete: Cascade) + position Int + + @@id([collectionId, assetId]) + @@index([assetId]) + @@map("media_collection_items") +} + +model MediaPerson { + id String @id @default(cuid()) + spaceId String + space Space @relation(fields: [spaceId], references: [id], onDelete: Cascade) + displayName String + isSelf Boolean @default(false) + confirmed Boolean @default(false) + anonymizeByDefault String @default("none") + /// Mean identity embedding (Float32). Biometric: local only, deleted with the person. + centroid Bytes? + consentArtifactId String? + consentRecordedAt DateTime? + consentRecordedBy String? + ageVerifiedAt DateTime? + ageVerificationMethod String @default("none") + /// Seed for a stable synthetic identity. + syntheticSeed Int? + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + faceTracks MediaFaceTrack[] + marks MediaMark[] + + @@index([spaceId]) + @@map("media_people") +} + +model MediaFaceTrack { + id String @id @default(cuid()) + spaceId String + space Space @relation(fields: [spaceId], references: [id], onDelete: Cascade) + assetId String + asset MediaAsset @relation(fields: [assetId], references: [id], onDelete: Cascade) + personId String? + person MediaPerson? @relation(fields: [personId], references: [id], onDelete: SetNull) + startMs Int + endMs Int + confidenceMin Float + /// Box keyframes JSON relative to the media root. + keyframesPath String + /// Biometric (Float32). Local only. + embedding Bytes? + modelVersion String + createdAt DateTime @default(now()) + + @@index([assetId]) + @@index([personId]) + @@map("media_face_tracks") +} + +model MediaMark { + id String @id @default(cuid()) + spaceId String + space Space @relation(fields: [spaceId], references: [id], onDelete: Cascade) + kind String + label String? + personId String? + person MediaPerson? @relation(fields: [personId], references: [id], onDelete: Cascade) + assetId String? + asset MediaAsset? @relation(fields: [assetId], references: [id], onDelete: Cascade) + /// MediaMarkPrompt + prompt Json? + treatment String + maskDir String? + qualityScore Float? + suggestedFallback String? + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + @@index([spaceId, personId]) + @@index([assetId]) + @@map("media_marks") +} + +model MediaBrandKit { + id String @id @default(cuid()) + spaceId String + space Space @relation(fields: [spaceId], references: [id], onDelete: Cascade) + /// BrandKit + kit Json + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + projects MediaProject[] + + @@index([spaceId]) + @@map("media_brand_kits") +} + +model MediaProject { + id String @id @default(cuid()) + spaceId String + space Space @relation(fields: [spaceId], references: [id], onDelete: Cascade) + createdBy String + title String + goal String + status String @default("draft") + brandKitId String? + brandKit MediaBrandKit? @relation(fields: [brandKitId], references: [id], onDelete: SetNull) + collectionIds String[] @default([]) + assetIds String[] @default([]) + profileIds String[] @default([]) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + plans MediaEditPlan[] + renders MediaRender[] + + @@index([spaceId, updatedAt]) + @@map("media_projects") +} + +model MediaEditPlan { + id String @id @default(cuid()) + spaceId String + space Space @relation(fields: [spaceId], references: [id], onDelete: Cascade) + projectId String + project MediaProject @relation(fields: [projectId], references: [id], onDelete: Cascade) + profileId String + version Int + /// Edl, validated before insert. + edl Json + createdBy String + rationale String? + createdAt DateTime @default(now()) + renders MediaRender[] + + @@unique([projectId, profileId, version]) + @@map("media_edit_plans") +} + +model MediaRender { + id String @id @default(cuid()) + spaceId String + space Space @relation(fields: [spaceId], references: [id], onDelete: Cascade) + projectId String + project MediaProject @relation(fields: [projectId], references: [id], onDelete: Cascade) + planId String + plan MediaEditPlan @relation(fields: [planId], references: [id], onDelete: Cascade) + profileId String + quality String + status String @default("queued") + progress Float @default(0) + /// MediaRenderOutput[] plus internal relative paths. + outputs Json @default("[]") + gateReport Json? + ffprobe Json? + autoFixRound Int @default(0) + error String? + createdBy String + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + publications MediaPublication[] + + @@index([projectId, createdAt]) + @@index([spaceId, status]) + @@map("media_renders") +} + +model MediaPublication { + id String @id @default(cuid()) + spaceId String + space Space @relation(fields: [spaceId], references: [id], onDelete: Cascade) + renderId String + render MediaRender @relation(fields: [renderId], references: [id], onDelete: Cascade) + profileId String + destination String + status String @default("draft") + scheduledAt DateTime? + publishedAt DateTime? + externalUrl String? + targetUrl String? + trackingUrl String? + utm Json? + title String? + caption String? + tags String[] @default([]) + thumbnailVariant String? + bundleArtifactId String? + createdBy String + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + metrics MediaMetric[] + + @@index([spaceId, createdAt]) + @@index([renderId]) + @@map("media_publications") +} + +model MediaMetric { + id String @id @default(cuid()) + spaceId String + space Space @relation(fields: [spaceId], references: [id], onDelete: Cascade) + publicationId String + publication MediaPublication @relation(fields: [publicationId], references: [id], onDelete: Cascade) + observedAt DateTime + views Int @default(0) + clicks Int @default(0) + conversions Int @default(0) + revenueCents Int @default(0) + currency String @default("EUR") + source String + createdAt DateTime @default(now()) + + @@unique([publicationId, observedAt, source]) + @@index([spaceId, observedAt]) + @@map("media_metrics") +} + +model MediaMetricMapping { + id String @id @default(cuid()) + spaceId String + space Space @relation(fields: [spaceId], references: [id], onDelete: Cascade) + name String + /// MetricCsvMapping without id + mapping Json + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + @@unique([spaceId, name]) + @@map("media_metric_mappings") +} + +model MediaTeaserModel { + spaceId String @id + space Space @relation(fields: [spaceId], references: [id], onDelete: Cascade) + /// TeaserWeights + weights Json + sampleCount Int + updatedAt DateTime @updatedAt + + @@map("media_teaser_models") +} diff --git a/packages/media/package.json b/packages/media/package.json new file mode 100644 index 0000000..ab42765 --- /dev/null +++ b/packages/media/package.json @@ -0,0 +1,30 @@ +{ + "name": "@rakazo/media", + "version": "0.1.0", + "license": "Apache-2.0", + "type": "module", + "exports": { + ".": { + "types": "./src/index.ts", + "development": "./src/index.ts", + "default": "./src/index.ts" + }, + "./node": { + "types": "./src/node.ts", + "development": "./src/node.ts", + "default": "./src/node.ts" + } + }, + "scripts": { + "check": "tsc --noEmit -p tsconfig.json", + "test": "vitest run --root ../.. packages/media/src" + }, + "dependencies": { + "@rakazo/contracts": "workspace:*", + "zod": "4.6.2" + }, + "devDependencies": { + "typescript": "7.0.2", + "vitest": "4.1.11" + } +} diff --git a/packages/media/src/clustering.ts b/packages/media/src/clustering.ts new file mode 100644 index 0000000..59b696e --- /dev/null +++ b/packages/media/src/clustering.ts @@ -0,0 +1,87 @@ +import type { MediaAssetKind } from "@rakazo/contracts"; +import { notImplemented } from "./not-implemented.js"; + +export interface ClusterAsset { + id: string; + kind: MediaAssetKind; + capturedAt: Date | null; + gpsLat: number | null; + gpsLon: number | null; + deviceModel: string | null; + albumNames: string[]; + /** Mean frame embedding; null when not analyzed yet. */ + embedding: Float32Array | null; +} + +export interface SessionOptions { + /** New session after a gap longer than this. Default 45. */ + gapMinutes: number; + /** New session after a location jump larger than this. Default 2. */ + maxDistanceKm: number; + /** New session on calendar-day change when the gap exceeds this. Default 3. */ + dayBreakHours: number; + /** Merge adjacent sessions whose mean embeddings reach this cosine. Default 0.85. */ + mergeSimilarity: number; + /** ...and whose gap is below this. Default 2. */ + mergeMaxGapHours: number; + /** IANA zone for calendar-day boundaries. Default "UTC". */ + timeZone: string; +} + +export const DEFAULT_SESSION_OPTIONS: SessionOptions = { + gapMinutes: 45, + maxDistanceKm: 2, + dayBreakHours: 3, + mergeSimilarity: 0.85, + mergeMaxGapHours: 2, + timeZone: "UTC", +}; + +export interface SessionCluster { + /** Stable across re-clustering: derived from the earliest asset id of the session. */ + key: string; + assetIds: string[]; + startAt: Date | null; + endAt: Date | null; + centroid: { lat: number; lon: number } | null; +} + +/** + * Deterministic session grouping (spec 4.4). Assets without capturedAt form + * one trailing "undated" session; exact duplicates (same id) are ignored. + */ +export function buildSessions( + _assets: ClusterAsset[], + _options?: Partial, +): SessionCluster[] { + return notImplemented("buildSessions"); +} + +export interface VectorItem { + id: string; + vector: Float32Array; +} + +/** Agglomerative average-linkage clustering; returns groups with stable keys. */ +export function clusterThemes( + _items: VectorItem[], + _options?: { similarity?: number; minSize?: number }, +): Array<{ key: string; ids: string[] }> { + return notImplemented("clusterThemes"); +} + +/** Group face tracks into person suggestions by identity embedding. Local only. */ +export function clusterFaces( + _tracks: VectorItem[], + _options?: { similarity?: number }, +): Array<{ key: string; ids: string[]; centroid: Float32Array }> { + return notImplemented("clusterFaces"); +} + +/** Rule-based title from date range, place centroid, and dominant album. No model involved. */ +export function ruleLabel( + _cluster: { startAt: Date | null; endAt: Date | null; albumNames: string[] }, + _locale?: string, +): string { + return notImplemented("ruleLabel"); +} diff --git a/packages/media/src/compile.ts b/packages/media/src/compile.ts new file mode 100644 index 0000000..913e831 --- /dev/null +++ b/packages/media/src/compile.ts @@ -0,0 +1,117 @@ +import type { + BrandKit, + Edl, + MediaRenderQuality, + NormalizedBox, + PlatformProfile, + ReframeKeyframe, + RenderPlan, +} from "@rakazo/contracts"; +import { notImplemented } from "./not-implemented.js"; + +export interface CompileSource { + /** Redacted intermediate when redactions apply, otherwise original or proxy. */ + path: string; + width: number; + height: number; + durationMs: number; + fps: number; + hasAudio: boolean; + rotation: 0 | 90 | 180 | 270; +} + +export interface CaptionWord { + startMs: number; + endMs: number; + text: string; +} + +export interface CompileInput { + edl: Edl; + profile: PlatformProfile; + brandKit: BrandKit | null; + quality: MediaRenderQuality; + /** Absolute work directory for intermediates and outputs. */ + workDir: string; + sources: Map; + /** Source-time words per asset for captions. */ + transcripts: Map; + /** Saliency or subject points per asset for "auto" reframe. */ + subjects: Map>; + /** Pre-rendered motion clips keyed by overlay id, "intro", "outro". */ + motionClips: Map; + files: { + lutPath: string | null; + watermarkPath: string | null; + fonts: Map; + /** artifactId → absolute path for audio clips. */ + audio: Map; + }; + /** ffmpeg video encoder name, e.g. libx264, h264_vaapi, h264_videotoolbox. */ + videoEncoder: string; +} + +/** + * Pure compiler from a validated EDL to ffmpeg argv steps plus caption files. + * No string ever reaches a shell; text is escaped for filtergraph and ASS, + * every label comes from SafeId values. + */ +export function compileRenderPlan(_input: CompileInput): RenderPlan { + return notImplemented("compileRenderPlan"); +} + +export interface CaptionCue { + startMs: number; + endMs: number; + text: string; + /** Word timings inside the cue for karaoke highlight. */ + words: CaptionWord[]; +} + +export function buildCaptionCues( + _words: CaptionWord[], + _options: { maxCharsPerLine: number; maxLines: number }, +): CaptionCue[] { + return notImplemented("buildCaptionCues"); +} + +export function toSrt(_cues: CaptionCue[]): string { + return notImplemented("toSrt"); +} + +export function toVtt(_cues: CaptionCue[]): string { + return notImplemented("toVtt"); +} + +/** Smoothed crop path that keeps the subject in frame for the output aspect. */ +export function autoReframe( + _subjects: Array<{ tMs: number; cx: number; cy: number; strength: number }>, + _input: { + sourceWidth: number; + sourceHeight: number; + outWidth: number; + outHeight: number; + durationMs: number; + }, +): ReframeKeyframe[] { + return notImplemented("autoReframe"); +} + +/** Crop path that keeps every given face box outside the output frame, or null if impossible. */ +export function reframeExcluding( + _faces: Array<{ tMs: number; box: NormalizedBox }>, + _input: { + sourceWidth: number; + sourceHeight: number; + outWidth: number; + outHeight: number; + durationMs: number; + }, +): ReframeKeyframe[] | null { + return notImplemented("reframeExcluding"); +} + +/** Escape text for ffmpeg drawtext/filtergraph values. */ +export function escapeFilterText(_text: string): string { + return notImplemented("escapeFilterText"); +} diff --git a/packages/media/src/edl.ts b/packages/media/src/edl.ts new file mode 100644 index 0000000..4c3a06e --- /dev/null +++ b/packages/media/src/edl.ts @@ -0,0 +1,45 @@ +import type { BrandKit, Edl, EdlError, MediaAssetKind, PlatformProfile } from "@rakazo/contracts"; +import { notImplemented } from "./not-implemented.js"; + +export interface EdlContext { + profile: PlatformProfile; + brandKit: BrandKit | null; + assets: Map< + string, + { kind: MediaAssetKind; durationMs: number | null; width: number | null; height: number | null } + >; + /** Artifact ids the plan may reference (music, voice-over, fonts, logos). */ + artifactIds: Set; + /** Music artifacts and whether their license allows commercial use. */ + music: Map; + markIds: Set; + personIds: Set; + faceTrackIds: Set; + templateIds: Set; + /** Segments the user reserved for the paid release. */ + paidOnly: Array<{ assetId: string; startMs: number; endMs: number }>; +} + +export type EdlValidation = { ok: true; edl: Edl } | { ok: false; errors: EdlError[] }; + +/** + * Parse with EdlSchema, then check references, ranges, overlaps, durations, + * safe zones, paid-only use (teaser-style profiles), and music licensing. + * Every failure is reported; nothing is silently corrected. + */ +export function validateEdl(_input: unknown, _context: EdlContext): EdlValidation { + return notImplemented("validateEdl"); +} + +/** Output length in ms including intro, outro, speed, and transition overlap. */ +export function edlDurationMs(_edl: Edl): number { + return notImplemented("edlDurationMs"); +} + +/** Map an output timestamp back to the clip and source time it shows. */ +export function locateOutputTime( + _edl: Edl, + _outputMs: number, +): { clipId: string; assetId: string; sourceMs: number } | null { + return notImplemented("locateOutputTime"); +} diff --git a/packages/media/src/gate.ts b/packages/media/src/gate.ts new file mode 100644 index 0000000..f3e4610 --- /dev/null +++ b/packages/media/src/gate.ts @@ -0,0 +1,87 @@ +import type { + AiAlteration, + ContentCategory, + Edl, + FfprobeReport, + GateIssue, + GateReport, + NormalizedBox, + PlatformProfile, +} from "@rakazo/contracts"; +import { notImplemented } from "./not-implemented.js"; + +/** Tags a clean export may carry. Everything else is a metadata leak. */ +export const METADATA_ALLOWED_TAGS: readonly string[] = []; + +export interface GateInput { + profile: PlatformProfile; + edl: Edl; + ffprobe: FfprobeReport; + /** Classifier scores on the rendered output. */ + content: Array<{ tMs: number; scores: Partial> }>; + /** + * Faces re-detected on the rendered output. personId null means the face + * could not be matched to a known person. + */ + faces: Array<{ tMs: number; box: NormalizedBox; personId: string | null; coverage: number }>; + /** Frames where tracking lost a face that must stay anonymized for longer than allowed. */ + trackingGaps: Array<{ startMs: number; endMs: number; personId: string | null }>; + /** Residual visibility 0..1 of treated marks in the output. */ + marks: Array<{ markId: string; tMs: number; residualVisibility: number }>; + /** People visible in the output with their protection and consent state. */ + people: Array<{ + id: string; + anonymizeByDefault: string; + consentRecordedAt: string | null; + ageVerifiedAt: string | null; + }>; + copy: { title: string | null; caption: string | null; tags: string[] }; + aiAlterations: AiAlteration[]; + bundleHasDisclosure: boolean; + watermarkBox: NormalizedBox | null; + sampledFrames: number; + autoFixRound: number; + now: Date; +} + +/** Deterministic gate (spec 6). Any blocking issue fails the report. */ +export function evaluateGate(_input: GateInput): GateReport { + return notImplemented("evaluateGate"); +} + +export function checkTechnical( + _profile: PlatformProfile, + _edl: Edl, + _probe: FfprobeReport, +): GateIssue[] { + return notImplemented("checkTechnical"); +} + +export function findMetadataLeaks(_tags: Record): string[] { + return notImplemented("findMetadataLeaks"); +} + +export function checkCopy( + _profile: PlatformProfile, + _copy: { title: string | null; caption: string | null; tags: string[] }, +): GateIssue[] { + return notImplemented("checkCopy"); +} + +export interface AutoFixResult { + edl: Edl; + changes: string[]; +} + +/** + * Compliance-only repairs: trim or drop offending clips, change crop, widen + * redaction padding, swap to another segment. Never adds obfuscation aimed at + * classifiers. Returns null when the round limit is reached or nothing safe applies. + */ +export function planAutoFix( + _edl: Edl, + _report: GateReport, + _alternatives: Array<{ segmentId: string; assetId: string; startMs: number; endMs: number }>, +): AutoFixResult | null { + return notImplemented("planAutoFix"); +} diff --git a/packages/media/src/geo.ts b/packages/media/src/geo.ts new file mode 100644 index 0000000..2efc882 --- /dev/null +++ b/packages/media/src/geo.ts @@ -0,0 +1,28 @@ +import { notImplemented } from "./not-implemented.js"; + +/** Great-circle distance in kilometres. */ +export function haversineKm( + _a: { lat: number; lon: number }, + _b: { lat: number; lon: number }, +): number { + return notImplemented("haversineKm"); +} + +/** Round a coordinate to two decimals (about 1 km) before storage or display. */ +export function roundCoordinate(_value: number): number { + return notImplemented("roundCoordinate"); +} + +/** Cosine similarity of two equal-length vectors. */ +export function cosineSimilarity(_a: ArrayLike, _b: ArrayLike): number { + return notImplemented("cosineSimilarity"); +} + +/** Encode Float32 vectors as little-endian bytes for MediaAnalysis.vectors and centroids. */ +export function encodeVectors(_vectors: number[][]): Uint8Array { + return notImplemented("encodeVectors"); +} + +export function decodeVectors(_bytes: Uint8Array, _dim: number): Float32Array[] { + return notImplemented("decodeVectors"); +} diff --git a/packages/media/src/index.ts b/packages/media/src/index.ts new file mode 100644 index 0000000..c1111ff --- /dev/null +++ b/packages/media/src/index.ts @@ -0,0 +1,8 @@ +export * from "./clustering.js"; +export * from "./compile.js"; +export * from "./edl.js"; +export * from "./gate.js"; +export * from "./geo.js"; +export * from "./marketing.js"; +export * from "./profiles.js"; +export * from "./teaser.js"; diff --git a/packages/media/src/marketing.ts b/packages/media/src/marketing.ts new file mode 100644 index 0000000..e63db11 --- /dev/null +++ b/packages/media/src/marketing.ts @@ -0,0 +1,66 @@ +import type { + BrandKit, + MetricCsvMapping, + MotionTemplateInfo, + MotionTemplateRef, + PlatformProfile, + Utm, +} from "@rakazo/contracts"; +import { notImplemented } from "./not-implemented.js"; + +/** Append UTM parameters to an http(s) target. Rejects other schemes. */ +export function buildTrackingUrl(_targetUrl: string, _utm: Utm): string { + return notImplemented("buildTrackingUrl"); +} + +export function normalizeHashtags(_tags: string[], _limit: number): string[] { + return notImplemented("normalizeHashtags"); +} + +export interface ParsedMetricRow { + row: number; + publicationKey: string; + observedAt: Date; + views: number; + clicks: number; + conversions: number; + revenueCents: number; + currency: string; +} + +/** RFC 4180 CSV parsing with the saved column mapping. */ +export function parseMetricsCsv( + _csv: string, + _mapping: MetricCsvMapping, +): { rows: ParsedMetricRow[]; skipped: Array<{ row: number; reason: string }> } { + return notImplemented("parseMetricsCsv"); +} + +/** Whether the brand intro fits before the hook window; shorts put the hook first. */ +export function introFits(_profile: PlatformProfile, _introMs: number): boolean { + return notImplemented("introFits"); +} + +/** Deterministic intro proposals from the brand kit, tone, and available templates. */ +export function proposeIntroVariants( + _kit: BrandKit, + _templates: MotionTemplateInfo[], + _count: number, +): Array<{ template: MotionTemplateRef; rationale: string }> { + return notImplemented("proposeIntroVariants"); +} + +/** Structural brand-kit checks beyond the schema (contrast, duplicate presets). */ +export function validateBrandKit(_kit: BrandKit): string[] { + return notImplemented("validateBrandKit"); +} + +/** Upload checklist markdown for an export bundle. */ +export function uploadChecklist(_input: { + profile: PlatformProfile; + aiAltered: boolean; + hasCaptions: boolean; + trackingUrl: string | null; +}): string { + return notImplemented("uploadChecklist"); +} diff --git a/packages/media/src/node.ts b/packages/media/src/node.ts new file mode 100644 index 0000000..5d6911a --- /dev/null +++ b/packages/media/src/node.ts @@ -0,0 +1,18 @@ +import type { PlatformProfile } from "@rakazo/contracts"; +import { notImplemented } from "./not-implemented.js"; +import type { ProfileLoadError } from "./profiles.js"; + +/** Directory of the profile JSON files shipped with this package. */ +export const BUILTIN_PROFILES_DIR = new URL("../profiles/", import.meta.url); + +/** Read every *.json in a directory; missing directory yields no profiles. */ +export async function loadProfilesFromDir( + _dir: string | URL, +): Promise<{ profiles: PlatformProfile[]; errors: ProfileLoadError[] }> { + return notImplemented("loadProfilesFromDir"); +} + +/** Resolve a path inside root; rejects traversal and symlinks that escape it. */ +export async function resolveInside(_root: string, _relative: string): Promise { + return notImplemented("resolveInside"); +} diff --git a/packages/media/src/not-implemented.ts b/packages/media/src/not-implemented.ts new file mode 100644 index 0000000..0c4ab30 --- /dev/null +++ b/packages/media/src/not-implemented.ts @@ -0,0 +1,4 @@ +/** Phase A scaffold marker. No call site may remain once Media Studio ships. */ +export function notImplemented(name: string): never { + throw new Error(`not implemented: ${name}`); +} diff --git a/packages/media/src/profiles.ts b/packages/media/src/profiles.ts new file mode 100644 index 0000000..c78fe82 --- /dev/null +++ b/packages/media/src/profiles.ts @@ -0,0 +1,38 @@ +import type { PlatformProfile, PlatformProfileSummary } from "@rakazo/contracts"; +import { notImplemented } from "./not-implemented.js"; + +export const PROFILE_STALE_DAYS = 90; + +export interface ProfileLoadError { + file: string; + message: string; +} + +export interface ProfileSet { + profiles: Map; + errors: ProfileLoadError[]; +} + +/** Parse raw JSON documents; invalid ones become errors and are skipped whole. */ +export function parseProfiles(_documents: Array<{ file: string; json: unknown }>): { + profiles: PlatformProfile[]; + errors: ProfileLoadError[]; +} { + return notImplemented("parseProfiles"); +} + +/** Private profiles override builtin ones with the same id. */ +export function mergeProfiles( + _builtin: PlatformProfile[], + _private: PlatformProfile[], +): ProfileSet { + return notImplemented("mergeProfiles"); +} + +export function summarizeProfile( + _profile: PlatformProfile, + _origin: "builtin" | "private", + _now: Date, +): PlatformProfileSummary { + return notImplemented("summarizeProfile"); +} diff --git a/packages/media/src/teaser.ts b/packages/media/src/teaser.ts new file mode 100644 index 0000000..d52418f --- /dev/null +++ b/packages/media/src/teaser.ts @@ -0,0 +1,98 @@ +import type { + BrandKit, + Edl, + MediaProjectGoal, + PlatformProfile, + TeaserFeature, + TeaserWeights, +} from "@rakazo/contracts"; +import { notImplemented } from "./not-implemented.js"; + +export type SegmentFeatures = Partial>; + +export function defaultTeaserWeights(_goal: MediaProjectGoal): TeaserWeights { + return notImplemented("defaultTeaserWeights"); +} + +export interface ScoredSegmentInput { + id: string; + assetId: string; + startMs: number; + endMs: number; + capturedAt: Date | null; + features: SegmentFeatures; + paidOnly: boolean; + transcript: string | null; +} + +export function scoreSegment(_features: SegmentFeatures, _weights: TeaserWeights): number { + return notImplemented("scoreSegment"); +} + +/** 0..1 speech-hook strength from a transcript excerpt (questions, exclamations, numbers, announcements). */ +export function speechHookScore(_text: string, _language?: string): number { + return notImplemented("speechHookScore"); +} + +export interface TeaserSelection { + clips: Array<{ segmentId: string; assetId: string; inMs: number; outMs: number }>; + hookSegmentId: string | null; + /** Why each clip was chosen, for plan rationale. */ + notes: string[]; +} + +/** + * Pick clips for a goal and profile: a hook inside hookWindowMs, rising + * tension, cut before the payoff (open loop), never paid-only segments, + * total within the profile's duration bounds. + */ +export function selectTeaserSegments(_input: { + segments: ScoredSegmentInput[]; + profile: PlatformProfile; + goal: MediaProjectGoal; + targetMs: number; + weights: TeaserWeights; + endCardMs: number; +}): TeaserSelection { + return notImplemented("selectTeaserSegments"); +} + +/** Regularized logistic regression of conversion on features; null below TEASER_LEARNING_MIN_SAMPLES. */ +export function fitTeaserWeights( + _samples: Array<{ features: SegmentFeatures; views: number; conversions: number }>, + _prior: TeaserWeights, +): TeaserWeights | null { + return notImplemented("fitTeaserWeights"); +} + +/** Deterministic first-cut EDL for a project and profile; the agent refines it. */ +export function autoPlanEdl(_input: { + goal: MediaProjectGoal; + profile: PlatformProfile; + brandKit: BrandKit | null; + segments: ScoredSegmentInput[]; + weights: TeaserWeights; + assets: Map< + string, + { durationMs: number | null; width: number | null; height: number | null; hasSpeech: boolean } + >; + /** Person anonymization defaults to carry into redactions. */ + people: Array<{ id: string; anonymizeByDefault: string }>; + marks: Array<{ id: string; treatment: string; assetId: string | null; personId: string | null }>; + ctaText: string | null; +}): Edl { + return notImplemented("autoPlanEdl"); +} + +export function rankThumbnailFrames( + _candidates: Array<{ + tMs: number; + sharpness: number; + face: number; + thirds: number; + contrast: number; + }>, + _count: number, +): number[] { + return notImplemented("rankThumbnailFrames"); +} diff --git a/packages/media/tsconfig.json b/packages/media/tsconfig.json new file mode 100644 index 0000000..c699328 --- /dev/null +++ b/packages/media/tsconfig.json @@ -0,0 +1,8 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "rootDir": "src", + "noEmit": true + }, + "include": ["src"] +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index f2acb6e..179bf2a 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -78,6 +78,9 @@ importers: '@rakazo/logging': specifier: workspace:* version: link:../../packages/logging + '@rakazo/media': + specifier: workspace:* + version: link:../../packages/media '@rakazo/memory': specifier: workspace:* version: link:../../packages/memory @@ -176,6 +179,9 @@ importers: expo-localization: specifier: 57.0.2 version: 57.0.2(expo@57.0.26)(react@19.2.3) + expo-media-library: + specifier: 57.0.5 + version: 57.0.5(expo@57.0.26)(react-native@0.86.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7))(@types/react@19.2.18)(react@19.2.3)) expo-modules-core: specifier: 57.0.20 version: 57.0.20(react-native-worklets@0.10.1(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7))(react-native@0.86.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7))(@types/react@19.2.18)(react@19.2.3))(react@19.2.3))(react-native@0.86.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7))(@types/react@19.2.18)(react@19.2.3))(react@19.2.3) @@ -203,6 +209,9 @@ importers: expo-updates: specifier: 57.0.24 version: 57.0.24(expo@57.0.26)(react-native@0.86.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7))(@types/react@19.2.18)(react@19.2.3))(react@19.2.3) + expo-video: + specifier: 57.0.5 + version: 57.0.5(expo@57.0.26)(react-native@0.86.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7))(@types/react@19.2.18)(react@19.2.3))(react@19.2.3) react: specifier: 19.2.3 version: 19.2.3 @@ -288,6 +297,9 @@ importers: '@rakazo/ui-web': specifier: workspace:* version: link:../../packages/ui-web + '@tanstack/react-virtual': + specifier: 3.14.13 + version: 3.14.13(react-dom@19.2.3(react@19.2.3))(react@19.2.3) better-auth: specifier: 1.7.4 version: 1.7.4(@opentelemetry/api@1.9.1)(@prisma/client@7.10.0(prisma@7.10.0(@types/react-dom@19.2.7(@types/react@19.2.18))(@types/react@19.2.18)(magicast@0.5.4)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(typescript@7.0.2))(typescript@7.0.2))(mysql2@3.15.3)(pg@8.23.0)(prisma@7.10.0(@types/react-dom@19.2.7(@types/react@19.2.18))(@types/react@19.2.18)(magicast@0.5.4)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(typescript@7.0.2))(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(vitest@4.1.11(@opentelemetry/api@1.9.1)(@types/node@26.5.0)(jsdom@30.0.1(@noble/hashes@2.3.0))(vite@8.3.0(@types/node@26.5.0)(esbuild@0.28.2)(jiti@2.7.0)(terser@5.50.0)(tsx@4.23.13)(yaml@2.9.0))) @@ -579,6 +591,12 @@ importers: '@rakazo/logging': specifier: workspace:* version: link:../logging + '@rakazo/media': + specifier: workspace:* + version: link:../media + '@resvg/resvg-js': + specifier: 2.6.2 + version: 2.6.2 chat: specifier: 4.40.0 version: 4.40.0(zod@4.6.2) @@ -800,6 +818,22 @@ importers: specifier: 4.1.11 version: 4.1.11(@opentelemetry/api@1.9.1)(@types/node@26.5.0)(jsdom@30.0.1(@noble/hashes@2.3.0))(vite@8.3.0(@types/node@26.5.0)(esbuild@0.28.2)(jiti@2.7.0)(terser@5.50.0)(tsx@4.23.13)(yaml@2.9.0)) + packages/media: + dependencies: + '@rakazo/contracts': + specifier: workspace:* + version: link:../contracts + zod: + specifier: 4.6.2 + version: 4.6.2 + devDependencies: + typescript: + specifier: 7.0.2 + version: 7.0.2 + vitest: + specifier: 4.1.11 + version: 4.1.11(@opentelemetry/api@1.9.1)(@types/node@26.5.0)(jsdom@30.0.1(@noble/hashes@2.3.0))(vite@8.3.0(@types/node@26.5.0)(esbuild@0.28.2)(jiti@2.7.0)(terser@5.50.0)(tsx@4.23.13)(yaml@2.9.0)) + packages/memory: dependencies: '@rakazo/adapter-kit': @@ -3800,6 +3834,82 @@ packages: '@types/react': optional: true + '@resvg/resvg-js-android-arm-eabi@2.6.2': + resolution: {integrity: sha512-FrJibrAk6v29eabIPgcTUMPXiEz8ssrAk7TXxsiZzww9UTQ1Z5KAbFJs+Z0Ez+VZTYgnE5IQJqBcoSiMebtPHA==} + engines: {node: '>= 10'} + cpu: [arm] + os: [android] + + '@resvg/resvg-js-android-arm64@2.6.2': + resolution: {integrity: sha512-VcOKezEhm2VqzXpcIJoITuvUS/fcjIw5NA/w3tjzWyzmvoCdd+QXIqy3FBGulWdClvp4g+IfUemigrkLThSjAQ==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [android] + + '@resvg/resvg-js-darwin-arm64@2.6.2': + resolution: {integrity: sha512-nmok2LnAd6nLUKI16aEB9ydMC6Lidiiq2m1nEBDR1LaaP7FGs4AJ90qDraxX+CWlVuRlvNjyYJTNv8qFjtL9+A==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [darwin] + + '@resvg/resvg-js-darwin-x64@2.6.2': + resolution: {integrity: sha512-GInyZLjgWDfsVT6+SHxQVRwNzV0AuA1uqGsOAW+0th56J7Nh6bHHKXHBWzUrihxMetcFDmQMAX1tZ1fZDYSRsw==} + engines: {node: '>= 10'} + cpu: [x64] + os: [darwin] + + '@resvg/resvg-js-linux-arm-gnueabihf@2.6.2': + resolution: {integrity: sha512-YIV3u/R9zJbpqTTNwTZM5/ocWetDKGsro0SWp70eGEM9eV2MerWyBRZnQIgzU3YBnSBQ1RcxRZvY/UxwESfZIw==} + engines: {node: '>= 10'} + cpu: [arm] + os: [linux] + + '@resvg/resvg-js-linux-arm64-gnu@2.6.2': + resolution: {integrity: sha512-zc2BlJSim7YR4FZDQ8OUoJg5holYzdiYMeobb9pJuGDidGL9KZUv7SbiD4E8oZogtYY42UZEap7dqkkYuA91pg==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [linux] + + '@resvg/resvg-js-linux-arm64-musl@2.6.2': + resolution: {integrity: sha512-3h3dLPWNgSsD4lQBJPb4f+kvdOSJHa5PjTYVsWHxLUzH4IFTJUAnmuWpw4KqyQ3NA5QCyhw4TWgxk3jRkQxEKg==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [linux] + + '@resvg/resvg-js-linux-x64-gnu@2.6.2': + resolution: {integrity: sha512-IVUe+ckIerA7xMZ50duAZzwf1U7khQe2E0QpUxu5MBJNao5RqC0zwV/Zm965vw6D3gGFUl7j4m+oJjubBVoftw==} + engines: {node: '>= 10'} + cpu: [x64] + os: [linux] + + '@resvg/resvg-js-linux-x64-musl@2.6.2': + resolution: {integrity: sha512-UOf83vqTzoYQO9SZ0fPl2ZIFtNIz/Rr/y+7X8XRX1ZnBYsQ/tTb+cj9TE+KHOdmlTFBxhYzVkP2lRByCzqi4jQ==} + engines: {node: '>= 10'} + cpu: [x64] + os: [linux] + + '@resvg/resvg-js-win32-arm64-msvc@2.6.2': + resolution: {integrity: sha512-7C/RSgCa+7vqZ7qAbItfiaAWhyRSoD4l4BQAbVDqRRsRgY+S+hgS3in0Rxr7IorKUpGE69X48q6/nOAuTJQxeQ==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [win32] + + '@resvg/resvg-js-win32-ia32-msvc@2.6.2': + resolution: {integrity: sha512-har4aPAlvjnLcil40AC77YDIk6loMawuJwFINEM7n0pZviwMkMvjb2W5ZirsNOZY4aDbo5tLx0wNMREp5Brk+w==} + engines: {node: '>= 10'} + cpu: [ia32] + os: [win32] + + '@resvg/resvg-js-win32-x64-msvc@2.6.2': + resolution: {integrity: sha512-ZXtYhtUr5SSaBrUDq7DiyjOFJqBVL/dOBN7N/qmi/pO0IgiWW/f/ue3nbvu9joWE5aAKDoIzy/CxsY0suwGosQ==} + engines: {node: '>= 10'} + cpu: [x64] + os: [win32] + + '@resvg/resvg-js@2.6.2': + resolution: {integrity: sha512-xBaJish5OeGmniDj9cW5PRa/PtmuVU3ziqrbr5xJj901ZDN4TosrVaNZpEiLZAxdfnhAe7uQ7QFWfjPe9d9K2Q==} + engines: {node: '>= 10'} + '@rolldown/binding-android-arm-eabi@1.2.8': resolution: {integrity: sha512-tN5aztYkKCte4i5SIrrz5yK/HMjEuCqCSCJa418jOV8tZ1cBY3YF2otxB1ktPxzsLA1BeTqwapK0bfjxNvHJVw==} engines: {node: ^20.19.0 || >=22.12.0} @@ -4116,6 +4226,15 @@ packages: peerDependencies: vite: ^5.2.0 || ^6 || ^7 || ^8 + '@tanstack/react-virtual@3.14.13': + resolution: {integrity: sha512-JbDTAwtzZ99aOeCrAfW5EsE5KSq5RWh6Af2dtFwyLIIk48Ja7vm6n4axu/43T3vfjFnEGJalxJ1wyYjSQD6bSg==} + peerDependencies: + react: 19.2.3 + react-dom: 19.2.3 + + '@tanstack/virtual-core@3.17.11': + resolution: {integrity: sha512-+ILjvtHup6Y2hzQ6YzwMgX1Q+oQpxEGOXCEsCNaPoIP0VxMbizIBTmYTDtkerkIQS8/CbP1BRuyt8V/8BCsy1g==} + '@testcontainers/postgresql@12.1.0': resolution: {integrity: sha512-Pjf2VSVNirEPfz36nidyrVAnZvc2YhajOznY4VgyEsvfTd5qiMNOuPq96drREvxAUtXl5SFLX7vXj7sSq4aTcA==} @@ -6165,6 +6284,12 @@ packages: peerDependencies: expo: '*' + expo-media-library@57.0.5: + resolution: {integrity: sha512-amSLrLsS7Gl4nCqbc2PlVbHj5jK9kQLsSOQgJ2QasHCSHzYXw6Ui7aolxX5APg9P4TgYPE0yWn46ndifh52bRw==} + peerDependencies: + expo: '*' + react-native: '*' + expo-modules-autolinking@57.0.13: resolution: {integrity: sha512-Hj5NjZRlfccazhKab+wK19leRVUr1Y/PUERaRvegIbUSkq4GVxKq6lU9QHlKQyC16MdD/VS4iwsiLivB4o7lvQ==} hasBin: true @@ -6279,6 +6404,13 @@ packages: expo-dev-client: optional: true + expo-video@57.0.5: + resolution: {integrity: sha512-VK2o+d6rb+tVeL2DI6d0F58pRV8d8bctLpOrMJEHRYtMIK5iwvjjrRx2mciYN8/d04SgWC8TkI+wuZ8Sbp/yuA==} + peerDependencies: + expo: '*' + react: 19.2.3 + react-native: '*' + expo@57.0.26: resolution: {integrity: sha512-U1rsFwcuZNnU61Z/YLnnVC/ZqP0wpxh/rCKUMff3ot/rhLpxFY1zxEqu4vyaexyvBAB1mwpl9lA/ltcHBoQGyg==} hasBin: true @@ -13559,6 +13691,57 @@ snapshots: optionalDependencies: '@types/react': 19.2.18 + '@resvg/resvg-js-android-arm-eabi@2.6.2': + optional: true + + '@resvg/resvg-js-android-arm64@2.6.2': + optional: true + + '@resvg/resvg-js-darwin-arm64@2.6.2': + optional: true + + '@resvg/resvg-js-darwin-x64@2.6.2': + optional: true + + '@resvg/resvg-js-linux-arm-gnueabihf@2.6.2': + optional: true + + '@resvg/resvg-js-linux-arm64-gnu@2.6.2': + optional: true + + '@resvg/resvg-js-linux-arm64-musl@2.6.2': + optional: true + + '@resvg/resvg-js-linux-x64-gnu@2.6.2': + optional: true + + '@resvg/resvg-js-linux-x64-musl@2.6.2': + optional: true + + '@resvg/resvg-js-win32-arm64-msvc@2.6.2': + optional: true + + '@resvg/resvg-js-win32-ia32-msvc@2.6.2': + optional: true + + '@resvg/resvg-js-win32-x64-msvc@2.6.2': + optional: true + + '@resvg/resvg-js@2.6.2': + optionalDependencies: + '@resvg/resvg-js-android-arm-eabi': 2.6.2 + '@resvg/resvg-js-android-arm64': 2.6.2 + '@resvg/resvg-js-darwin-arm64': 2.6.2 + '@resvg/resvg-js-darwin-x64': 2.6.2 + '@resvg/resvg-js-linux-arm-gnueabihf': 2.6.2 + '@resvg/resvg-js-linux-arm64-gnu': 2.6.2 + '@resvg/resvg-js-linux-arm64-musl': 2.6.2 + '@resvg/resvg-js-linux-x64-gnu': 2.6.2 + '@resvg/resvg-js-linux-x64-musl': 2.6.2 + '@resvg/resvg-js-win32-arm64-msvc': 2.6.2 + '@resvg/resvg-js-win32-ia32-msvc': 2.6.2 + '@resvg/resvg-js-win32-x64-msvc': 2.6.2 + '@rolldown/binding-android-arm-eabi@1.2.8': optional: true @@ -13847,6 +14030,14 @@ snapshots: tailwindcss: 4.3.3 vite: 8.3.0(@types/node@26.5.0)(esbuild@0.28.2)(jiti@2.7.0)(terser@5.50.0)(tsx@4.23.13)(yaml@2.9.0) + '@tanstack/react-virtual@3.14.13(react-dom@19.2.3(react@19.2.3))(react@19.2.3)': + dependencies: + '@tanstack/virtual-core': 3.17.11 + react: 19.2.3 + react-dom: 19.2.3(react@19.2.3) + + '@tanstack/virtual-core@3.17.11': {} + '@testcontainers/postgresql@12.1.0': dependencies: testcontainers: 12.1.0 @@ -16233,6 +16424,11 @@ snapshots: expo: 57.0.26(@babel/core@7.29.7)(@expo/dom-webview@57.0.1)(@expo/metro-runtime@57.0.16)(expo-router@57.0.24)(react-dom@19.2.3(react@19.2.3))(react-native-web@0.21.2(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(react-native-webview@13.16.1(react-native@0.86.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7))(@types/react@19.2.18)(react@19.2.3))(react@19.2.3))(react-native-worklets@0.10.1(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7))(react-native@0.86.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7))(@types/react@19.2.18)(react@19.2.3))(react@19.2.3))(react-native@0.86.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7))(@types/react@19.2.18)(react@19.2.3))(react@19.2.3)(typescript@6.0.3) expo-json-utils: 57.0.2 + expo-media-library@57.0.5(expo@57.0.26)(react-native@0.86.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7))(@types/react@19.2.18)(react@19.2.3)): + dependencies: + expo: 57.0.26(@babel/core@7.29.7)(@expo/dom-webview@57.0.1)(@expo/metro-runtime@57.0.16)(expo-router@57.0.24)(react-dom@19.2.3(react@19.2.3))(react-native-web@0.21.2(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(react-native-webview@13.16.1(react-native@0.86.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7))(@types/react@19.2.18)(react@19.2.3))(react@19.2.3))(react-native-worklets@0.10.1(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7))(react-native@0.86.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7))(@types/react@19.2.18)(react@19.2.3))(react@19.2.3))(react-native@0.86.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7))(@types/react@19.2.18)(react@19.2.3))(react@19.2.3)(typescript@6.0.3) + react-native: 0.86.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7))(@types/react@19.2.18)(react@19.2.3) + expo-modules-autolinking@57.0.13(typescript@6.0.3): dependencies: '@expo/require-utils': 57.0.5(typescript@6.0.3) @@ -16391,6 +16587,12 @@ snapshots: transitivePeerDependencies: - supports-color + expo-video@57.0.5(expo@57.0.26)(react-native@0.86.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7))(@types/react@19.2.18)(react@19.2.3))(react@19.2.3): + dependencies: + expo: 57.0.26(@babel/core@7.29.7)(@expo/dom-webview@57.0.1)(@expo/metro-runtime@57.0.16)(expo-router@57.0.24)(react-dom@19.2.3(react@19.2.3))(react-native-web@0.21.2(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(react-native-webview@13.16.1(react-native@0.86.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7))(@types/react@19.2.18)(react@19.2.3))(react@19.2.3))(react-native-worklets@0.10.1(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7))(react-native@0.86.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7))(@types/react@19.2.18)(react@19.2.3))(react@19.2.3))(react-native@0.86.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7))(@types/react@19.2.18)(react@19.2.3))(react@19.2.3)(typescript@6.0.3) + react: 19.2.3 + react-native: 0.86.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7))(@types/react@19.2.18)(react@19.2.3) + expo@57.0.26(@babel/core@7.29.7)(@expo/dom-webview@57.0.1)(@expo/metro-runtime@57.0.16)(expo-router@57.0.24)(react-dom@19.2.3(react@19.2.3))(react-native-web@0.21.2(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(react-native-webview@13.16.1(react-native@0.86.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7))(@types/react@19.2.18)(react@19.2.3))(react@19.2.3))(react-native-worklets@0.10.1(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7))(react-native@0.86.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7))(@types/react@19.2.18)(react@19.2.3))(react@19.2.3))(react-native@0.86.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7))(@types/react@19.2.18)(react@19.2.3))(react@19.2.3)(typescript@6.0.3): dependencies: '@babel/runtime': 7.29.7