import { internalMutation, internalQuery, mutation, query, type MutationCtx } from "./_generated/server"; import type { Doc, Id } from "./_generated/dataModel"; import { v } from "convex/values"; import { requireAuth } from "./helpers"; import { buildFreepikAssetDedupeKey, buildPexelsVideoDedupeKey, buildStoredMediaDedupeKey, mapMediaArchiveRowToListItem, normalizeMediaArchiveInput, type MediaArchiveInput, type MediaArchiveKind, type MediaArchiveListItem, } from "../lib/media-archive"; const MEDIA_LIBRARY_DEFAULT_LIMIT = 200; const MEDIA_LIBRARY_MIN_LIMIT = 1; const MEDIA_LIBRARY_MAX_LIMIT = 500; const mediaArchiveInputValidator = v.object({ kind: v.union(v.literal("image"), v.literal("video"), v.literal("asset")), source: v.union( v.literal("upload"), v.literal("ai-image"), v.literal("ai-video"), v.literal("freepik-asset"), v.literal("pexels-video"), ), dedupeKey: v.string(), title: v.optional(v.string()), filename: v.optional(v.string()), mimeType: v.optional(v.string()), storageId: v.optional(v.id("_storage")), previewStorageId: v.optional(v.id("_storage")), originalUrl: v.optional(v.string()), previewUrl: v.optional(v.string()), sourceUrl: v.optional(v.string()), providerAssetId: v.optional(v.string()), width: v.optional(v.number()), height: v.optional(v.number()), durationSeconds: v.optional(v.number()), metadata: v.optional(v.any()), firstSourceCanvasId: v.optional(v.id("canvases")), firstSourceNodeId: v.optional(v.id("nodes")), }); type MediaItemStorageRef = { storageId?: Id<"_storage">; previewStorageId?: Id<"_storage">; }; type UpsertMediaArgs = { ownerId: string; input: MediaArchiveInput; now?: number; }; type MediaInsertValue = Omit, "_id" | "_creationTime">; type LegacyMediaBackfillCanvas = Pick, "_id" | "ownerId">; type LegacyMediaBackfillNode = Pick, "_id" | "canvasId" | "type" | "data">; export type LegacyMediaBackfillCanvasResult = { scannedNodeCount: number; upsertedItemCount: number; }; function normalizeMediaLibraryLimit(limit: number | undefined): number { if (typeof limit !== "number" || !Number.isFinite(limit)) { return MEDIA_LIBRARY_DEFAULT_LIMIT; } return Math.min(MEDIA_LIBRARY_MAX_LIMIT, Math.max(MEDIA_LIBRARY_MIN_LIMIT, Math.floor(limit))); } function compactUndefined>(value: T): Partial { const entries = Object.entries(value).filter(([, entryValue]) => entryValue !== undefined); return Object.fromEntries(entries) as Partial; } function asNonEmptyString(value: unknown): string | undefined { if (typeof value !== "string") { return undefined; } const trimmed = value.trim(); return trimmed.length > 0 ? trimmed : undefined; } function asPositiveNumber(value: unknown): number | undefined { if (typeof value !== "number" || !Number.isFinite(value) || value <= 0) { return undefined; } return value; } function toStorageId(value: unknown): Id<"_storage"> | undefined { const storageId = asNonEmptyString(value); return storageId as Id<"_storage"> | undefined; } export function mapLegacyNodeToMediaArchiveInput(node: LegacyMediaBackfillNode): MediaArchiveInput | null { const data = (node.data ?? {}) as Record; const firstSourceCanvasId = node.canvasId; const firstSourceNodeId = node._id; if (node.type === "image") { const storageId = toStorageId(data.storageId); const legacyUrl = asNonEmptyString(data.url); if (!storageId && !legacyUrl) { return null; } return { kind: "image", source: "upload", dedupeKey: storageId ? buildStoredMediaDedupeKey(storageId) : `legacy:image-url:${legacyUrl}`, storageId, filename: asNonEmptyString(data.originalFilename) ?? asNonEmptyString(data.filename), mimeType: asNonEmptyString(data.mimeType), width: asPositiveNumber(data.width), height: asPositiveNumber(data.height), metadata: legacyUrl ? { legacyUrl } : undefined, firstSourceCanvasId, firstSourceNodeId, }; } if (node.type === "ai-image") { const storageId = toStorageId(data.storageId); if (!storageId) { return null; } return { kind: "image", source: "ai-image", dedupeKey: buildStoredMediaDedupeKey(storageId), storageId, width: asPositiveNumber(data.width), height: asPositiveNumber(data.height), firstSourceCanvasId, firstSourceNodeId, }; } if (node.type === "ai-video") { const storageId = toStorageId(data.storageId); if (!storageId) { return null; } return { kind: "video", source: "ai-video", dedupeKey: buildStoredMediaDedupeKey(storageId), storageId, durationSeconds: asPositiveNumber(data.durationSeconds), firstSourceCanvasId, firstSourceNodeId, }; } if (node.type === "asset") { const sourceUrl = asNonEmptyString(data.sourceUrl); const assetType = asNonEmptyString(data.assetType) ?? "photo"; const providerAssetId = typeof data.assetId === "number" || typeof data.assetId === "string" ? String(data.assetId) : undefined; const dedupeKey = providerAssetId ? buildFreepikAssetDedupeKey(assetType, providerAssetId) : sourceUrl ? `freepik:url:${sourceUrl}` : undefined; if (!dedupeKey) { return null; } return { kind: "asset", source: "freepik-asset", dedupeKey, title: asNonEmptyString(data.title), originalUrl: asNonEmptyString(data.url), previewUrl: asNonEmptyString(data.previewUrl) ?? asNonEmptyString(data.url), sourceUrl, providerAssetId, width: asPositiveNumber(data.intrinsicWidth), height: asPositiveNumber(data.intrinsicHeight), metadata: compactUndefined({ assetType, authorName: asNonEmptyString(data.authorName), license: asNonEmptyString(data.license), orientation: asNonEmptyString(data.orientation), }), firstSourceCanvasId, firstSourceNodeId, }; } if (node.type === "video") { const originalUrl = asNonEmptyString(data.mp4Url); const sourceUrl = asNonEmptyString((data.attribution as { videoUrl?: unknown } | undefined)?.videoUrl) ?? asNonEmptyString(data.sourceUrl); const providerAssetId = typeof data.pexelsId === "number" || typeof data.pexelsId === "string" ? String(data.pexelsId) : undefined; const dedupeKey = providerAssetId ? buildPexelsVideoDedupeKey(providerAssetId) : sourceUrl ? `pexels:url:${sourceUrl}` : originalUrl ? `pexels:mp4:${originalUrl}` : undefined; if (!dedupeKey) { return null; } return { kind: "video", source: "pexels-video", dedupeKey, originalUrl, previewUrl: asNonEmptyString(data.thumbnailUrl), sourceUrl, providerAssetId, width: asPositiveNumber(data.width), height: asPositiveNumber(data.height), durationSeconds: asPositiveNumber(data.duration), firstSourceCanvasId, firstSourceNodeId, }; } return null; } export async function backfillLegacyMediaForCanvas( ctx: MutationCtx, args: { canvas: LegacyMediaBackfillCanvas; nodes: LegacyMediaBackfillNode[]; now?: number; }, ): Promise { const now = args.now ?? Date.now(); let upsertedItemCount = 0; for (const node of args.nodes) { const input = mapLegacyNodeToMediaArchiveInput(node); if (!input) { continue; } await upsertMediaItemByOwnerAndDedupe(ctx, { ownerId: args.canvas.ownerId, input, now, }); upsertedItemCount += 1; } return { scannedNodeCount: args.nodes.length, upsertedItemCount, }; } export function collectOwnedMediaStorageIds(items: Array): Set> { const ids = new Set>(); for (const item of items) { if (item.storageId) { ids.add(item.storageId); } if (item.previewStorageId) { ids.add(item.previewStorageId); } } return ids; } export function listMediaArchiveItems( rows: Array>, options?: { kind?: MediaArchiveKind; limit?: number }, ): MediaArchiveListItem[] { const normalizedLimit = normalizeMediaLibraryLimit(options?.limit); const filteredRows = rows .filter((row) => (options?.kind ? row.kind === options.kind : true)) .sort((a, b) => b.updatedAt - a.updatedAt) .slice(0, normalizedLimit); return filteredRows.map((row) => mapMediaArchiveRowToListItem({ ...row, _id: row._id, }), ); } export async function upsertMediaItemByOwnerAndDedupe( ctx: MutationCtx, { ownerId, input, now = Date.now() }: UpsertMediaArgs, ): Promise> { const normalizedInput = normalizeMediaArchiveInput(input); const existing = await ctx.db .query("mediaItems") .withIndex("by_owner_dedupe", (q) => q.eq("ownerId", ownerId).eq("dedupeKey", input.dedupeKey)) .unique(); if (existing) { const patchValue = compactUndefined({ ...normalizedInput, updatedAt: now, lastUsedAt: now, }) as Partial; await ctx.db.patch( existing._id, patchValue, ); const updated = await ctx.db.get(existing._id); if (!updated) { throw new Error("media item vanished after patch"); } return updated; } const insertValue: MediaInsertValue = compactUndefined({ ownerId, ...normalizedInput, createdAt: now, updatedAt: now, lastUsedAt: now, }) as MediaInsertValue; const insertedId = await ctx.db.insert("mediaItems", insertValue); const inserted = await ctx.db.get(insertedId); if (!inserted) { throw new Error("failed to read inserted media item"); } return inserted; } export const list = query({ args: { kind: v.optional(v.union(v.literal("image"), v.literal("video"), v.literal("asset"))), limit: v.optional(v.number()), }, handler: async (ctx, { kind, limit }) => { const user = await requireAuth(ctx); const rows = await ctx.db .query("mediaItems") .withIndex("by_owner_updated", (q) => q.eq("ownerId", user.userId)) .order("desc") .take(normalizeMediaLibraryLimit(limit)); return listMediaArchiveItems(rows, { kind, limit }); }, }); export const listByOwnerInternal = internalQuery({ args: { ownerId: v.string(), kind: v.optional(v.union(v.literal("image"), v.literal("video"), v.literal("asset"))), limit: v.optional(v.number()), }, handler: async (ctx, { ownerId, kind, limit }) => { const rows = await ctx.db .query("mediaItems") .withIndex("by_owner_updated", (q) => q.eq("ownerId", ownerId)) .order("desc") .take(normalizeMediaLibraryLimit(limit)); return listMediaArchiveItems(rows, { kind, limit }); }, }); export const upsert = mutation({ args: { input: mediaArchiveInputValidator, }, handler: async (ctx, { input }) => { const user = await requireAuth(ctx); return await upsertMediaItemByOwnerAndDedupe(ctx, { ownerId: user.userId, input, }); }, }); export const upsertForOwnerInternal = internalMutation({ args: { ownerId: v.string(), input: mediaArchiveInputValidator, }, handler: async (ctx, { ownerId, input }) => { return await upsertMediaItemByOwnerAndDedupe(ctx, { ownerId, input, }); }, });