feat(media): add Convex media archive with backfill and mixed-media library

This commit is contained in:
2026-04-10 15:15:44 +02:00
parent ddb2412349
commit a1df097f9c
26 changed files with 2664 additions and 122 deletions

View File

@@ -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<ReturnType<typeof useDashboardSnapshot>["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<ReturnType<typeof useDashboardSnapshot>["snapshot"]>["mediaPreview"][number]): string {
if (item.kind === "video") {
return "Videodatei";
}
return formatDimensions(item.width, item.height);
}
function getMediaItemLabel(item: NonNullable<ReturnType<typeof useDashboardSnapshot>["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() {
) : (
<div className="grid gap-3 sm:grid-cols-4">
{(mediaPreview ?? []).map((item) => {
const itemKey = getMediaItemKey(item);
const previewUrl = resolveMediaPreviewUrl(item, mediaPreviewUrlMap);
const itemLabel = getMediaItemLabel(item);
const itemMeta = getMediaItemMeta(item);
return (
<article key={item.storageId} className="overflow-hidden rounded-xl border bg-card">
<article key={itemKey} className="overflow-hidden rounded-xl border bg-card">
<div className="relative aspect-square bg-muted/50">
{previewUrl ? (
{previewUrl && item.kind === "video" ? (
<video
src={previewUrl}
className="h-full w-full object-cover"
muted
playsInline
preload="metadata"
/>
) : previewUrl ? (
// eslint-disable-next-line @next/next/no-img-element
<img
src={previewUrl}
alt={item.filename ?? "Mediathek-Bild"}
alt={itemLabel}
className="h-full w-full object-cover"
loading="lazy"
/>
@@ -376,17 +433,21 @@ export function DashboardPageClient() {
</div>
) : (
<div className="flex h-full w-full items-center justify-center text-muted-foreground">
{item.kind === "video" ? (
<Video className="size-5" />
) : item.kind === "asset" ? (
<Box className="size-5" />
) : (
<ImageIcon className="size-5" />
)}
</div>
)}
</div>
<div className="space-y-1 p-2">
<p className="truncate text-xs font-medium" title={item.filename}>
{item.filename ?? "Unbenanntes Bild"}
</p>
<p className="text-[11px] text-muted-foreground">
{formatDimensions(item.width, item.height)}
<p className="truncate text-xs font-medium" title={itemLabel}>
{itemLabel}
</p>
<p className="text-[11px] text-muted-foreground">{itemMeta}</p>
</div>
</article>
);
@@ -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."
/>
</div>
);

View File

@@ -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(
<AssetBrowserPanel
nodeId="node-1"
canvasId="canvas-1"
onClose={onClose}
initialState={{
term: "forest",
assetType: "photo",
results: [asset],
page: 1,
totalPages: 1,
}}
/>,
);
});
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",
},
});
});
});

View File

@@ -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<typeof useCanvasDrop> | 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(
<HookHarness
registerUploadedImageMedia={registerUploadedImageMedia}
runCreateNodeOnlineOnly={runCreateNodeOnlineOnly}
syncPendingMoveForClientRequest={syncPendingMoveForClientRequest}
/>,
);
});
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 () => {

View File

@@ -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<typeof import("react")>("react");
let promotedInitialFalseState = false;
return {
...actual,
useState<T>(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(
<VideoBrowserPanel
nodeId="node-1"
canvasId="canvas-1"
onClose={onClose}
initialState={{
term: "nature",
orientation: "",
durationFilter: "all",
results: [video],
page: 1,
totalPages: 1,
}}
/>,
);
});
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",
},
});
});
});

View File

@@ -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<string | null>(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(() => {

View File

@@ -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"
/>
</>

View File

@@ -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(() => {

View File

@@ -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");
});
});

View File

@@ -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<Record<string, string | undefined>>({});
const [isResolvingUrls, setIsResolvingUrls] = useState(false);
const [urlError, setUrlError] = useState<string | null>(null);
const [pendingPickStorageId, setPendingPickStorageId] = useState<Id<"_storage"> | null>(null);
const [pendingPickItemKey, setPendingPickItemKey] = useState<string | null>(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<void> {
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({
<DialogContent className="max-h-[85vh] sm:max-w-5xl" showCloseButton>
<DialogHeader>
<DialogTitle>{title}</DialogTitle>
<DialogDescription>{description}</DialogDescription>
<DialogDescription>{effectiveDescription}</DialogDescription>
</DialogHeader>
<div className="min-h-[320px] overflow-y-auto pr-1">
@@ -201,42 +266,58 @@ export function MediaLibraryDialog({
<ImageIcon className="h-8 w-8 text-muted-foreground" />
<p className="text-sm font-medium">Keine Medien vorhanden</p>
<p className="text-xs text-muted-foreground">
Sobald du Bilder hochlaedst oder generierst, erscheinen sie hier.
Sobald du Medien hochlaedst oder generierst, erscheinen sie hier.
</p>
</div>
) : (
<div className="grid grid-cols-2 gap-3 sm:grid-cols-3 lg:grid-cols-4">
{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 (
<div
key={item.storageId}
key={itemKey}
className="group flex flex-col overflow-hidden rounded-lg border bg-card"
>
<div className="relative aspect-square bg-muted/50">
{item.url ? (
{item.url && item.kind === "video" ? (
<video
src={item.url}
className="h-full w-full object-cover"
muted
playsInline
preload="metadata"
/>
) : item.url ? (
// eslint-disable-next-line @next/next/no-img-element
<img
src={item.url}
alt={item.filename ?? "Mediathek-Bild"}
alt={itemLabel}
className="h-full w-full object-cover"
loading="lazy"
/>
) : (
<div className="flex h-full w-full items-center justify-center text-muted-foreground">
{item.kind === "video" ? (
<Video className="h-6 w-6" />
) : item.kind === "asset" ? (
<Box className="h-6 w-6" />
) : (
<ImageIcon className="h-6 w-6" />
)}
</div>
)}
</div>
<div className="flex flex-1 flex-col gap-1 p-2">
<p className="truncate text-xs font-medium" title={item.filename}>
{item.filename ?? "Unbenanntes Bild"}
<p className="truncate text-xs font-medium" title={itemLabel}>
{itemLabel}
</p>
<p className="text-[11px] text-muted-foreground">
{dimensions ?? "Groesse unbekannt"}
{metaLabel}
</p>
{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 ? (
<>

View File

@@ -1,6 +1,11 @@
type MediaPreviewReference<TStorageId extends string = string> = {
storageId: TStorageId;
kind?: "image" | "video" | "asset";
storageId?: TStorageId;
previewStorageId?: TStorageId;
previewUrl?: string;
originalUrl?: string;
sourceUrl?: string;
url?: string;
};
export function collectMediaStorageIdsForResolution<TStorageId extends string>(
@@ -25,6 +30,10 @@ export function resolveMediaPreviewUrl(
item: MediaPreviewReference,
urlMap: Record<string, string | undefined>,
): 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];
}

View File

@@ -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;

View File

@@ -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,
},
});
},
});

View File

@@ -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<Doc<"mediaItems">>,
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<string, MediaPreviewItem>();
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<Doc<"nodes">>, limit: number): MediaPrev
.filter((item): item is MediaPreviewItem => item !== null)
.sort((a, b) => b.createdAt - a.createdAt);
const deduped = new Map<Id<"_storage">, MediaPreviewItem>();
const deduped = new Map<string, MediaPreviewItem>();
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<Doc<"canvases">>,
limit: number,
): Promise<MediaPreviewItem[]> {
if (canvases.length === 0 || limit <= 0) {
return [];
}
const deduped = new Map<string, MediaPreviewItem>();
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);
},
});

412
convex/media.ts Normal file
View File

@@ -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<Doc<"mediaItems">, "_id" | "_creationTime">;
type LegacyMediaBackfillCanvas = Pick<Doc<"canvases">, "_id" | "ownerId">;
type LegacyMediaBackfillNode = Pick<Doc<"nodes">, "_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<T extends Record<string, unknown>>(value: T): Partial<T> {
const entries = Object.entries(value).filter(([, entryValue]) => entryValue !== undefined);
return Object.fromEntries(entries) as Partial<T>;
}
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<string, unknown>;
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<LegacyMediaBackfillCanvasResult> {
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<MediaItemStorageRef>): Set<Id<"_storage">> {
const ids = new Set<Id<"_storage">>();
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<Doc<"mediaItems">>,
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<Doc<"mediaItems">> {
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<MediaInsertValue>;
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,
});
},
});

109
convex/migrations.ts Normal file
View File

@@ -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<Id<"canvases">>,
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<MediaArchiveBackfillBatchResult> {
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);
},
});

View File

@@ -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
// ==========================================================================

View File

@@ -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<Id<"_storage">>,
ownedStorageIds: Set<Id<"_storage">>,
): {
verifiedStorageIds: Array<Id<"_storage">>;
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<Set<Id<"_storage">>> {
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<Id<"_storage">>();
for (const nodes of imageNodesByCanvas) {
for (const node of nodes) {
const data = node.data as Record<string, unknown> | 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;
}

View File

@@ -14,6 +14,26 @@ import {
export type DashboardSnapshot = FunctionReturnType<typeof api.dashboard.getSnapshot>;
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<DashboardSnapshot>(userId)?.snapshot ?? null;
const cached = readDashboardSnapshotCache<DashboardSnapshot>(userId)?.snapshot ?? null;
if (!cached) {
return null;
}
return isDashboardSnapshotShapeCompatible(cached) ? cached : null;
}, [userId, cacheEpoch]);
useEffect(() => {

View File

@@ -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}`;

115
lib/media-archive.ts Normal file
View File

@@ -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<string, unknown>;
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<MediaArchiveRow, "_id"> & {
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,
};
}

View File

@@ -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<MockMediaItem, "_id">) => {
const inserted = {
_id: `media_${rows.length + 1}` as Id<"mediaItems">,
...value,
};
rows.push(inserted);
return inserted._id;
},
patch: async (id: Id<"mediaItems">, patch: Partial<MockMediaItem>) => {
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<string, unknown>([
[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<MockMediaItem, "_id">) => {
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<MockMediaItem>) => {
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<unknown> })._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<string, unknown>([
[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<string, unknown>) => {
const doc = docs.get(id) as Record<string, unknown> | 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<MockMediaItem, "_id">) => {
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<string, unknown>([
[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<string, unknown>) => {
const doc = docs.get(id) as Record<string, unknown> | 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<MockMediaItem, "_id">) => {
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<unknown> })._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,
});
});
});

View File

@@ -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<string, unknown>;
};
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<MockMediaItem, "_id">) => {
const inserted = {
_id: `media_${rows.length + 1}` as Id<"mediaItems">,
...value,
};
rows.push(inserted);
return inserted._id;
},
patch: async (id: Id<"mediaItems">, patchValue: Partial<MockMediaItem>) => {
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);
});
});

View File

@@ -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,
});
});
});

View File

@@ -40,9 +40,13 @@ type RunCreateNodeOnlineOnly = Parameters<typeof useCanvasDrop>[0]["runCreateNod
type HarnessProps = {
runCreateNodeOnlineOnly: RunCreateNodeOnlineOnly;
registerUploadedImageMedia?: Parameters<typeof useCanvasDrop>[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<HarnessProps["runCreateNodeOnlineOnly"]>()
.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);
});

View File

@@ -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();
});
});

View File

@@ -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",
],
},