feat(media): add Convex media archive with backfill and mixed-media library
This commit is contained in:
@@ -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">
|
||||
<ImageIcon className="size-5" />
|
||||
{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>
|
||||
);
|
||||
|
||||
133
components/canvas/__tests__/asset-browser-panel.test.tsx
Normal file
133
components/canvas/__tests__/asset-browser-panel.test.tsx
Normal 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",
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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 () => {
|
||||
|
||||
182
components/canvas/__tests__/video-browser-panel.test.tsx
Normal file
182
components/canvas/__tests__/video-browser-panel.test.tsx
Normal 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",
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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(() => {
|
||||
|
||||
@@ -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"
|
||||
/>
|
||||
</>
|
||||
|
||||
@@ -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(() => {
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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">
|
||||
<ImageIcon className="h-6 w-6" />
|
||||
{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 ? (
|
||||
<>
|
||||
|
||||
@@ -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];
|
||||
}
|
||||
|
||||
4
convex/_generated/api.d.ts
vendored
4
convex/_generated/api.d.ts
vendored
@@ -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;
|
||||
|
||||
37
convex/ai.ts
37
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,
|
||||
},
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -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
412
convex/media.ts
Normal 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
109
convex/migrations.ts
Normal 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);
|
||||
},
|
||||
});
|
||||
@@ -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
|
||||
// ==========================================================================
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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(() => {
|
||||
|
||||
@@ -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
115
lib/media-archive.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
478
tests/convex/media-archive.test.ts
Normal file
478
tests/convex/media-archive.test.ts
Normal 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,
|
||||
});
|
||||
});
|
||||
});
|
||||
356
tests/convex/media-backfill.test.ts
Normal file
356
tests/convex/media-backfill.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
135
tests/lib/media-archive.test.ts
Normal file
135
tests/lib/media-archive.test.ts
Normal 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,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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",
|
||||
],
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user