From a1df097f9c5b53e043dc1a50d7c91ef863ad3161 Mon Sep 17 00:00:00 2001 From: Matthias Meister Date: Fri, 10 Apr 2026 15:15:44 +0200 Subject: [PATCH] feat(media): add Convex media archive with backfill and mixed-media library --- app/dashboard/page-client.tsx | 81 ++- .../__tests__/asset-browser-panel.test.tsx | 133 +++++ .../canvas/__tests__/use-canvas-drop.test.tsx | 65 +++ .../__tests__/video-browser-panel.test.tsx | 182 +++++++ components/canvas/asset-browser-panel.tsx | 36 +- components/canvas/nodes/image-node.tsx | 6 + components/canvas/video-browser-panel.tsx | 43 +- .../__tests__/media-preview-utils.test.ts | 38 ++ components/media/media-library-dialog.tsx | 125 ++++- components/media/media-preview-utils.ts | 27 +- convex/_generated/api.d.ts | 4 + convex/ai.ts | 37 ++ convex/dashboard.ts | 174 +++++-- convex/media.ts | 412 +++++++++++++++ convex/migrations.ts | 109 ++++ convex/schema.ts | 42 ++ convex/storage.ts | 93 ++-- hooks/use-dashboard-snapshot.ts | 27 +- lib/dashboard-snapshot-cache.ts | 2 +- lib/media-archive.ts | 115 +++++ tests/convex/media-archive.test.ts | 478 ++++++++++++++++++ tests/convex/media-backfill.test.ts | 356 +++++++++++++ tests/lib/media-archive.test.ts | 135 +++++ tests/use-canvas-drop.test.ts | 19 +- tests/use-dashboard-snapshot.test.ts | 45 +- vitest.config.ts | 2 + 26 files changed, 2664 insertions(+), 122 deletions(-) create mode 100644 components/canvas/__tests__/asset-browser-panel.test.tsx create mode 100644 components/canvas/__tests__/video-browser-panel.test.tsx create mode 100644 convex/media.ts create mode 100644 convex/migrations.ts create mode 100644 lib/media-archive.ts create mode 100644 tests/convex/media-archive.test.ts create mode 100644 tests/convex/media-backfill.test.ts create mode 100644 tests/lib/media-archive.test.ts diff --git a/app/dashboard/page-client.tsx b/app/dashboard/page-client.tsx index 801df40..f4cea81 100644 --- a/app/dashboard/page-client.tsx +++ b/app/dashboard/page-client.tsx @@ -7,6 +7,7 @@ import { useTheme } from "next-themes"; import { useMutation } from "convex/react"; import { useTranslations } from "next-intl"; import { + Box, ChevronDown, Coins, ImageIcon, @@ -16,6 +17,7 @@ import { Moon, Search, Sun, + Video, } from "lucide-react"; import { Avatar, AvatarFallback } from "@/components/ui/avatar"; @@ -66,6 +68,50 @@ function formatDimensions(width: number | undefined, height: number | undefined) return "Größe unbekannt"; } +function getMediaItemKey(item: NonNullable["snapshot"]>["mediaPreview"][number]): string { + if (item.storageId) { + return item.storageId; + } + + if (item.originalUrl) { + return `url:${item.originalUrl}`; + } + + if (item.previewUrl) { + return `preview:${item.previewUrl}`; + } + + if (item.sourceUrl) { + return `source:${item.sourceUrl}`; + } + + return `${item.kind}:${item.createdAt}:${item.filename ?? "unnamed"}`; +} + +function getMediaItemMeta(item: NonNullable["snapshot"]>["mediaPreview"][number]): string { + if (item.kind === "video") { + return "Videodatei"; + } + + return formatDimensions(item.width, item.height); +} + +function getMediaItemLabel(item: NonNullable["snapshot"]>["mediaPreview"][number]): string { + if (item.filename) { + return item.filename; + } + + if (item.kind === "video") { + return "Unbenanntes Video"; + } + + if (item.kind === "asset") { + return "Unbenanntes Asset"; + } + + return "Unbenanntes Bild"; +} + export function DashboardPageClient() { const t = useTranslations("toasts"); const router = useRouter(); @@ -357,16 +403,27 @@ export function DashboardPageClient() { ) : (
{(mediaPreview ?? []).map((item) => { + const itemKey = getMediaItemKey(item); const previewUrl = resolveMediaPreviewUrl(item, mediaPreviewUrlMap); + const itemLabel = getMediaItemLabel(item); + const itemMeta = getMediaItemMeta(item); return ( -
+
- {previewUrl ? ( + {previewUrl && item.kind === "video" ? ( +
) : (
- + {item.kind === "video" ? ( +
)}
-

- {item.filename ?? "Unbenanntes Bild"} -

-

- {formatDimensions(item.width, item.height)} +

+ {itemLabel}

+

{itemMeta}

); @@ -400,7 +461,7 @@ export function DashboardPageClient() { open={isMediaLibraryDialogOpen} onOpenChange={setIsMediaLibraryDialogOpen} title="Mediathek" - description="Alle deine Bilder aus LemonSpace in einer zentralen Vorschau." + description="Alle deine Medien aus LemonSpace in einer zentralen Vorschau." /> ); diff --git a/components/canvas/__tests__/asset-browser-panel.test.tsx b/components/canvas/__tests__/asset-browser-panel.test.tsx new file mode 100644 index 0000000..7795b9a --- /dev/null +++ b/components/canvas/__tests__/asset-browser-panel.test.tsx @@ -0,0 +1,133 @@ +// @vitest-environment jsdom + +import React, { act } from "react"; +import { createRoot, type Root } from "react-dom/client"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { buildFreepikAssetDedupeKey } from "@/lib/media-archive"; + +const mocks = vi.hoisted(() => ({ + searchFreepik: vi.fn(async () => ({ results: [], totalPages: 1, currentPage: 1 })), + upsertMedia: vi.fn(async () => undefined), + getNode: vi.fn(() => ({ id: "node-1", data: {} })), + queueNodeDataUpdate: vi.fn(async () => undefined), + queueNodeResize: vi.fn(async () => undefined), +})); + +vi.mock("convex/react", () => ({ + useAction: () => mocks.searchFreepik, + useMutation: () => mocks.upsertMedia, +})); + +vi.mock("@xyflow/react", () => ({ + useReactFlow: () => ({ + getNode: mocks.getNode, + }), +})); + +vi.mock("@/components/canvas/canvas-sync-context", () => ({ + useCanvasSync: () => ({ + queueNodeDataUpdate: mocks.queueNodeDataUpdate, + queueNodeResize: mocks.queueNodeResize, + status: { isOffline: false }, + }), +})); + +import { AssetBrowserPanel } from "@/components/canvas/asset-browser-panel"; + +(globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true; + +describe("AssetBrowserPanel", () => { + let container: HTMLDivElement | null = null; + let root: Root | null = null; + + beforeEach(() => { + mocks.searchFreepik.mockClear(); + mocks.upsertMedia.mockClear(); + mocks.getNode.mockClear(); + mocks.queueNodeDataUpdate.mockClear(); + mocks.queueNodeResize.mockClear(); + + container = document.createElement("div"); + document.body.appendChild(container); + root = createRoot(container); + }); + + afterEach(async () => { + if (root) { + await act(async () => { + root?.unmount(); + }); + } + container?.remove(); + container = null; + root = null; + }); + + it("upserts selected Freepik asset into media archive", async () => { + const onClose = vi.fn(); + const asset = { + id: 123, + title: "Forest texture", + assetType: "photo" as const, + previewUrl: "https://cdn.freepik.test/preview.jpg", + intrinsicWidth: 1600, + intrinsicHeight: 900, + sourceUrl: "https://www.freepik.com/asset/123", + license: "freemium" as const, + authorName: "Alice", + orientation: "landscape", + }; + + await act(async () => { + root?.render( + , + ); + }); + + const selectButton = document.querySelector('[aria-label="Select asset: Forest texture"]'); + if (!(selectButton instanceof HTMLButtonElement)) { + throw new Error("Asset select button not found"); + } + + await act(async () => { + selectButton.click(); + }); + + expect(mocks.upsertMedia).toHaveBeenCalledTimes(1); + expect(mocks.upsertMedia).toHaveBeenCalledWith({ + input: { + kind: "asset", + source: "freepik-asset", + dedupeKey: buildFreepikAssetDedupeKey("photo", 123), + title: "Forest texture", + previewUrl: "https://cdn.freepik.test/preview.jpg", + originalUrl: "https://cdn.freepik.test/preview.jpg", + sourceUrl: "https://www.freepik.com/asset/123", + providerAssetId: "123", + width: 1600, + height: 900, + metadata: { + provider: "freepik", + assetId: 123, + assetType: "photo", + license: "freemium", + authorName: "Alice", + orientation: "landscape", + }, + firstSourceCanvasId: "canvas-1", + firstSourceNodeId: "node-1", + }, + }); + }); +}); diff --git a/components/canvas/__tests__/use-canvas-drop.test.tsx b/components/canvas/__tests__/use-canvas-drop.test.tsx index f9200e4..b05617a 100644 --- a/components/canvas/__tests__/use-canvas-drop.test.tsx +++ b/components/canvas/__tests__/use-canvas-drop.test.tsx @@ -8,6 +8,10 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import type { Id } from "@/convex/_generated/dataModel"; import { CANVAS_NODE_DND_MIME } from "@/lib/canvas-connection-policy"; import { NODE_DEFAULTS } from "@/lib/canvas-utils"; +import { + emitDashboardSnapshotCacheInvalidationSignal, + invalidateDashboardSnapshotForLastSignedInUser, +} from "@/lib/dashboard-snapshot-cache"; import { toast } from "@/lib/toast"; import { useCanvasDrop } from "@/components/canvas/use-canvas-drop"; import { createCompressedImagePreview } from "@/components/canvas/canvas-media-utils"; @@ -28,6 +32,11 @@ vi.mock("@/components/canvas/canvas-media-utils", () => ({ })), })); +vi.mock("@/lib/dashboard-snapshot-cache", () => ({ + invalidateDashboardSnapshotForLastSignedInUser: vi.fn(), + emitDashboardSnapshotCacheInvalidationSignal: vi.fn(), +})); + const latestHandlersRef: { current: ReturnType | null; } = { current: null }; @@ -245,6 +254,62 @@ describe("useCanvasDrop", () => { width: 1600, height: 900, }); + expect(invalidateDashboardSnapshotForLastSignedInUser).toHaveBeenCalledTimes(1); + expect(emitDashboardSnapshotCacheInvalidationSignal).toHaveBeenCalledTimes(1); + }); + + it("registers dropped image media when node creation fails", async () => { + const registerUploadedImageMedia = vi.fn(async () => ({ ok: true as const })); + const runCreateNodeOnlineOnly = vi.fn(async () => { + throw new Error("create failed"); + }); + const syncPendingMoveForClientRequest = vi.fn(async () => undefined); + const file = new File(["image-bytes"], "photo.png", { type: "image/png" }); + + container = document.createElement("div"); + document.body.appendChild(container); + root = createRoot(container); + + await act(async () => { + root?.render( + , + ); + }); + + await act(async () => { + await latestHandlersRef.current?.onDrop({ + preventDefault: vi.fn(), + clientX: 240, + clientY: 180, + dataTransfer: { + getData: vi.fn(() => ""), + files: [file], + }, + } as unknown as React.DragEvent); + }); + + await act(async () => { + await Promise.resolve(); + }); + + expect(syncPendingMoveForClientRequest).not.toHaveBeenCalled(); + expect(registerUploadedImageMedia).toHaveBeenCalledWith({ + canvasId: "canvas-1", + storageId: "storage-1", + filename: "photo.png", + mimeType: "image/png", + width: 1600, + height: 900, + }); + expect(registerUploadedImageMedia).not.toHaveBeenCalledWith( + expect.objectContaining({ nodeId: expect.anything() }), + ); + expect(invalidateDashboardSnapshotForLastSignedInUser).toHaveBeenCalledTimes(1); + expect(emitDashboardSnapshotCacheInvalidationSignal).toHaveBeenCalledTimes(1); }); it("creates a node from a JSON payload drop", async () => { diff --git a/components/canvas/__tests__/video-browser-panel.test.tsx b/components/canvas/__tests__/video-browser-panel.test.tsx new file mode 100644 index 0000000..e580368 --- /dev/null +++ b/components/canvas/__tests__/video-browser-panel.test.tsx @@ -0,0 +1,182 @@ +// @vitest-environment jsdom + +import React, { act } from "react"; +import { createRoot, type Root } from "react-dom/client"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { buildPexelsVideoDedupeKey } from "@/lib/media-archive"; + +vi.mock("react", async () => { + const actual = await vi.importActual("react"); + let promotedInitialFalseState = false; + return { + ...actual, + useState(initial: T | (() => T)) { + if (!promotedInitialFalseState && initial === false) { + promotedInitialFalseState = true; + return actual.useState(true as T); + } + return actual.useState(initial); + }, + }; +}); + +const mocks = vi.hoisted(() => ({ + searchVideos: vi.fn(async () => ({ videos: [], total_results: 0, per_page: 20 })), + popularVideos: vi.fn(async () => ({ videos: [], total_results: 0, per_page: 20 })), + upsertMedia: vi.fn(async () => undefined), + getNode: vi.fn(() => ({ id: "node-1", data: {} })), + queueNodeDataUpdate: vi.fn(async () => undefined), + queueNodeResize: vi.fn(async () => undefined), +})); + +vi.mock("convex/react", () => ({ + useAction: (() => { + let callIndex = 0; + return () => { + callIndex += 1; + return callIndex === 1 ? mocks.searchVideos : mocks.popularVideos; + }; + })(), + useMutation: () => mocks.upsertMedia, +})); + +vi.mock("@xyflow/react", () => ({ + useReactFlow: () => ({ + getNode: mocks.getNode, + }), +})); + +vi.mock("@/components/canvas/canvas-sync-context", () => ({ + useCanvasSync: () => ({ + queueNodeDataUpdate: mocks.queueNodeDataUpdate, + queueNodeResize: mocks.queueNodeResize, + status: { isOffline: false }, + }), +})); + +import { VideoBrowserPanel } from "@/components/canvas/video-browser-panel"; + +(globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true; + +describe("VideoBrowserPanel", () => { + let container: HTMLDivElement | null = null; + let root: Root | null = null; + + beforeEach(() => { + mocks.searchVideos.mockClear(); + mocks.popularVideos.mockClear(); + mocks.upsertMedia.mockClear(); + mocks.getNode.mockClear(); + mocks.queueNodeDataUpdate.mockClear(); + mocks.queueNodeResize.mockClear(); + + container = document.createElement("div"); + document.body.appendChild(container); + root = createRoot(container); + }); + + afterEach(async () => { + if (root) { + await act(async () => { + root?.unmount(); + }); + } + container?.remove(); + container = null; + root = null; + }); + + it("upserts selected Pexels video into media archive", async () => { + const onClose = vi.fn(); + const video = { + id: 987, + width: 1920, + height: 1080, + url: "https://www.pexels.com/video/987/", + image: "https://images.pexels.test/987.jpeg", + duration: 42, + user: { + id: 777, + name: "Filmmaker", + url: "https://www.pexels.com/@filmmaker", + }, + video_files: [ + { + id: 501, + quality: "hd" as const, + file_type: "video/mp4", + width: 1920, + height: 1080, + fps: 30, + link: "https://player.pexels.test/987-hd.mp4", + }, + ], + }; + + await act(async () => { + root?.render( + , + ); + }); + + await act(async () => { + await new Promise((resolve) => setTimeout(resolve, 20)); + }); + + const selectButton = Array.from(document.querySelectorAll("button")).find((button) => + button.getAttribute("aria-label")?.includes("auswählen"), + ); + if (!(selectButton instanceof HTMLButtonElement)) { + throw new Error("Video select button not found"); + } + + await act(async () => { + selectButton.click(); + }); + + expect(mocks.upsertMedia).toHaveBeenCalledTimes(1); + expect(mocks.upsertMedia).toHaveBeenCalledWith({ + input: { + kind: "video", + source: "pexels-video", + dedupeKey: buildPexelsVideoDedupeKey(987), + providerAssetId: "987", + originalUrl: "https://player.pexels.test/987-hd.mp4", + previewUrl: "https://images.pexels.test/987.jpeg", + sourceUrl: "https://www.pexels.com/video/987/", + width: 1920, + height: 1080, + durationSeconds: 42, + metadata: { + provider: "pexels", + videoId: 987, + userId: 777, + userName: "Filmmaker", + userUrl: "https://www.pexels.com/@filmmaker", + selectedFile: { + id: 501, + quality: "hd", + fileType: "video/mp4", + width: 1920, + height: 1080, + fps: 30, + }, + }, + firstSourceCanvasId: "canvas-1", + firstSourceNodeId: "node-1", + }, + }); + }); +}); diff --git a/components/canvas/asset-browser-panel.tsx b/components/canvas/asset-browser-panel.tsx index e2427cb..e223ff2 100644 --- a/components/canvas/asset-browser-panel.tsx +++ b/components/canvas/asset-browser-panel.tsx @@ -10,7 +10,7 @@ import { useState, } from "react"; import { createPortal } from "react-dom"; -import { useAction } from "convex/react"; +import { useAction, useMutation } from "convex/react"; import { useReactFlow } from "@xyflow/react"; import { X, Search, Loader2, AlertCircle } from "lucide-react"; import { api } from "@/convex/_generated/api"; @@ -21,6 +21,7 @@ import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs"; import { Badge } from "@/components/ui/badge"; import { computeMediaNodeSize } from "@/lib/canvas-utils"; import { preserveNodeFavorite } from "@/lib/canvas-node-favorite"; +import { buildFreepikAssetDedupeKey } from "@/lib/media-archive"; import { useCanvasSync } from "@/components/canvas/canvas-sync-context"; import { toast } from "@/lib/toast"; @@ -92,6 +93,7 @@ export function AssetBrowserPanel({ const [selectingAssetKey, setSelectingAssetKey] = useState(null); const searchFreepik = useAction(api.freepik.search); + const upsertMedia = useMutation(api.media.upsert); const { getNode } = useReactFlow(); const { queueNodeDataUpdate, queueNodeResize, status } = useCanvasSync(); const shouldSkipInitialSearchRef = useRef(Boolean(initialState?.results?.length)); @@ -234,6 +236,36 @@ export function AssetBrowserPanel({ width: targetSize.width, height: targetSize.height, }); + + try { + await upsertMedia({ + input: { + kind: "asset", + source: "freepik-asset", + dedupeKey: buildFreepikAssetDedupeKey(asset.assetType, asset.id), + title: asset.title, + originalUrl: asset.previewUrl, + previewUrl: asset.previewUrl, + sourceUrl: asset.sourceUrl, + providerAssetId: String(asset.id), + width: asset.intrinsicWidth, + height: asset.intrinsicHeight, + metadata: { + provider: "freepik", + assetId: asset.id, + assetType: asset.assetType, + license: asset.license, + authorName: asset.authorName, + orientation: asset.orientation, + }, + firstSourceCanvasId: canvasId as Id<"canvases">, + firstSourceNodeId: nodeId as Id<"nodes">, + }, + }); + } catch (mediaError) { + console.error("Failed to upsert Freepik media item", mediaError); + } + onClose(); } catch (error) { console.error("Failed to select asset", error); @@ -241,7 +273,7 @@ export function AssetBrowserPanel({ setSelectingAssetKey(null); } }, - [canvasId, getNode, isSelecting, nodeId, onClose, queueNodeDataUpdate, queueNodeResize, status.isOffline], + [canvasId, getNode, isSelecting, nodeId, onClose, queueNodeDataUpdate, queueNodeResize, status.isOffline, upsertMedia], ); const handlePreviousPage = useCallback(() => { diff --git a/components/canvas/nodes/image-node.tsx b/components/canvas/nodes/image-node.tsx index 72d20be..e232362 100644 --- a/components/canvas/nodes/image-node.tsx +++ b/components/canvas/nodes/image-node.tsx @@ -376,6 +376,11 @@ export default function ImageNode({ return; } + if (item.kind !== "image" || !item.storageId) { + toast.error(t('canvas.uploadFailed'), "Nur Bilddateien mit Storage-ID koennen uebernommen werden."); + return; + } + setMediaLibraryPhase("applying"); setPendingMediaLibraryStorageId(item.storageId); @@ -644,6 +649,7 @@ export default function ImageNode({ open={isMediaLibraryOpen} onOpenChange={setIsMediaLibraryOpen} onPick={handlePickFromMediaLibrary} + kindFilter="image" pickCtaLabel="Uebernehmen" /> diff --git a/components/canvas/video-browser-panel.tsx b/components/canvas/video-browser-panel.tsx index 5e1541d..082206c 100644 --- a/components/canvas/video-browser-panel.tsx +++ b/components/canvas/video-browser-panel.tsx @@ -10,7 +10,7 @@ import { type PointerEvent, } from "react"; import { createPortal } from "react-dom"; -import { useAction } from "convex/react"; +import { useAction, useMutation } from "convex/react"; import { useReactFlow } from "@xyflow/react"; import { X, Search, Loader2, AlertCircle, Play, Pause } from "lucide-react"; import { api } from "@/convex/_generated/api"; @@ -20,6 +20,7 @@ import { Button } from "@/components/ui/button"; import type { PexelsVideo, PexelsVideoFile } from "@/lib/pexels-types"; import { pickPreviewVideoFile, pickVideoFile } from "@/lib/pexels-types"; import { preserveNodeFavorite } from "@/lib/canvas-node-favorite"; +import { buildPexelsVideoDedupeKey } from "@/lib/media-archive"; import { toast } from "@/lib/toast"; import { useCanvasSync } from "@/components/canvas/canvas-sync-context"; @@ -85,6 +86,7 @@ export function VideoBrowserPanel({ const searchVideos = useAction(api.pexels.searchVideos); const popularVideos = useAction(api.pexels.popularVideos); + const upsertMedia = useMutation(api.media.upsert); const { getNode } = useReactFlow(); const { queueNodeDataUpdate, queueNodeResize, status } = useCanvasSync(); const shouldSkipInitialSearchRef = useRef( @@ -253,6 +255,43 @@ export function VideoBrowserPanel({ width: targetWidth, height: targetHeight, }); + + try { + await upsertMedia({ + input: { + kind: "video", + source: "pexels-video", + dedupeKey: buildPexelsVideoDedupeKey(video.id), + providerAssetId: String(video.id), + originalUrl: file.link, + previewUrl: video.image, + sourceUrl: video.url, + width: video.width, + height: video.height, + durationSeconds: video.duration, + metadata: { + provider: "pexels", + videoId: video.id, + userId: video.user.id, + userName: video.user.name, + userUrl: video.user.url, + selectedFile: { + id: file.id, + quality: file.quality, + fileType: file.file_type, + width: file.width, + height: file.height, + fps: file.fps, + }, + }, + firstSourceCanvasId: canvasId as Id<"canvases">, + firstSourceNodeId: nodeId as Id<"nodes">, + }, + }); + } catch (mediaError) { + console.error("Failed to upsert Pexels media item", mediaError); + } + onClose(); } catch (error) { console.error("Failed to select video", error); @@ -260,7 +299,7 @@ export function VideoBrowserPanel({ setSelectingVideoId(null); } }, - [canvasId, getNode, isSelecting, nodeId, onClose, queueNodeDataUpdate, queueNodeResize, status.isOffline], + [canvasId, getNode, isSelecting, nodeId, onClose, queueNodeDataUpdate, queueNodeResize, status.isOffline, upsertMedia], ); const handlePreviousPage = useCallback(() => { diff --git a/components/media/__tests__/media-preview-utils.test.ts b/components/media/__tests__/media-preview-utils.test.ts index 2a7e6e9..58eac8b 100644 --- a/components/media/__tests__/media-preview-utils.test.ts +++ b/components/media/__tests__/media-preview-utils.test.ts @@ -15,6 +15,16 @@ describe("media-preview-utils", () => { expect(ids).toEqual(["preview-1", "orig-1", "orig-2"]); }); + it("collects only available storage ids for mixed archive items", () => { + const ids = collectMediaStorageIdsForResolution([ + { previewUrl: "https://cdn.example.com/preview-only.jpg" }, + { previewStorageId: "preview-2" }, + { storageId: "orig-2" }, + ]); + + expect(ids).toEqual(["preview-2", "orig-2"]); + }); + it("resolves preview url first and falls back to original url", () => { const previewFirst = resolveMediaPreviewUrl( { storageId: "orig-1", previewStorageId: "preview-1" }, @@ -35,4 +45,32 @@ describe("media-preview-utils", () => { expect(fallbackToOriginal).toBe("https://cdn.example.com/original.jpg"); }); + + it("resolves direct remote preview URLs before storage map", () => { + const directPreview = resolveMediaPreviewUrl( + { + previewUrl: "https://cdn.example.com/direct-preview.webp", + storageId: "orig-1", + previewStorageId: "preview-1", + }, + { + "preview-1": "https://cdn.example.com/preview.webp", + "orig-1": "https://cdn.example.com/original.jpg", + }, + ); + + expect(directPreview).toBe("https://cdn.example.com/direct-preview.webp"); + }); + + it("falls back to direct remote original URLs when storage ids are missing", () => { + const previewUrl = resolveMediaPreviewUrl( + { + kind: "video", + originalUrl: "https://cdn.example.com/video.mp4", + }, + {}, + ); + + expect(previewUrl).toBe("https://cdn.example.com/video.mp4"); + }); }); diff --git a/components/media/media-library-dialog.tsx b/components/media/media-library-dialog.tsx index ce33442..fe9b65b 100644 --- a/components/media/media-library-dialog.tsx +++ b/components/media/media-library-dialog.tsx @@ -2,7 +2,7 @@ import { useEffect, useMemo, useState } from "react"; import { useMutation } from "convex/react"; -import { AlertCircle, ImageIcon, Loader2 } from "lucide-react"; +import { AlertCircle, Box, ImageIcon, Loader2, Video } from "lucide-react"; import { api } from "@/convex/_generated/api"; import type { Id } from "@/convex/_generated/dataModel"; @@ -25,16 +25,22 @@ const MIN_LIMIT = 1; const MAX_LIMIT = 500; export type MediaLibraryMetadataItem = { - storageId: Id<"_storage">; + kind: "image" | "video" | "asset"; + source: "upload" | "ai-image" | "ai-video" | "freepik-asset" | "pexels-video"; + storageId?: Id<"_storage">; previewStorageId?: Id<"_storage">; + previewUrl?: string; + originalUrl?: string; + sourceUrl?: string; filename?: string; mimeType?: string; width?: number; height?: number; previewWidth?: number; previewHeight?: number; - sourceCanvasId: Id<"canvases">; - sourceNodeId: Id<"nodes">; + durationSeconds?: number; + sourceCanvasId?: Id<"canvases">; + sourceNodeId?: Id<"nodes">; createdAt: number; }; @@ -49,6 +55,7 @@ export type MediaLibraryDialogProps = { title?: string; description?: string; limit?: number; + kindFilter?: "image" | "video" | "asset"; pickCtaLabel?: string; }; @@ -68,26 +75,79 @@ function formatDimensions(width: number | undefined, height: number | undefined) return `${width} x ${height}px`; } +function formatMediaMeta(item: MediaLibraryItem): string { + if (item.kind === "video") { + if (typeof item.durationSeconds === "number" && Number.isFinite(item.durationSeconds)) { + return `${Math.max(1, Math.round(item.durationSeconds))}s`; + } + return "Videodatei"; + } + + return formatDimensions(item.width, item.height) ?? "Groesse unbekannt"; +} + +function getItemKey(item: MediaLibraryItem): string { + if (item.storageId) { + return item.storageId; + } + + if (item.originalUrl) { + return `url:${item.originalUrl}`; + } + + if (item.previewUrl) { + return `preview:${item.previewUrl}`; + } + + if (item.sourceUrl) { + return `source:${item.sourceUrl}`; + } + + return `${item.kind}:${item.createdAt}:${item.filename ?? "unnamed"}`; +} + +function getItemLabel(item: MediaLibraryItem): string { + if (item.filename) { + return item.filename; + } + + if (item.kind === "video") { + return "Unbenanntes Video"; + } + + if (item.kind === "asset") { + return "Unbenanntes Asset"; + } + + return "Unbenanntes Bild"; +} + export function MediaLibraryDialog({ open, onOpenChange, onPick, title = "Mediathek", - description = "Waehle ein Bild aus deiner LemonSpace-Mediathek.", + description, limit, + kindFilter, pickCtaLabel = "Auswaehlen", }: MediaLibraryDialogProps) { const normalizedLimit = useMemo(() => normalizeLimit(limit), [limit]); const metadata = useAuthQuery( api.dashboard.listMediaLibrary, - open ? { limit: normalizedLimit } : "skip", + open + ? { + limit: normalizedLimit, + ...(kindFilter ? { kindFilter } : {}), + } + : "skip", ); const resolveUrls = useMutation(api.storage.batchGetUrlsForUserMedia); const [urlMap, setUrlMap] = useState>({}); const [isResolvingUrls, setIsResolvingUrls] = useState(false); const [urlError, setUrlError] = useState(null); - const [pendingPickStorageId, setPendingPickStorageId] = useState | null>(null); + const [pendingPickItemKey, setPendingPickItemKey] = useState(null); useEffect(() => { let isCancelled = false; @@ -155,17 +215,22 @@ export function MediaLibraryDialog({ const isMetadataLoading = open && metadata === undefined; const isInitialLoading = isMetadataLoading || (metadata !== undefined && isResolvingUrls); const isPreviewMode = typeof onPick !== "function"; + const effectiveDescription = + description ?? + (kindFilter === "image" + ? "Waehle ein Bild aus deiner LemonSpace-Mediathek." + : "Durchsuche deine Medien aus Uploads, KI-Generierung und Archivquellen."); async function handlePick(item: MediaLibraryItem): Promise { - if (!onPick || pendingPickStorageId) { + if (!onPick || pendingPickItemKey) { return; } - setPendingPickStorageId(item.storageId); + setPendingPickItemKey(getItemKey(item)); try { await onPick(item); } finally { - setPendingPickStorageId(null); + setPendingPickItemKey(null); } } @@ -174,7 +239,7 @@ export function MediaLibraryDialog({ {title} - {description} + {effectiveDescription}
@@ -201,42 +266,58 @@ export function MediaLibraryDialog({

Keine Medien vorhanden

- Sobald du Bilder hochlaedst oder generierst, erscheinen sie hier. + Sobald du Medien hochlaedst oder generierst, erscheinen sie hier.

) : (
{items.map((item) => { - const dimensions = formatDimensions(item.width, item.height); - const isPickingThis = pendingPickStorageId === item.storageId; + const itemKey = getItemKey(item); + const isPickingThis = pendingPickItemKey === itemKey; + const itemLabel = getItemLabel(item); + const metaLabel = formatMediaMeta(item); return (
- {item.url ? ( + {item.url && item.kind === "video" ? ( +
-

- {item.filename ?? "Unbenanntes Bild"} +

+ {itemLabel}

- {dimensions ?? "Groesse unbekannt"} + {metaLabel}

{isPreviewMode ? ( @@ -247,7 +328,7 @@ export function MediaLibraryDialog({ size="sm" className="mt-2 h-7" onClick={() => void handlePick(item)} - disabled={Boolean(pendingPickStorageId)} + disabled={Boolean(pendingPickItemKey)} > {isPickingThis ? ( <> diff --git a/components/media/media-preview-utils.ts b/components/media/media-preview-utils.ts index 189d49c..01f2492 100644 --- a/components/media/media-preview-utils.ts +++ b/components/media/media-preview-utils.ts @@ -1,6 +1,11 @@ type MediaPreviewReference = { - storageId: TStorageId; + kind?: "image" | "video" | "asset"; + storageId?: TStorageId; previewStorageId?: TStorageId; + previewUrl?: string; + originalUrl?: string; + sourceUrl?: string; + url?: string; }; export function collectMediaStorageIdsForResolution( @@ -25,6 +30,10 @@ export function resolveMediaPreviewUrl( item: MediaPreviewReference, urlMap: Record, ): string | undefined { + if (item.previewUrl) { + return item.previewUrl; + } + if (item.previewStorageId) { const previewUrl = urlMap[item.previewStorageId]; if (previewUrl) { @@ -32,5 +41,21 @@ export function resolveMediaPreviewUrl( } } + if (item.originalUrl) { + return item.originalUrl; + } + + if (item.sourceUrl) { + return item.sourceUrl; + } + + if (item.url) { + return item.url; + } + + if (!item.storageId) { + return undefined; + } + return urlMap[item.storageId]; } diff --git a/convex/_generated/api.d.ts b/convex/_generated/api.d.ts index 6384311..97390d3 100644 --- a/convex/_generated/api.d.ts +++ b/convex/_generated/api.d.ts @@ -25,6 +25,8 @@ import type * as export_ from "../export.js"; import type * as freepik from "../freepik.js"; import type * as helpers from "../helpers.js"; import type * as http from "../http.js"; +import type * as media from "../media.js"; +import type * as migrations from "../migrations.js"; import type * as node_type_validator from "../node_type_validator.js"; import type * as nodes from "../nodes.js"; import type * as openrouter from "../openrouter.js"; @@ -59,6 +61,8 @@ declare const fullApi: ApiFromModules<{ freepik: typeof freepik; helpers: typeof helpers; http: typeof http; + media: typeof media; + migrations: typeof migrations; node_type_validator: typeof node_type_validator; nodes: typeof nodes; openrouter: typeof openrouter; diff --git a/convex/ai.ts b/convex/ai.ts index 20febe1..2e09d6f 100644 --- a/convex/ai.ts +++ b/convex/ai.ts @@ -38,6 +38,8 @@ import { type VideoPollStatus, } from "../lib/video-poll-logging"; import { normalizePublicTier } from "../lib/tier-credits"; +import { upsertMediaItemByOwnerAndDedupe } from "./media"; +import { buildStoredMediaDedupeKey } from "../lib/media-archive"; const MAX_IMAGE_RETRIES = 2; const MAX_VIDEO_POLL_ATTEMPTS = 30; @@ -160,6 +162,23 @@ export const finalizeImageSuccess = internalMutation({ }, }); + const canvas = await ctx.db.get(existing.canvasId); + if (!canvas) { + throw new Error("Canvas not found"); + } + + await upsertMediaItemByOwnerAndDedupe(ctx, { + ownerId: canvas.ownerId, + input: { + kind: "image", + source: "ai-image", + dedupeKey: buildStoredMediaDedupeKey(storageId), + storageId, + firstSourceCanvasId: existing.canvasId, + firstSourceNodeId: nodeId, + }, + }); + return { creditCost }; }, }); @@ -600,6 +619,24 @@ export const finalizeVideoSuccess = internalMutation({ creditCost, }, }); + + const canvas = await ctx.db.get(existing.canvasId); + if (!canvas) { + throw new Error("Canvas not found"); + } + + await upsertMediaItemByOwnerAndDedupe(ctx, { + ownerId: canvas.ownerId, + input: { + kind: "video", + source: "ai-video", + dedupeKey: buildStoredMediaDedupeKey(storageId), + storageId, + durationSeconds, + firstSourceCanvasId: existing.canvasId, + firstSourceNodeId: nodeId, + }, + }); }, }); diff --git a/convex/dashboard.ts b/convex/dashboard.ts index 0ee72df..d402c3d 100644 --- a/convex/dashboard.ts +++ b/convex/dashboard.ts @@ -1,4 +1,4 @@ -import { query } from "./_generated/server"; +import { query, type QueryCtx } from "./_generated/server"; import type { Doc, Id } from "./_generated/dataModel"; import { v } from "convex/values"; @@ -12,21 +12,80 @@ const DASHBOARD_MEDIA_PREVIEW_LIMIT = 8; const MEDIA_LIBRARY_DEFAULT_LIMIT = 200; const MEDIA_LIBRARY_MIN_LIMIT = 1; const MEDIA_LIBRARY_MAX_LIMIT = 500; +const MEDIA_ARCHIVE_FETCH_MULTIPLIER = 4; type MediaPreviewItem = { - storageId: Id<"_storage">; + kind: "image" | "video" | "asset"; + source: "upload" | "ai-image" | "ai-video" | "freepik-asset" | "pexels-video"; + storageId?: Id<"_storage">; previewStorageId?: Id<"_storage">; + originalUrl?: string; + previewUrl?: string; + sourceUrl?: string; filename?: string; mimeType?: string; width?: number; height?: number; previewWidth?: number; previewHeight?: number; - sourceCanvasId: Id<"canvases">; - sourceNodeId: Id<"nodes">; + sourceCanvasId?: Id<"canvases">; + sourceNodeId?: Id<"nodes">; createdAt: number; }; +function readArchivedMediaPreview(item: Doc<"mediaItems">): MediaPreviewItem | null { + if (!item.storageId && !item.previewStorageId && !item.previewUrl && !item.originalUrl && !item.sourceUrl) { + return null; + } + + return { + kind: item.kind, + source: item.source, + storageId: item.storageId, + previewStorageId: item.previewStorageId, + originalUrl: item.originalUrl, + previewUrl: item.previewUrl, + sourceUrl: item.sourceUrl, + filename: item.filename ?? item.title, + mimeType: item.mimeType, + width: item.width, + height: item.height, + sourceCanvasId: item.firstSourceCanvasId, + sourceNodeId: item.firstSourceNodeId, + createdAt: item.updatedAt, + }; +} + +function buildMediaPreviewFromArchive( + mediaItems: Array>, + limit: number, + kindFilter?: "image" | "video" | "asset", +): MediaPreviewItem[] { + const sortedRows = mediaItems + .filter((item) => (kindFilter ? item.kind === kindFilter : true)) + .sort((a, b) => b.updatedAt - a.updatedAt); + + const deduped = new Map(); + for (const item of sortedRows) { + const dedupeKey = item.storageId ?? item.dedupeKey; + if (deduped.has(dedupeKey)) { + continue; + } + + const preview = readArchivedMediaPreview(item); + if (!preview) { + continue; + } + + deduped.set(dedupeKey, preview); + if (deduped.size >= limit) { + break; + } + } + + return [...deduped.values()]; +} + function readImageMediaPreview(node: Doc<"nodes">): MediaPreviewItem | null { if (node.type !== "image") { return null; @@ -62,6 +121,8 @@ function readImageMediaPreview(node: Doc<"nodes">): MediaPreviewItem | null { : undefined; return { + kind: "image", + source: "upload", storageId: storageId as Id<"_storage">, previewStorageId, filename, @@ -82,13 +143,14 @@ function buildMediaPreview(nodes: Array>, limit: number): MediaPrev .filter((item): item is MediaPreviewItem => item !== null) .sort((a, b) => b.createdAt - a.createdAt); - const deduped = new Map, MediaPreviewItem>(); + const deduped = new Map(); for (const item of candidates) { - if (deduped.has(item.storageId)) { + const dedupeKey = item.storageId ?? `${item.sourceCanvasId}:${item.sourceNodeId}`; + if (deduped.has(dedupeKey)) { continue; } - deduped.set(item.storageId, item); + deduped.set(dedupeKey, item); if (deduped.size >= limit) { break; } @@ -105,6 +167,43 @@ function normalizeMediaLibraryLimit(limit: number | undefined): number { return Math.min(MEDIA_LIBRARY_MAX_LIMIT, Math.max(MEDIA_LIBRARY_MIN_LIMIT, Math.floor(limit))); } +async function buildMediaPreviewFromNodeFallback( + ctx: QueryCtx, + canvases: Array>, + limit: number, +): Promise { + if (canvases.length === 0 || limit <= 0) { + return []; + } + + const deduped = new Map(); + for (const canvas of canvases.slice(0, 12)) { + if (deduped.size >= limit) { + break; + } + + const nodes = await ctx.db + .query("nodes") + .withIndex("by_canvas_type", (q) => q.eq("canvasId", canvas._id).eq("type", "image")) + .order("desc") + .take(Math.max(limit * 2, 16)); + const candidates = buildMediaPreview(nodes, limit); + + for (const candidate of candidates) { + const dedupeKey = candidate.storageId ?? `${candidate.sourceCanvasId}:${candidate.sourceNodeId}`; + if (deduped.has(dedupeKey)) { + continue; + } + deduped.set(dedupeKey, candidate); + if (deduped.size >= limit) { + break; + } + } + } + + return [...deduped.values()].slice(0, limit); +} + export const getSnapshot = query({ args: {}, handler: async (ctx) => { @@ -130,7 +229,7 @@ export const getSnapshot = query({ }; } - const [balanceRow, subscriptionRow, usageTransactions, recentTransactionsRaw, canvases] = + const [balanceRow, subscriptionRow, usageTransactions, recentTransactionsRaw, canvases, mediaArchiveRows] = await Promise.all([ ctx.db .query("creditBalances") @@ -156,18 +255,17 @@ export const getSnapshot = query({ .withIndex("by_owner_updated", (q) => q.eq("ownerId", user.userId)) .order("desc") .collect(), + ctx.db + .query("mediaItems") + .withIndex("by_owner_updated", (q) => q.eq("ownerId", user.userId)) + .order("desc") + .take(Math.max(DASHBOARD_MEDIA_PREVIEW_LIMIT * MEDIA_ARCHIVE_FETCH_MULTIPLIER, 32)), ]); - const imageNodesByCanvas = await Promise.all( - canvases.map((canvas) => - ctx.db - .query("nodes") - .withIndex("by_canvas_type", (q) => q.eq("canvasId", canvas._id).eq("type", "image")) - .order("desc") - .collect(), - ), - ); - const mediaPreview = buildMediaPreview(imageNodesByCanvas.flat(), DASHBOARD_MEDIA_PREVIEW_LIMIT); + let mediaPreview = buildMediaPreviewFromArchive(mediaArchiveRows, DASHBOARD_MEDIA_PREVIEW_LIMIT); + if (mediaPreview.length === 0 && mediaArchiveRows.length === 0) { + mediaPreview = await buildMediaPreviewFromNodeFallback(ctx, canvases, DASHBOARD_MEDIA_PREVIEW_LIMIT); + } const tier = normalizeBillingTier(subscriptionRow?.tier); const monthStart = new Date(new Date().getFullYear(), new Date().getMonth(), 1).getTime(); @@ -215,34 +313,42 @@ export const getSnapshot = query({ export const listMediaLibrary = query({ args: { limit: v.optional(v.number()), + kindFilter: v.optional(v.union(v.literal("image"), v.literal("video"), v.literal("asset"))), }, - handler: async (ctx, { limit }) => { + handler: async (ctx, { limit, kindFilter }) => { const user = await optionalAuth(ctx); if (!user) { return []; } const normalizedLimit = normalizeMediaLibraryLimit(limit); + const baseTake = Math.max(normalizedLimit * MEDIA_ARCHIVE_FETCH_MULTIPLIER, normalizedLimit); + const mediaArchiveRows = kindFilter + ? await ctx.db + .query("mediaItems") + .withIndex("by_owner_kind_updated", (q) => q.eq("ownerId", user.userId).eq("kind", kindFilter)) + .order("desc") + .take(baseTake) + : await ctx.db + .query("mediaItems") + .withIndex("by_owner_updated", (q) => q.eq("ownerId", user.userId)) + .order("desc") + .take(baseTake); + const mediaFromArchive = buildMediaPreviewFromArchive(mediaArchiveRows, normalizedLimit, kindFilter); + if (mediaFromArchive.length > 0 || mediaArchiveRows.length > 0) { + return mediaFromArchive; + } + + if (kindFilter && kindFilter !== "image") { + return []; + } + const canvases = await ctx.db .query("canvases") .withIndex("by_owner_updated", (q) => q.eq("ownerId", user.userId)) .order("desc") .collect(); - if (canvases.length === 0) { - return []; - } - - const imageNodesByCanvas = await Promise.all( - canvases.map((canvas) => - ctx.db - .query("nodes") - .withIndex("by_canvas_type", (q) => q.eq("canvasId", canvas._id).eq("type", "image")) - .order("desc") - .collect(), - ), - ); - - return buildMediaPreview(imageNodesByCanvas.flat(), normalizedLimit); + return await buildMediaPreviewFromNodeFallback(ctx, canvases, normalizedLimit); }, }); diff --git a/convex/media.ts b/convex/media.ts new file mode 100644 index 0000000..ab6c091 --- /dev/null +++ b/convex/media.ts @@ -0,0 +1,412 @@ +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, + }); + }, +}); diff --git a/convex/migrations.ts b/convex/migrations.ts new file mode 100644 index 0000000..4425a09 --- /dev/null +++ b/convex/migrations.ts @@ -0,0 +1,109 @@ +import { v } from "convex/values"; + +import { internalMutation, type MutationCtx } from "./_generated/server"; +import type { Id } from "./_generated/dataModel"; +import { backfillLegacyMediaForCanvas } from "./media"; + +const MEDIA_BACKFILL_DEFAULT_BATCH_SIZE = 25; +const MEDIA_BACKFILL_MIN_BATCH_SIZE = 1; +const MEDIA_BACKFILL_MAX_BATCH_SIZE = 200; + +export type MediaArchiveBackfillBatchArgs = { + cursor?: Id<"canvases">; + batchSize?: number; + now?: number; +}; + +export type MediaArchiveBackfillBatchResult = { + processedCanvasCount: number; + scannedNodeCount: number; + upsertedItemCount: number; + nextCursor: Id<"canvases"> | null; + done: boolean; +}; + +function normalizeBatchSize(batchSize: number | undefined): number { + if (typeof batchSize !== "number" || !Number.isFinite(batchSize)) { + return MEDIA_BACKFILL_DEFAULT_BATCH_SIZE; + } + + return Math.min( + MEDIA_BACKFILL_MAX_BATCH_SIZE, + Math.max(MEDIA_BACKFILL_MIN_BATCH_SIZE, Math.floor(batchSize)), + ); +} + +function computeStartIndex( + canvasIds: Array>, + cursor: Id<"canvases"> | undefined, +): number { + if (!cursor) { + return 0; + } + + const exactCursorIndex = canvasIds.findIndex((canvasId) => canvasId === cursor); + if (exactCursorIndex >= 0) { + return exactCursorIndex + 1; + } + + const fallbackIndex = canvasIds.findIndex((canvasId) => canvasId > cursor); + return fallbackIndex >= 0 ? fallbackIndex : canvasIds.length; +} + +export async function backfillMediaArchiveBatch( + ctx: MutationCtx, + { cursor, batchSize, now = Date.now() }: MediaArchiveBackfillBatchArgs, +): Promise { + const normalizedBatchSize = normalizeBatchSize(batchSize); + const canvases = await ctx.db.query("canvases").order("asc").collect(); + const canvasIds = canvases.map((canvas) => canvas._id); + + const startIndex = computeStartIndex(canvasIds, cursor); + const batch = canvases.slice(startIndex, startIndex + normalizedBatchSize); + + let scannedNodeCount = 0; + let upsertedItemCount = 0; + + for (const canvas of batch) { + const nodes = await ctx.db + .query("nodes") + .withIndex("by_canvas", (q) => q.eq("canvasId", canvas._id)) + .collect(); + + const canvasResult = await backfillLegacyMediaForCanvas(ctx, { + canvas: { + _id: canvas._id, + ownerId: canvas.ownerId, + }, + nodes, + now, + }); + + scannedNodeCount += canvasResult.scannedNodeCount; + upsertedItemCount += canvasResult.upsertedItemCount; + } + + const processedCanvasCount = batch.length; + const done = startIndex + processedCanvasCount >= canvases.length; + const nextCursor = + processedCanvasCount > 0 ? batch[processedCanvasCount - 1]._id : (cursor ?? null); + + return { + processedCanvasCount, + scannedNodeCount, + upsertedItemCount, + nextCursor, + done, + }; +} + +export const backfillMediaArchiveBatchInternal = internalMutation({ + args: { + cursor: v.optional(v.id("canvases")), + batchSize: v.optional(v.number()), + now: v.optional(v.number()), + }, + handler: async (ctx, args) => { + return await backfillMediaArchiveBatch(ctx, args); + }, +}); diff --git a/convex/schema.ts b/convex/schema.ts index ae95520..f30d14a 100644 --- a/convex/schema.ts +++ b/convex/schema.ts @@ -32,6 +32,20 @@ const nodeStatus = v.union( v.literal("error") ); +const mediaItemKind = v.union( + v.literal("image"), + v.literal("video"), + v.literal("asset") +); + +const mediaItemSource = v.union( + v.literal("upload"), + v.literal("ai-image"), + v.literal("ai-video"), + v.literal("freepik-asset"), + v.literal("pexels-video") +); + // ============================================================================ // Node Data — typ-spezifische Payloads // ============================================================================ @@ -187,6 +201,34 @@ export default defineSchema({ .index("by_userId", ["userId"]) .index("by_userId_nodeType", ["userId", "nodeType"]), + mediaItems: defineTable({ + ownerId: v.string(), + kind: mediaItemKind, + source: mediaItemSource, + 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")), + createdAt: v.number(), + updatedAt: v.number(), + lastUsedAt: v.number(), + }) + .index("by_owner_updated", ["ownerId", "updatedAt"]) + .index("by_owner_kind_updated", ["ownerId", "kind", "updatedAt"]) + .index("by_owner_dedupe", ["ownerId", "dedupeKey"]), + // ========================================================================== // Credit-System // ========================================================================== diff --git a/convex/storage.ts b/convex/storage.ts index 25d7ec1..2d1f19d 100644 --- a/convex/storage.ts +++ b/convex/storage.ts @@ -2,6 +2,8 @@ import { mutation, type MutationCtx, type QueryCtx } from "./_generated/server"; import { v } from "convex/values"; import { requireAuth } from "./helpers"; import type { Id } from "./_generated/dataModel"; +import { collectOwnedMediaStorageIds, upsertMediaItemByOwnerAndDedupe } from "./media"; +import { buildStoredMediaDedupeKey } from "../lib/media-archive"; const STORAGE_URL_BATCH_SIZE = 12; const PERFORMANCE_LOG_THRESHOLD_MS = 250; @@ -30,6 +32,24 @@ type StorageUrlResult = error: string; }; +export function verifyOwnedStorageIds( + requestedStorageIds: Array>, + ownedStorageIds: Set>, +): { + verifiedStorageIds: Array>; + rejectedStorageIds: number; +} { + const uniqueSortedStorageIds = [...new Set(requestedStorageIds)].sort(); + const verifiedStorageIds = uniqueSortedStorageIds.filter((storageId) => + ownedStorageIds.has(storageId), + ); + + return { + verifiedStorageIds, + rejectedStorageIds: uniqueSortedStorageIds.length - verifiedStorageIds.length, + }; +} + async function assertCanvasOwner( ctx: QueryCtx | MutationCtx, canvasId: Id<"canvases">, @@ -170,20 +190,24 @@ export const batchGetUrlsForUserMedia = mutation({ const startedAt = Date.now(); const user = await requireAuth(ctx); - const uniqueSortedStorageIds = [...new Set(storageIds)].sort(); - if (uniqueSortedStorageIds.length === 0) { + if (storageIds.length === 0) { return {}; } - const ownedStorageIds = await collectOwnedImageStorageIdsForUser(ctx, user.userId); - const verifiedStorageIds = uniqueSortedStorageIds.filter((storageId) => - ownedStorageIds.has(storageId), + const mediaItems = await ctx.db + .query("mediaItems") + .withIndex("by_owner_updated", (q) => q.eq("ownerId", user.userId)) + .collect(); + const ownedStorageIds = collectOwnedMediaStorageIds(mediaItems); + const { verifiedStorageIds, rejectedStorageIds } = verifyOwnedStorageIds( + storageIds, + ownedStorageIds, ); - const rejectedStorageIds = uniqueSortedStorageIds.length - verifiedStorageIds.length; + if (rejectedStorageIds > 0) { console.warn("[storage.batchGetUrlsForUserMedia] rejected unowned storage ids", { userId: user.userId, - requestedCount: uniqueSortedStorageIds.length, + requestedCount: storageIds.length, rejectedStorageIds, }); } @@ -236,6 +260,22 @@ export const registerUploadedImageMedia = mutation({ } } + await upsertMediaItemByOwnerAndDedupe(ctx, { + ownerId: user.userId, + input: { + kind: "image", + source: "upload", + dedupeKey: buildStoredMediaDedupeKey(args.storageId), + storageId: args.storageId, + filename: args.filename, + mimeType: args.mimeType, + width: args.width, + height: args.height, + firstSourceCanvasId: args.canvasId, + firstSourceNodeId: args.nodeId, + }, + }); + console.info("[storage.registerUploadedImageMedia] acknowledged", { userId: user.userId, canvasId: args.canvasId, @@ -280,42 +320,3 @@ function collectStorageIds( return [...ids]; } - -async function collectOwnedImageStorageIdsForUser( - ctx: QueryCtx | MutationCtx, - userId: string, -): Promise>> { - const canvases = await ctx.db - .query("canvases") - .withIndex("by_owner", (q) => q.eq("ownerId", userId)) - .collect(); - if (canvases.length === 0) { - return new Set(); - } - - const imageNodesByCanvas = await Promise.all( - canvases.map((canvas) => - ctx.db - .query("nodes") - .withIndex("by_canvas_type", (q) => q.eq("canvasId", canvas._id).eq("type", "image")) - .collect(), - ), - ); - - const imageStorageIds = new Set>(); - for (const nodes of imageNodesByCanvas) { - for (const node of nodes) { - const data = node.data as Record | undefined; - const storageId = data?.storageId; - const previewStorageId = data?.previewStorageId; - if (typeof storageId === "string" && storageId.length > 0) { - imageStorageIds.add(storageId as Id<"_storage">); - } - if (typeof previewStorageId === "string" && previewStorageId.length > 0) { - imageStorageIds.add(previewStorageId as Id<"_storage">); - } - } - } - - return imageStorageIds; -} diff --git a/hooks/use-dashboard-snapshot.ts b/hooks/use-dashboard-snapshot.ts index 4163fc6..640e4ce 100644 --- a/hooks/use-dashboard-snapshot.ts +++ b/hooks/use-dashboard-snapshot.ts @@ -14,6 +14,26 @@ import { export type DashboardSnapshot = FunctionReturnType; +function isDashboardSnapshotShapeCompatible(snapshot: unknown): snapshot is DashboardSnapshot { + if (!snapshot || typeof snapshot !== "object") { + return false; + } + + const value = snapshot as { mediaPreview?: unknown }; + if (!Array.isArray(value.mediaPreview)) { + return false; + } + + return value.mediaPreview.every((item) => { + if (!item || typeof item !== "object") { + return false; + } + + const kind = (item as { kind?: unknown }).kind; + return kind === "image" || kind === "video" || kind === "asset"; + }); +} + export function useDashboardSnapshot(userId?: string | null): { snapshot: DashboardSnapshot | undefined; source: "live" | "cache" | "none"; @@ -25,7 +45,12 @@ export function useDashboardSnapshot(userId?: string | null): { return null; } - return readDashboardSnapshotCache(userId)?.snapshot ?? null; + const cached = readDashboardSnapshotCache(userId)?.snapshot ?? null; + if (!cached) { + return null; + } + + return isDashboardSnapshotShapeCompatible(cached) ? cached : null; }, [userId, cacheEpoch]); useEffect(() => { diff --git a/lib/dashboard-snapshot-cache.ts b/lib/dashboard-snapshot-cache.ts index 51fb2a3..d3ba39f 100644 --- a/lib/dashboard-snapshot-cache.ts +++ b/lib/dashboard-snapshot-cache.ts @@ -1,5 +1,5 @@ const STORAGE_NAMESPACE = "lemonspace.dashboard"; -const CACHE_VERSION = 1; +const CACHE_VERSION = 2; const DEFAULT_TTL_MS = 12 * 60 * 60 * 1000; const LAST_DASHBOARD_USER_KEY = "ls-last-dashboard-user"; const INVALIDATION_SIGNAL_KEY = `${STORAGE_NAMESPACE}:snapshot:invalidate:v${CACHE_VERSION}`; diff --git a/lib/media-archive.ts b/lib/media-archive.ts new file mode 100644 index 0000000..f9686b7 --- /dev/null +++ b/lib/media-archive.ts @@ -0,0 +1,115 @@ +export type MediaArchiveKind = "image" | "video" | "asset"; + +export type MediaArchiveSource = + | "upload" + | "ai-image" + | "ai-video" + | "freepik-asset" + | "pexels-video"; + +type MediaArchiveCommonFields = { + kind: MediaArchiveKind; + source: MediaArchiveSource; + dedupeKey: string; + title?: string; + filename?: string; + mimeType?: string; + width?: number; + height?: number; + durationSeconds?: number; + metadata?: Record; + firstSourceCanvasId?: string; + firstSourceNodeId?: string; +}; + +type MediaArchiveStoredFields = { + storageId?: string; + previewStorageId?: string; +}; + +type MediaArchiveExternalFields = { + originalUrl?: string; + previewUrl?: string; + sourceUrl?: string; + providerAssetId?: string; +}; + +export type MediaArchiveInput = MediaArchiveCommonFields & + MediaArchiveStoredFields & + MediaArchiveExternalFields & { + [key: string]: unknown; + }; + +export type NormalizedMediaArchiveInput = + | (MediaArchiveCommonFields & MediaArchiveStoredFields) + | (MediaArchiveCommonFields & MediaArchiveExternalFields); + +export type MediaArchiveRow = (MediaArchiveCommonFields & + MediaArchiveStoredFields & + MediaArchiveExternalFields & { + _id: string; + createdAt: number; + updatedAt: number; + lastUsedAt: number; + }); + +export type MediaArchiveListItem = Omit & { + id: string; +}; + +export function buildStoredMediaDedupeKey(storageId: string): string { + return `storage:${storageId}`; +} + +export function buildFreepikAssetDedupeKey(assetType: string, assetId: number | string): string { + return `freepik:${assetType}:${assetId}`; +} + +export function buildPexelsVideoDedupeKey(videoId: number | string): string { + return `pexels:video:${videoId}`; +} + +function isExternalSource(source: MediaArchiveSource): boolean { + return source === "freepik-asset" || source === "pexels-video"; +} + +export function normalizeMediaArchiveInput(input: MediaArchiveInput): NormalizedMediaArchiveInput { + const base: MediaArchiveCommonFields = { + kind: input.kind, + source: input.source, + dedupeKey: input.dedupeKey, + title: input.title, + filename: input.filename, + mimeType: input.mimeType, + width: input.width, + height: input.height, + durationSeconds: input.durationSeconds, + metadata: input.metadata, + firstSourceCanvasId: input.firstSourceCanvasId, + firstSourceNodeId: input.firstSourceNodeId, + }; + + if (isExternalSource(input.source)) { + return { + ...base, + originalUrl: input.originalUrl, + previewUrl: input.previewUrl, + sourceUrl: input.sourceUrl, + providerAssetId: input.providerAssetId, + }; + } + + return { + ...base, + storageId: input.storageId, + previewStorageId: input.previewStorageId, + }; +} + +export function mapMediaArchiveRowToListItem(row: MediaArchiveRow): MediaArchiveListItem { + const { _id, ...rest } = row; + return { + id: _id, + ...rest, + }; +} diff --git a/tests/convex/media-archive.test.ts b/tests/convex/media-archive.test.ts new file mode 100644 index 0000000..d64dcd0 --- /dev/null +++ b/tests/convex/media-archive.test.ts @@ -0,0 +1,478 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; + +vi.mock("@/convex/helpers", () => ({ + requireAuth: vi.fn(), + optionalAuth: vi.fn(), +})); + +import type { Id } from "@/convex/_generated/dataModel"; +import { finalizeImageSuccess, finalizeVideoSuccess } from "@/convex/ai"; +import { + collectOwnedMediaStorageIds, + listMediaArchiveItems, + upsertMediaItemByOwnerAndDedupe, +} from "@/convex/media"; +import { verifyOwnedStorageIds } from "@/convex/storage"; +import { registerUploadedImageMedia } from "@/convex/storage"; +import { buildStoredMediaDedupeKey } from "@/lib/media-archive"; +import { requireAuth } from "@/convex/helpers"; + +type MockMediaItem = { + _id: Id<"mediaItems">; + ownerId: string; + dedupeKey: string; + kind: "image" | "video" | "asset"; + source: "upload" | "ai-image" | "ai-video" | "freepik-asset" | "pexels-video"; + storageId?: Id<"_storage">; + previewStorageId?: Id<"_storage">; + createdAt: number; + updatedAt: number; + lastUsedAt: number; +}; + +function createMockDb(initialRows: MockMediaItem[] = []) { + const rows = [...initialRows]; + + return { + rows, + db: { + query: (table: "mediaItems") => { + expect(table).toBe("mediaItems"); + + return { + withIndex: ( + index: "by_owner_dedupe", + apply: (q: { eq: (field: string, value: unknown) => unknown }) => unknown, + ) => { + expect(index).toBe("by_owner_dedupe"); + + const clauses: Array<{ field: string; value: unknown }> = []; + const queryBuilder = { + eq(field: string, value: unknown) { + clauses.push({ field, value }); + return this; + }, + }; + + apply(queryBuilder); + + return { + unique: async () => { + const ownerId = clauses.find((clause) => clause.field === "ownerId")?.value; + const dedupeKey = clauses.find((clause) => clause.field === "dedupeKey")?.value; + + return ( + rows.find((row) => row.ownerId === ownerId && row.dedupeKey === dedupeKey) ?? null + ); + }, + }; + }, + }; + }, + insert: async (_table: "mediaItems", value: Omit) => { + const inserted = { + _id: `media_${rows.length + 1}` as Id<"mediaItems">, + ...value, + }; + rows.push(inserted); + return inserted._id; + }, + patch: async (id: Id<"mediaItems">, patch: Partial) => { + const row = rows.find((entry) => entry._id === id); + if (!row) { + throw new Error("row missing"); + } + Object.assign(row, patch); + }, + get: async (id: Id<"mediaItems">) => rows.find((entry) => entry._id === id) ?? null, + }, + }; +} + +describe("media archive", () => { + afterEach(() => { + vi.restoreAllMocks(); + }); + + it("lists media ordered by updatedAt desc and supports kind filter", () => { + const media = listMediaArchiveItems( + [ + { + _id: "media_1" as Id<"mediaItems">, + _creationTime: 1, + ownerId: "user_1", + dedupeKey: "storage:s1", + kind: "image", + source: "upload", + createdAt: 1, + updatedAt: 10, + lastUsedAt: 10, + }, + { + _id: "media_2" as Id<"mediaItems">, + _creationTime: 2, + ownerId: "user_1", + dedupeKey: "storage:s2", + kind: "video", + source: "ai-video", + createdAt: 2, + updatedAt: 50, + lastUsedAt: 50, + }, + { + _id: "media_3" as Id<"mediaItems">, + _creationTime: 3, + ownerId: "user_1", + dedupeKey: "storage:s3", + kind: "image", + source: "ai-image", + createdAt: 3, + updatedAt: 30, + lastUsedAt: 30, + }, + ], + { kind: "image", limit: 2 }, + ); + + expect(media.map((item) => item.id)).toEqual(["media_3", "media_1"]); + expect(media.every((item) => item.kind === "image")).toBe(true); + }); + + it("upserts idempotently by owner+dedupe", async () => { + const now = 1700000000000; + const { rows, db } = createMockDb(); + + const first = await upsertMediaItemByOwnerAndDedupe( + { db } as never, + { + ownerId: "user_1", + now, + input: { + kind: "image", + source: "upload", + dedupeKey: "storage:abc", + storageId: "storage_abc" as Id<"_storage">, + }, + }, + ); + + const second = await upsertMediaItemByOwnerAndDedupe( + { db } as never, + { + ownerId: "user_1", + now: now + 5, + input: { + kind: "image", + source: "upload", + dedupeKey: "storage:abc", + storageId: "storage_abc" as Id<"_storage">, + }, + }, + ); + + expect(rows).toHaveLength(1); + expect(second._id).toBe(first._id); + expect(second.updatedAt).toBe(now + 5); + expect(second.lastUsedAt).toBe(now + 5); + }); + + it("verifies user media ownership over storage and preview ids", () => { + const ownedSet = collectOwnedMediaStorageIds([ + { + storageId: "storage_original" as Id<"_storage">, + previewStorageId: "storage_preview" as Id<"_storage">, + }, + ]); + + const result = verifyOwnedStorageIds( + [ + "storage_preview" as Id<"_storage">, + "storage_original" as Id<"_storage">, + "storage_unowned" as Id<"_storage">, + ], + ownedSet, + ); + + expect(result.verifiedStorageIds).toEqual([ + "storage_original" as Id<"_storage">, + "storage_preview" as Id<"_storage">, + ]); + expect(result.rejectedStorageIds).toBe(1); + }); + + it("registerUploadedImageMedia persists upload archive entry", async () => { + vi.mocked(requireAuth).mockResolvedValue({ userId: "user_1" } as never); + + const rows: MockMediaItem[] = []; + const canvasId = "canvas_1" as Id<"canvases">; + const nodeId = "node_1" as Id<"nodes">; + const storageId = "storage_upload_1" as Id<"_storage">; + + const docs = new Map([ + [canvasId, { _id: canvasId, ownerId: "user_1" }], + [nodeId, { _id: nodeId, canvasId }], + ]); + + const db = { + get: vi.fn(async (id: string) => { + return rows.find((row) => row._id === id) ?? docs.get(id) ?? null; + }), + query: vi.fn((table: "mediaItems") => { + expect(table).toBe("mediaItems"); + return { + withIndex: vi.fn( + ( + index: "by_owner_dedupe", + apply: (q: { eq: (field: string, value: unknown) => unknown }) => unknown, + ) => { + expect(index).toBe("by_owner_dedupe"); + const clauses: Array<{ field: string; value: unknown }> = []; + const queryBuilder = { + eq(field: string, value: unknown) { + clauses.push({ field, value }); + return this; + }, + }; + apply(queryBuilder); + return { + unique: async () => { + const ownerId = clauses.find((clause) => clause.field === "ownerId")?.value; + const dedupeKey = clauses.find((clause) => clause.field === "dedupeKey")?.value; + return rows.find((row) => row.ownerId === ownerId && row.dedupeKey === dedupeKey) ?? null; + }, + }; + }, + ), + }; + }), + insert: vi.fn(async (_table: "mediaItems", value: Omit) => { + const inserted = { + _id: `media_${rows.length + 1}` as Id<"mediaItems">, + ...value, + }; + rows.push(inserted); + return inserted._id; + }), + patch: vi.fn(async (id: Id<"mediaItems">, patch: Partial) => { + const row = rows.find((entry) => entry._id === id); + if (!row) throw new Error("row missing"); + Object.assign(row, patch); + }), + }; + + await (registerUploadedImageMedia as unknown as { _handler: (ctx: unknown, args: unknown) => Promise })._handler( + { db } as never, + { + canvasId, + nodeId, + storageId, + filename: "sunset.png", + mimeType: "image/png", + width: 1920, + height: 1080, + }, + ); + + expect(rows).toHaveLength(1); + expect(rows[0]).toMatchObject({ + ownerId: "user_1", + kind: "image", + source: "upload", + dedupeKey: buildStoredMediaDedupeKey(storageId), + storageId, + filename: "sunset.png", + mimeType: "image/png", + width: 1920, + height: 1080, + firstSourceCanvasId: canvasId, + firstSourceNodeId: nodeId, + }); + }); + + it("finalizeImageSuccess writes ai-image archive entry", async () => { + const rows: MockMediaItem[] = []; + const now = 1700000000000; + const nodeId = "node_ai_image_1" as Id<"nodes">; + const canvasId = "canvas_1" as Id<"canvases">; + const storageId = "storage_ai_image_1" as Id<"_storage">; + + const nodeDoc = { + _id: nodeId, + canvasId, + data: {}, + status: "executing", + retryCount: 0, + }; + + const docs = new Map([ + [nodeId, nodeDoc], + [canvasId, { _id: canvasId, ownerId: "user_1" }], + ]); + + vi.spyOn(Date, "now").mockReturnValue(now); + + const db = { + get: vi.fn(async (id: string) => { + return rows.find((row) => row._id === id) ?? docs.get(id) ?? null; + }), + patch: vi.fn(async (id: string, patch: Record) => { + const doc = docs.get(id) as Record | undefined; + if (!doc) throw new Error("missing doc"); + Object.assign(doc, patch); + }), + query: vi.fn((table: "mediaItems") => { + expect(table).toBe("mediaItems"); + return { + withIndex: vi.fn( + ( + index: "by_owner_dedupe", + apply: (q: { eq: (field: string, value: unknown) => unknown }) => unknown, + ) => { + expect(index).toBe("by_owner_dedupe"); + const clauses: Array<{ field: string; value: unknown }> = []; + const queryBuilder = { + eq(field: string, value: unknown) { + clauses.push({ field, value }); + return this; + }, + }; + apply(queryBuilder); + return { + unique: async () => { + const ownerId = clauses.find((clause) => clause.field === "ownerId")?.value; + const dedupeKey = clauses.find((clause) => clause.field === "dedupeKey")?.value; + return rows.find((row) => row.ownerId === ownerId && row.dedupeKey === dedupeKey) ?? null; + }, + }; + }, + ), + }; + }), + insert: vi.fn(async (_table: "mediaItems", value: Omit) => { + const inserted = { + _id: `media_${rows.length + 1}` as Id<"mediaItems">, + ...value, + }; + rows.push(inserted); + return inserted._id; + }), + }; + + await (finalizeImageSuccess as unknown as { _handler: (ctx: unknown, args: unknown) => Promise<{ creditCost: number }> })._handler( + { db } as never, + { + nodeId, + prompt: "cinematic sunset over lake", + modelId: "google/gemini-2.5-flash-image", + storageId, + retryCount: 1, + }, + ); + + expect(rows).toHaveLength(1); + expect(rows[0]).toMatchObject({ + ownerId: "user_1", + kind: "image", + source: "ai-image", + dedupeKey: buildStoredMediaDedupeKey(storageId), + storageId, + firstSourceCanvasId: canvasId, + firstSourceNodeId: nodeId, + }); + }); + + it("finalizeVideoSuccess writes ai-video archive entry", async () => { + const rows: MockMediaItem[] = []; + const now = 1700000000000; + const nodeId = "node_ai_video_1" as Id<"nodes">; + const canvasId = "canvas_1" as Id<"canvases">; + const storageId = "storage_ai_video_1" as Id<"_storage">; + + const nodeDoc = { + _id: nodeId, + canvasId, + data: {}, + status: "executing", + retryCount: 0, + }; + + const docs = new Map([ + [nodeId, nodeDoc], + [canvasId, { _id: canvasId, ownerId: "user_1" }], + ]); + + vi.spyOn(Date, "now").mockReturnValue(now); + + const db = { + get: vi.fn(async (id: string) => { + return rows.find((row) => row._id === id) ?? docs.get(id) ?? null; + }), + patch: vi.fn(async (id: string, patch: Record) => { + const doc = docs.get(id) as Record | undefined; + if (!doc) throw new Error("missing doc"); + Object.assign(doc, patch); + }), + query: vi.fn((table: "mediaItems") => { + expect(table).toBe("mediaItems"); + return { + withIndex: vi.fn( + ( + index: "by_owner_dedupe", + apply: (q: { eq: (field: string, value: unknown) => unknown }) => unknown, + ) => { + expect(index).toBe("by_owner_dedupe"); + const clauses: Array<{ field: string; value: unknown }> = []; + const queryBuilder = { + eq(field: string, value: unknown) { + clauses.push({ field, value }); + return this; + }, + }; + apply(queryBuilder); + return { + unique: async () => { + const ownerId = clauses.find((clause) => clause.field === "ownerId")?.value; + const dedupeKey = clauses.find((clause) => clause.field === "dedupeKey")?.value; + return rows.find((row) => row.ownerId === ownerId && row.dedupeKey === dedupeKey) ?? null; + }, + }; + }, + ), + }; + }), + insert: vi.fn(async (_table: "mediaItems", value: Omit) => { + const inserted = { + _id: `media_${rows.length + 1}` as Id<"mediaItems">, + ...value, + }; + rows.push(inserted); + return inserted._id; + }), + }; + + await (finalizeVideoSuccess as unknown as { _handler: (ctx: unknown, args: unknown) => Promise })._handler( + { db } as never, + { + nodeId, + prompt: "camera truck left", + modelId: "wan-2-2-720p", + durationSeconds: 5, + storageId, + retryCount: 3, + creditCost: 52, + }, + ); + + expect(rows).toHaveLength(1); + expect(rows[0]).toMatchObject({ + ownerId: "user_1", + kind: "video", + source: "ai-video", + dedupeKey: buildStoredMediaDedupeKey(storageId), + storageId, + durationSeconds: 5, + firstSourceCanvasId: canvasId, + firstSourceNodeId: nodeId, + }); + }); +}); diff --git a/tests/convex/media-backfill.test.ts b/tests/convex/media-backfill.test.ts new file mode 100644 index 0000000..5fb78bb --- /dev/null +++ b/tests/convex/media-backfill.test.ts @@ -0,0 +1,356 @@ +import { describe, expect, it, vi } from "vitest"; + +vi.mock("@/convex/helpers", () => ({ + requireAuth: vi.fn(), + optionalAuth: vi.fn(), +})); + +import type { Id } from "@/convex/_generated/dataModel"; +import { + backfillLegacyMediaForCanvas, + mapLegacyNodeToMediaArchiveInput, +} from "@/convex/media"; +import { backfillMediaArchiveBatch } from "@/convex/migrations"; +import { + buildFreepikAssetDedupeKey, + buildPexelsVideoDedupeKey, + buildStoredMediaDedupeKey, +} from "@/lib/media-archive"; + +type MockCanvas = { + _id: Id<"canvases">; + ownerId: string; +}; + +type MockNode = { + _id: Id<"nodes">; + canvasId: Id<"canvases">; + type: string; + data: Record; +}; + +type MockMediaItem = { + _id: Id<"mediaItems">; + ownerId: string; + dedupeKey: string; + kind: "image" | "video" | "asset"; + source: "upload" | "ai-image" | "ai-video" | "freepik-asset" | "pexels-video"; + storageId?: Id<"_storage">; + providerAssetId?: string; + createdAt: number; + updatedAt: number; + lastUsedAt: number; +}; + +function createMockDb(args: { + canvases: MockCanvas[]; + nodes: MockNode[]; + mediaItems?: MockMediaItem[]; +}) { + const rows = [...(args.mediaItems ?? [])]; + + const db = { + query: (table: "canvases" | "nodes" | "mediaItems") => { + if (table === "canvases") { + return { + order: (direction: "asc" | "desc") => { + expect(direction).toBe("asc"); + return { + collect: async () => [...args.canvases], + }; + }, + }; + } + + if (table === "nodes") { + return { + withIndex: ( + index: "by_canvas", + apply: (q: { eq: (field: string, value: unknown) => unknown }) => unknown, + ) => { + expect(index).toBe("by_canvas"); + + const clauses: Array<{ field: string; value: unknown }> = []; + const queryBuilder = { + eq(field: string, value: unknown) { + clauses.push({ field, value }); + return this; + }, + }; + + apply(queryBuilder); + + return { + collect: async () => { + const canvasId = clauses.find((clause) => clause.field === "canvasId")?.value; + return args.nodes.filter((node) => node.canvasId === canvasId); + }, + }; + }, + }; + } + + return { + withIndex: ( + index: "by_owner_dedupe", + apply: (q: { eq: (field: string, value: unknown) => unknown }) => unknown, + ) => { + expect(index).toBe("by_owner_dedupe"); + + const clauses: Array<{ field: string; value: unknown }> = []; + const queryBuilder = { + eq(field: string, value: unknown) { + clauses.push({ field, value }); + return this; + }, + }; + + apply(queryBuilder); + + return { + unique: async () => { + const ownerId = clauses.find((clause) => clause.field === "ownerId")?.value; + const dedupeKey = clauses.find((clause) => clause.field === "dedupeKey")?.value; + return rows.find((row) => row.ownerId === ownerId && row.dedupeKey === dedupeKey) ?? null; + }, + }; + }, + }; + }, + insert: async (_table: "mediaItems", value: Omit) => { + const inserted = { + _id: `media_${rows.length + 1}` as Id<"mediaItems">, + ...value, + }; + rows.push(inserted); + return inserted._id; + }, + patch: async (id: Id<"mediaItems">, patchValue: Partial) => { + const row = rows.find((entry) => entry._id === id); + if (!row) { + throw new Error("missing row"); + } + Object.assign(row, patchValue); + }, + get: async (id: Id<"mediaItems">) => { + return rows.find((entry) => entry._id === id) ?? null; + }, + }; + + return { db, rows }; +} + +describe("media backfill", () => { + it("converts supported legacy node types into media archive inputs", () => { + const imageStorageId = "storage_image_1" as Id<"_storage">; + const aiImageStorageId = "storage_ai_image_1" as Id<"_storage">; + const aiVideoStorageId = "storage_ai_video_1" as Id<"_storage">; + + expect( + mapLegacyNodeToMediaArchiveInput({ + _id: "node_image" as Id<"nodes">, + canvasId: "canvas_1" as Id<"canvases">, + type: "image", + data: { + storageId: imageStorageId, + mimeType: "image/png", + width: 1920, + height: 1080, + }, + }), + ).toMatchObject({ + kind: "image", + source: "upload", + dedupeKey: buildStoredMediaDedupeKey(imageStorageId), + storageId: imageStorageId, + mimeType: "image/png", + width: 1920, + height: 1080, + }); + + expect( + mapLegacyNodeToMediaArchiveInput({ + _id: "node_ai_image" as Id<"nodes">, + canvasId: "canvas_1" as Id<"canvases">, + type: "ai-image", + data: { + storageId: aiImageStorageId, + }, + }), + ).toMatchObject({ + kind: "image", + source: "ai-image", + dedupeKey: buildStoredMediaDedupeKey(aiImageStorageId), + storageId: aiImageStorageId, + }); + + expect( + mapLegacyNodeToMediaArchiveInput({ + _id: "node_ai_video" as Id<"nodes">, + canvasId: "canvas_1" as Id<"canvases">, + type: "ai-video", + data: { + storageId: aiVideoStorageId, + durationSeconds: 10, + }, + }), + ).toMatchObject({ + kind: "video", + source: "ai-video", + dedupeKey: buildStoredMediaDedupeKey(aiVideoStorageId), + storageId: aiVideoStorageId, + durationSeconds: 10, + }); + + expect( + mapLegacyNodeToMediaArchiveInput({ + _id: "node_asset" as Id<"nodes">, + canvasId: "canvas_1" as Id<"canvases">, + type: "asset", + data: { + assetId: 123, + assetType: "photo", + sourceUrl: "https://www.freepik.com/asset/123", + previewUrl: "https://cdn.freepik.com/preview/123.jpg", + }, + }), + ).toMatchObject({ + kind: "asset", + source: "freepik-asset", + dedupeKey: buildFreepikAssetDedupeKey("photo", 123), + providerAssetId: "123", + sourceUrl: "https://www.freepik.com/asset/123", + previewUrl: "https://cdn.freepik.com/preview/123.jpg", + }); + + expect( + mapLegacyNodeToMediaArchiveInput({ + _id: "node_video" as Id<"nodes">, + canvasId: "canvas_1" as Id<"canvases">, + type: "video", + data: { + pexelsId: 987, + mp4Url: "https://videos.pexels.com/video-files/987.mp4", + thumbnailUrl: "https://images.pexels.com/videos/987.jpg", + duration: 42, + attribution: { + videoUrl: "https://www.pexels.com/video/987/", + }, + }, + }), + ).toMatchObject({ + kind: "video", + source: "pexels-video", + dedupeKey: buildPexelsVideoDedupeKey(987), + providerAssetId: "987", + originalUrl: "https://videos.pexels.com/video-files/987.mp4", + previewUrl: "https://images.pexels.com/videos/987.jpg", + sourceUrl: "https://www.pexels.com/video/987/", + durationSeconds: 42, + }); + }); + + it("collapses duplicates through upsert and remains idempotent", async () => { + const storageId = "storage_shared" as Id<"_storage">; + const canvas: MockCanvas = { _id: "canvas_1" as Id<"canvases">, ownerId: "user_1" }; + const nodes: MockNode[] = [ + { + _id: "node_1" as Id<"nodes">, + canvasId: canvas._id, + type: "image", + data: { storageId }, + }, + { + _id: "node_2" as Id<"nodes">, + canvasId: canvas._id, + type: "ai-image", + data: { storageId }, + }, + ]; + const { db, rows } = createMockDb({ canvases: [canvas], nodes }); + + await backfillLegacyMediaForCanvas( + { db } as never, + { + canvas, + nodes, + now: 1700000000000, + }, + ); + + await backfillLegacyMediaForCanvas( + { db } as never, + { + canvas, + nodes, + now: 1700000000100, + }, + ); + + expect(rows).toHaveLength(1); + expect(rows[0].dedupeKey).toBe(buildStoredMediaDedupeKey(storageId)); + expect(rows[0].updatedAt).toBe(1700000000100); + }); + + it("supports resumable cursor progression across batches", async () => { + const canvases: MockCanvas[] = [ + { _id: "canvas_1" as Id<"canvases">, ownerId: "user_1" }, + { _id: "canvas_2" as Id<"canvases">, ownerId: "user_1" }, + { _id: "canvas_3" as Id<"canvases">, ownerId: "user_2" }, + ]; + const nodes: MockNode[] = [ + { + _id: "node_1" as Id<"nodes">, + canvasId: canvases[0]._id, + type: "image", + data: { storageId: "storage_1" as Id<"_storage"> }, + }, + { + _id: "node_2" as Id<"nodes">, + canvasId: canvases[1]._id, + type: "image", + data: { storageId: "storage_2" as Id<"_storage"> }, + }, + { + _id: "node_3" as Id<"nodes">, + canvasId: canvases[2]._id, + type: "image", + data: { storageId: "storage_3" as Id<"_storage"> }, + }, + ]; + const { db, rows } = createMockDb({ canvases, nodes }); + + const first = await backfillMediaArchiveBatch( + { db } as never, + { batchSize: 1, now: 1700000000000 }, + ); + expect(first).toMatchObject({ + processedCanvasCount: 1, + done: false, + nextCursor: canvases[0]._id, + }); + expect(rows).toHaveLength(1); + + const second = await backfillMediaArchiveBatch( + { db } as never, + { batchSize: 1, cursor: first.nextCursor, now: 1700000000100 }, + ); + expect(second).toMatchObject({ + processedCanvasCount: 1, + done: false, + nextCursor: canvases[1]._id, + }); + expect(rows).toHaveLength(2); + + const third = await backfillMediaArchiveBatch( + { db } as never, + { batchSize: 1, cursor: second.nextCursor, now: 1700000000200 }, + ); + expect(third).toMatchObject({ + processedCanvasCount: 1, + done: true, + nextCursor: canvases[2]._id, + }); + expect(rows).toHaveLength(3); + }); +}); diff --git a/tests/lib/media-archive.test.ts b/tests/lib/media-archive.test.ts new file mode 100644 index 0000000..e510c74 --- /dev/null +++ b/tests/lib/media-archive.test.ts @@ -0,0 +1,135 @@ +import { describe, expect, it } from "vitest"; + +import { + buildFreepikAssetDedupeKey, + buildPexelsVideoDedupeKey, + buildStoredMediaDedupeKey, + mapMediaArchiveRowToListItem, + normalizeMediaArchiveInput, +} from "@/lib/media-archive"; + +describe("media archive helpers", () => { + it("builds storage dedupe keys", () => { + expect(buildStoredMediaDedupeKey("storage_123")).toBe("storage:storage_123"); + }); + + it("builds freepik dedupe keys", () => { + expect(buildFreepikAssetDedupeKey("photo", 42)).toBe("freepik:photo:42"); + }); + + it("builds pexels dedupe keys", () => { + expect(buildPexelsVideoDedupeKey(77)).toBe("pexels:video:77"); + }); + + it("normalizes stored media input and drops external-only fields", () => { + const normalized = normalizeMediaArchiveInput({ + kind: "image", + source: "upload", + dedupeKey: "storage:storage_1", + storageId: "storage_1", + previewStorageId: "preview_1", + filename: "photo.png", + mimeType: "image/png", + title: "Photo", + width: 1024, + height: 768, + durationSeconds: 12, + providerAssetId: "asset_1", + originalUrl: "https://cdn.example.com/original.png", + previewUrl: "https://cdn.example.com/preview.png", + sourceUrl: "https://example.com/origin", + metadata: { license: "custom" }, + firstSourceCanvasId: "canvas_1", + firstSourceNodeId: "node_1", + unknownField: "drop-me", + }); + + expect(normalized).toEqual({ + kind: "image", + source: "upload", + dedupeKey: "storage:storage_1", + storageId: "storage_1", + previewStorageId: "preview_1", + filename: "photo.png", + mimeType: "image/png", + title: "Photo", + width: 1024, + height: 768, + durationSeconds: 12, + metadata: { license: "custom" }, + firstSourceCanvasId: "canvas_1", + firstSourceNodeId: "node_1", + }); + }); + + it("normalizes external media input and drops storage-only fields", () => { + const normalized = normalizeMediaArchiveInput({ + kind: "asset", + source: "freepik-asset", + dedupeKey: "freepik:photo:42", + providerAssetId: "42", + title: "Palm Tree", + previewUrl: "https://cdn.freepik.com/preview.jpg", + originalUrl: "https://cdn.freepik.com/original.jpg", + sourceUrl: "https://www.freepik.com/asset/42", + storageId: "storage_1", + previewStorageId: "preview_1", + metadata: { license: "freepik-standard" }, + unknownField: "drop-me", + }); + + expect(normalized).toEqual({ + kind: "asset", + source: "freepik-asset", + dedupeKey: "freepik:photo:42", + providerAssetId: "42", + title: "Palm Tree", + previewUrl: "https://cdn.freepik.com/preview.jpg", + originalUrl: "https://cdn.freepik.com/original.jpg", + sourceUrl: "https://www.freepik.com/asset/42", + metadata: { license: "freepik-standard" }, + }); + }); + + it("maps media archive rows to one ui-facing card shape", () => { + const item = mapMediaArchiveRowToListItem({ + _id: "media_1", + kind: "video", + source: "pexels-video", + dedupeKey: "pexels:video:77", + title: "Ocean clip", + filename: "ocean.mp4", + mimeType: "video/mp4", + durationSeconds: 8, + previewUrl: "https://images.pexels.com/preview.jpg", + originalUrl: "https://videos.pexels.com/video.mp4", + sourceUrl: "https://www.pexels.com/video/77", + providerAssetId: "77", + width: 1920, + height: 1080, + updatedAt: 200, + createdAt: 100, + lastUsedAt: 200, + }); + + expect(item).toEqual({ + id: "media_1", + kind: "video", + source: "pexels-video", + dedupeKey: "pexels:video:77", + title: "Ocean clip", + filename: "ocean.mp4", + mimeType: "video/mp4", + durationSeconds: 8, + previewUrl: "https://images.pexels.com/preview.jpg", + originalUrl: "https://videos.pexels.com/video.mp4", + sourceUrl: "https://www.pexels.com/video/77", + providerAssetId: "77", + width: 1920, + height: 1080, + updatedAt: 200, + createdAt: 100, + lastUsedAt: 200, + }); + }); +}); diff --git a/tests/use-canvas-drop.test.ts b/tests/use-canvas-drop.test.ts index e66b536..4e4ff5d 100644 --- a/tests/use-canvas-drop.test.ts +++ b/tests/use-canvas-drop.test.ts @@ -40,9 +40,13 @@ type RunCreateNodeOnlineOnly = Parameters[0]["runCreateNod type HarnessProps = { runCreateNodeOnlineOnly: RunCreateNodeOnlineOnly; + registerUploadedImageMedia?: Parameters[0]["registerUploadedImageMedia"]; }; -function HookHarness({ runCreateNodeOnlineOnly }: HarnessProps) { +function HookHarness({ + runCreateNodeOnlineOnly, + registerUploadedImageMedia = async () => ({ ok: true }), +}: HarnessProps) { const value = useCanvasDrop({ canvasId: "canvas_1" as Id<"canvases">, isSyncOnline: true, @@ -50,7 +54,7 @@ function HookHarness({ runCreateNodeOnlineOnly }: HarnessProps) { edges: [], screenToFlowPosition: ({ x, y }) => ({ x, y }), generateUploadUrl: async () => "https://upload.example.com", - registerUploadedImageMedia: async () => ({ ok: true }), + registerUploadedImageMedia, runCreateNodeOnlineOnly, runCreateNodeWithEdgeSplitOnlineOnly: async () => "node_split_1" as Id<"nodes">, notifyOfflineUnsupported: () => {}, @@ -116,6 +120,7 @@ describe("useCanvasDrop image upload path", () => { const runCreateNodeOnlineOnly = vi .fn() .mockResolvedValue("node_1" as Id<"nodes">); + const registerUploadedImageMedia = vi.fn(async () => ({ ok: true as const })); container = document.createElement("div"); document.body.appendChild(container); @@ -125,6 +130,7 @@ describe("useCanvasDrop image upload path", () => { root?.render( React.createElement(HookHarness, { runCreateNodeOnlineOnly, + registerUploadedImageMedia, }), ); }); @@ -149,6 +155,15 @@ describe("useCanvasDrop image upload path", () => { expect(fetchMock).toHaveBeenCalledTimes(2); expect(runCreateNodeOnlineOnly).toHaveBeenCalledTimes(1); + expect(registerUploadedImageMedia).toHaveBeenCalledWith({ + canvasId: "canvas_1", + nodeId: "node_1", + storageId: "storage_1", + filename: "drop.png", + mimeType: "image/png", + width: 640, + height: 480, + }); expect(invalidateDashboardSnapshotForLastSignedInUserMock).toHaveBeenCalledTimes(1); expect(emitDashboardSnapshotCacheInvalidationSignalMock).toHaveBeenCalledTimes(1); }); diff --git a/tests/use-dashboard-snapshot.test.ts b/tests/use-dashboard-snapshot.test.ts index 5935c9a..e0a2b8e 100644 --- a/tests/use-dashboard-snapshot.test.ts +++ b/tests/use-dashboard-snapshot.test.ts @@ -49,7 +49,31 @@ function createCachedSnapshot() { }, ], canvases: [], - mediaPreview: [], + mediaPreview: [ + { + kind: "image", + storageId: "storage_1", + filename: "preview.jpg", + createdAt: 1, + }, + ], + }; +} + +function createLegacyCachedSnapshotWithoutKind() { + return { + balance: { available: 120 }, + subscription: null, + usageStats: null, + recentTransactions: [], + canvases: [], + mediaPreview: [ + { + storageId: "storage_legacy", + filename: "legacy.jpg", + createdAt: 1, + }, + ], }; } @@ -116,4 +140,23 @@ describe("useDashboardSnapshot", () => { expect(latestHookValue.current?.source).toBe("cache"); expect(firstSnapshot).toBe(secondSnapshot); }); + + it("ignores legacy cached snapshots that miss media item kind", async () => { + useAuthQueryMock.mockReturnValue(undefined); + getDashboardSnapshotCacheInvalidationSignalKeyMock.mockReturnValue("dashboard:invalidate"); + readDashboardSnapshotCacheMock.mockReturnValue({ + snapshot: createLegacyCachedSnapshotWithoutKind(), + }); + + container = document.createElement("div"); + document.body.appendChild(container); + root = createRoot(container); + + await act(async () => { + root?.render(React.createElement(HookHarness, { userId: "user_legacy" })); + }); + + expect(latestHookValue.current?.source).toBe("none"); + expect(latestHookValue.current?.snapshot).toBeUndefined(); + }); }); diff --git a/vitest.config.ts b/vitest.config.ts index d1d5301..d4b4f40 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -29,6 +29,8 @@ export default defineConfig({ "components/canvas/__tests__/use-node-local-data.test.tsx", "components/canvas/__tests__/use-canvas-sync-engine.test.ts", "components/canvas/__tests__/use-canvas-sync-engine-hook.test.tsx", + "components/canvas/__tests__/asset-browser-panel.test.tsx", + "components/canvas/__tests__/video-browser-panel.test.tsx", "components/media/__tests__/media-preview-utils.test.ts", ], },