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 { useMutation } from "convex/react";
|
||||||
import { useTranslations } from "next-intl";
|
import { useTranslations } from "next-intl";
|
||||||
import {
|
import {
|
||||||
|
Box,
|
||||||
ChevronDown,
|
ChevronDown,
|
||||||
Coins,
|
Coins,
|
||||||
ImageIcon,
|
ImageIcon,
|
||||||
@@ -16,6 +17,7 @@ import {
|
|||||||
Moon,
|
Moon,
|
||||||
Search,
|
Search,
|
||||||
Sun,
|
Sun,
|
||||||
|
Video,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
|
|
||||||
import { Avatar, AvatarFallback } from "@/components/ui/avatar";
|
import { Avatar, AvatarFallback } from "@/components/ui/avatar";
|
||||||
@@ -66,6 +68,50 @@ function formatDimensions(width: number | undefined, height: number | undefined)
|
|||||||
return "Größe unbekannt";
|
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() {
|
export function DashboardPageClient() {
|
||||||
const t = useTranslations("toasts");
|
const t = useTranslations("toasts");
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
@@ -357,16 +403,27 @@ export function DashboardPageClient() {
|
|||||||
) : (
|
) : (
|
||||||
<div className="grid gap-3 sm:grid-cols-4">
|
<div className="grid gap-3 sm:grid-cols-4">
|
||||||
{(mediaPreview ?? []).map((item) => {
|
{(mediaPreview ?? []).map((item) => {
|
||||||
|
const itemKey = getMediaItemKey(item);
|
||||||
const previewUrl = resolveMediaPreviewUrl(item, mediaPreviewUrlMap);
|
const previewUrl = resolveMediaPreviewUrl(item, mediaPreviewUrlMap);
|
||||||
|
const itemLabel = getMediaItemLabel(item);
|
||||||
|
const itemMeta = getMediaItemMeta(item);
|
||||||
|
|
||||||
return (
|
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">
|
<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
|
// eslint-disable-next-line @next/next/no-img-element
|
||||||
<img
|
<img
|
||||||
src={previewUrl}
|
src={previewUrl}
|
||||||
alt={item.filename ?? "Mediathek-Bild"}
|
alt={itemLabel}
|
||||||
className="h-full w-full object-cover"
|
className="h-full w-full object-cover"
|
||||||
loading="lazy"
|
loading="lazy"
|
||||||
/>
|
/>
|
||||||
@@ -376,17 +433,21 @@ export function DashboardPageClient() {
|
|||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="flex h-full w-full items-center justify-center text-muted-foreground">
|
<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>
|
</div>
|
||||||
<div className="space-y-1 p-2">
|
<div className="space-y-1 p-2">
|
||||||
<p className="truncate text-xs font-medium" title={item.filename}>
|
<p className="truncate text-xs font-medium" title={itemLabel}>
|
||||||
{item.filename ?? "Unbenanntes Bild"}
|
{itemLabel}
|
||||||
</p>
|
|
||||||
<p className="text-[11px] text-muted-foreground">
|
|
||||||
{formatDimensions(item.width, item.height)}
|
|
||||||
</p>
|
</p>
|
||||||
|
<p className="text-[11px] text-muted-foreground">{itemMeta}</p>
|
||||||
</div>
|
</div>
|
||||||
</article>
|
</article>
|
||||||
);
|
);
|
||||||
@@ -400,7 +461,7 @@ export function DashboardPageClient() {
|
|||||||
open={isMediaLibraryDialogOpen}
|
open={isMediaLibraryDialogOpen}
|
||||||
onOpenChange={setIsMediaLibraryDialogOpen}
|
onOpenChange={setIsMediaLibraryDialogOpen}
|
||||||
title="Mediathek"
|
title="Mediathek"
|
||||||
description="Alle deine Bilder aus LemonSpace in einer zentralen Vorschau."
|
description="Alle deine Medien aus LemonSpace in einer zentralen Vorschau."
|
||||||
/>
|
/>
|
||||||
</div>
|
</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 type { Id } from "@/convex/_generated/dataModel";
|
||||||
import { CANVAS_NODE_DND_MIME } from "@/lib/canvas-connection-policy";
|
import { CANVAS_NODE_DND_MIME } from "@/lib/canvas-connection-policy";
|
||||||
import { NODE_DEFAULTS } from "@/lib/canvas-utils";
|
import { NODE_DEFAULTS } from "@/lib/canvas-utils";
|
||||||
|
import {
|
||||||
|
emitDashboardSnapshotCacheInvalidationSignal,
|
||||||
|
invalidateDashboardSnapshotForLastSignedInUser,
|
||||||
|
} from "@/lib/dashboard-snapshot-cache";
|
||||||
import { toast } from "@/lib/toast";
|
import { toast } from "@/lib/toast";
|
||||||
import { useCanvasDrop } from "@/components/canvas/use-canvas-drop";
|
import { useCanvasDrop } from "@/components/canvas/use-canvas-drop";
|
||||||
import { createCompressedImagePreview } from "@/components/canvas/canvas-media-utils";
|
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: {
|
const latestHandlersRef: {
|
||||||
current: ReturnType<typeof useCanvasDrop> | null;
|
current: ReturnType<typeof useCanvasDrop> | null;
|
||||||
} = { current: null };
|
} = { current: null };
|
||||||
@@ -245,6 +254,62 @@ describe("useCanvasDrop", () => {
|
|||||||
width: 1600,
|
width: 1600,
|
||||||
height: 900,
|
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 () => {
|
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,
|
useState,
|
||||||
} from "react";
|
} from "react";
|
||||||
import { createPortal } from "react-dom";
|
import { createPortal } from "react-dom";
|
||||||
import { useAction } from "convex/react";
|
import { useAction, useMutation } from "convex/react";
|
||||||
import { useReactFlow } from "@xyflow/react";
|
import { useReactFlow } from "@xyflow/react";
|
||||||
import { X, Search, Loader2, AlertCircle } from "lucide-react";
|
import { X, Search, Loader2, AlertCircle } from "lucide-react";
|
||||||
import { api } from "@/convex/_generated/api";
|
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 { Badge } from "@/components/ui/badge";
|
||||||
import { computeMediaNodeSize } from "@/lib/canvas-utils";
|
import { computeMediaNodeSize } from "@/lib/canvas-utils";
|
||||||
import { preserveNodeFavorite } from "@/lib/canvas-node-favorite";
|
import { preserveNodeFavorite } from "@/lib/canvas-node-favorite";
|
||||||
|
import { buildFreepikAssetDedupeKey } from "@/lib/media-archive";
|
||||||
import { useCanvasSync } from "@/components/canvas/canvas-sync-context";
|
import { useCanvasSync } from "@/components/canvas/canvas-sync-context";
|
||||||
import { toast } from "@/lib/toast";
|
import { toast } from "@/lib/toast";
|
||||||
|
|
||||||
@@ -92,6 +93,7 @@ export function AssetBrowserPanel({
|
|||||||
const [selectingAssetKey, setSelectingAssetKey] = useState<string | null>(null);
|
const [selectingAssetKey, setSelectingAssetKey] = useState<string | null>(null);
|
||||||
|
|
||||||
const searchFreepik = useAction(api.freepik.search);
|
const searchFreepik = useAction(api.freepik.search);
|
||||||
|
const upsertMedia = useMutation(api.media.upsert);
|
||||||
const { getNode } = useReactFlow();
|
const { getNode } = useReactFlow();
|
||||||
const { queueNodeDataUpdate, queueNodeResize, status } = useCanvasSync();
|
const { queueNodeDataUpdate, queueNodeResize, status } = useCanvasSync();
|
||||||
const shouldSkipInitialSearchRef = useRef(Boolean(initialState?.results?.length));
|
const shouldSkipInitialSearchRef = useRef(Boolean(initialState?.results?.length));
|
||||||
@@ -234,6 +236,36 @@ export function AssetBrowserPanel({
|
|||||||
width: targetSize.width,
|
width: targetSize.width,
|
||||||
height: targetSize.height,
|
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();
|
onClose();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Failed to select asset", error);
|
console.error("Failed to select asset", error);
|
||||||
@@ -241,7 +273,7 @@ export function AssetBrowserPanel({
|
|||||||
setSelectingAssetKey(null);
|
setSelectingAssetKey(null);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[canvasId, getNode, isSelecting, nodeId, onClose, queueNodeDataUpdate, queueNodeResize, status.isOffline],
|
[canvasId, getNode, isSelecting, nodeId, onClose, queueNodeDataUpdate, queueNodeResize, status.isOffline, upsertMedia],
|
||||||
);
|
);
|
||||||
|
|
||||||
const handlePreviousPage = useCallback(() => {
|
const handlePreviousPage = useCallback(() => {
|
||||||
|
|||||||
@@ -376,6 +376,11 @@ export default function ImageNode({
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (item.kind !== "image" || !item.storageId) {
|
||||||
|
toast.error(t('canvas.uploadFailed'), "Nur Bilddateien mit Storage-ID koennen uebernommen werden.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
setMediaLibraryPhase("applying");
|
setMediaLibraryPhase("applying");
|
||||||
setPendingMediaLibraryStorageId(item.storageId);
|
setPendingMediaLibraryStorageId(item.storageId);
|
||||||
|
|
||||||
@@ -644,6 +649,7 @@ export default function ImageNode({
|
|||||||
open={isMediaLibraryOpen}
|
open={isMediaLibraryOpen}
|
||||||
onOpenChange={setIsMediaLibraryOpen}
|
onOpenChange={setIsMediaLibraryOpen}
|
||||||
onPick={handlePickFromMediaLibrary}
|
onPick={handlePickFromMediaLibrary}
|
||||||
|
kindFilter="image"
|
||||||
pickCtaLabel="Uebernehmen"
|
pickCtaLabel="Uebernehmen"
|
||||||
/>
|
/>
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ import {
|
|||||||
type PointerEvent,
|
type PointerEvent,
|
||||||
} from "react";
|
} from "react";
|
||||||
import { createPortal } from "react-dom";
|
import { createPortal } from "react-dom";
|
||||||
import { useAction } from "convex/react";
|
import { useAction, useMutation } from "convex/react";
|
||||||
import { useReactFlow } from "@xyflow/react";
|
import { useReactFlow } from "@xyflow/react";
|
||||||
import { X, Search, Loader2, AlertCircle, Play, Pause } from "lucide-react";
|
import { X, Search, Loader2, AlertCircle, Play, Pause } from "lucide-react";
|
||||||
import { api } from "@/convex/_generated/api";
|
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 type { PexelsVideo, PexelsVideoFile } from "@/lib/pexels-types";
|
||||||
import { pickPreviewVideoFile, pickVideoFile } from "@/lib/pexels-types";
|
import { pickPreviewVideoFile, pickVideoFile } from "@/lib/pexels-types";
|
||||||
import { preserveNodeFavorite } from "@/lib/canvas-node-favorite";
|
import { preserveNodeFavorite } from "@/lib/canvas-node-favorite";
|
||||||
|
import { buildPexelsVideoDedupeKey } from "@/lib/media-archive";
|
||||||
import { toast } from "@/lib/toast";
|
import { toast } from "@/lib/toast";
|
||||||
import { useCanvasSync } from "@/components/canvas/canvas-sync-context";
|
import { useCanvasSync } from "@/components/canvas/canvas-sync-context";
|
||||||
|
|
||||||
@@ -85,6 +86,7 @@ export function VideoBrowserPanel({
|
|||||||
|
|
||||||
const searchVideos = useAction(api.pexels.searchVideos);
|
const searchVideos = useAction(api.pexels.searchVideos);
|
||||||
const popularVideos = useAction(api.pexels.popularVideos);
|
const popularVideos = useAction(api.pexels.popularVideos);
|
||||||
|
const upsertMedia = useMutation(api.media.upsert);
|
||||||
const { getNode } = useReactFlow();
|
const { getNode } = useReactFlow();
|
||||||
const { queueNodeDataUpdate, queueNodeResize, status } = useCanvasSync();
|
const { queueNodeDataUpdate, queueNodeResize, status } = useCanvasSync();
|
||||||
const shouldSkipInitialSearchRef = useRef(
|
const shouldSkipInitialSearchRef = useRef(
|
||||||
@@ -253,6 +255,43 @@ export function VideoBrowserPanel({
|
|||||||
width: targetWidth,
|
width: targetWidth,
|
||||||
height: targetHeight,
|
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();
|
onClose();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Failed to select video", error);
|
console.error("Failed to select video", error);
|
||||||
@@ -260,7 +299,7 @@ export function VideoBrowserPanel({
|
|||||||
setSelectingVideoId(null);
|
setSelectingVideoId(null);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[canvasId, getNode, isSelecting, nodeId, onClose, queueNodeDataUpdate, queueNodeResize, status.isOffline],
|
[canvasId, getNode, isSelecting, nodeId, onClose, queueNodeDataUpdate, queueNodeResize, status.isOffline, upsertMedia],
|
||||||
);
|
);
|
||||||
|
|
||||||
const handlePreviousPage = useCallback(() => {
|
const handlePreviousPage = useCallback(() => {
|
||||||
|
|||||||
@@ -15,6 +15,16 @@ describe("media-preview-utils", () => {
|
|||||||
expect(ids).toEqual(["preview-1", "orig-1", "orig-2"]);
|
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", () => {
|
it("resolves preview url first and falls back to original url", () => {
|
||||||
const previewFirst = resolveMediaPreviewUrl(
|
const previewFirst = resolveMediaPreviewUrl(
|
||||||
{ storageId: "orig-1", previewStorageId: "preview-1" },
|
{ storageId: "orig-1", previewStorageId: "preview-1" },
|
||||||
@@ -35,4 +45,32 @@ describe("media-preview-utils", () => {
|
|||||||
|
|
||||||
expect(fallbackToOriginal).toBe("https://cdn.example.com/original.jpg");
|
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 { useEffect, useMemo, useState } from "react";
|
||||||
import { useMutation } from "convex/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 { api } from "@/convex/_generated/api";
|
||||||
import type { Id } from "@/convex/_generated/dataModel";
|
import type { Id } from "@/convex/_generated/dataModel";
|
||||||
@@ -25,16 +25,22 @@ const MIN_LIMIT = 1;
|
|||||||
const MAX_LIMIT = 500;
|
const MAX_LIMIT = 500;
|
||||||
|
|
||||||
export type MediaLibraryMetadataItem = {
|
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">;
|
previewStorageId?: Id<"_storage">;
|
||||||
|
previewUrl?: string;
|
||||||
|
originalUrl?: string;
|
||||||
|
sourceUrl?: string;
|
||||||
filename?: string;
|
filename?: string;
|
||||||
mimeType?: string;
|
mimeType?: string;
|
||||||
width?: number;
|
width?: number;
|
||||||
height?: number;
|
height?: number;
|
||||||
previewWidth?: number;
|
previewWidth?: number;
|
||||||
previewHeight?: number;
|
previewHeight?: number;
|
||||||
sourceCanvasId: Id<"canvases">;
|
durationSeconds?: number;
|
||||||
sourceNodeId: Id<"nodes">;
|
sourceCanvasId?: Id<"canvases">;
|
||||||
|
sourceNodeId?: Id<"nodes">;
|
||||||
createdAt: number;
|
createdAt: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -49,6 +55,7 @@ export type MediaLibraryDialogProps = {
|
|||||||
title?: string;
|
title?: string;
|
||||||
description?: string;
|
description?: string;
|
||||||
limit?: number;
|
limit?: number;
|
||||||
|
kindFilter?: "image" | "video" | "asset";
|
||||||
pickCtaLabel?: string;
|
pickCtaLabel?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -68,26 +75,79 @@ function formatDimensions(width: number | undefined, height: number | undefined)
|
|||||||
return `${width} x ${height}px`;
|
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({
|
export function MediaLibraryDialog({
|
||||||
open,
|
open,
|
||||||
onOpenChange,
|
onOpenChange,
|
||||||
onPick,
|
onPick,
|
||||||
title = "Mediathek",
|
title = "Mediathek",
|
||||||
description = "Waehle ein Bild aus deiner LemonSpace-Mediathek.",
|
description,
|
||||||
limit,
|
limit,
|
||||||
|
kindFilter,
|
||||||
pickCtaLabel = "Auswaehlen",
|
pickCtaLabel = "Auswaehlen",
|
||||||
}: MediaLibraryDialogProps) {
|
}: MediaLibraryDialogProps) {
|
||||||
const normalizedLimit = useMemo(() => normalizeLimit(limit), [limit]);
|
const normalizedLimit = useMemo(() => normalizeLimit(limit), [limit]);
|
||||||
const metadata = useAuthQuery(
|
const metadata = useAuthQuery(
|
||||||
api.dashboard.listMediaLibrary,
|
api.dashboard.listMediaLibrary,
|
||||||
open ? { limit: normalizedLimit } : "skip",
|
open
|
||||||
|
? {
|
||||||
|
limit: normalizedLimit,
|
||||||
|
...(kindFilter ? { kindFilter } : {}),
|
||||||
|
}
|
||||||
|
: "skip",
|
||||||
);
|
);
|
||||||
const resolveUrls = useMutation(api.storage.batchGetUrlsForUserMedia);
|
const resolveUrls = useMutation(api.storage.batchGetUrlsForUserMedia);
|
||||||
|
|
||||||
const [urlMap, setUrlMap] = useState<Record<string, string | undefined>>({});
|
const [urlMap, setUrlMap] = useState<Record<string, string | undefined>>({});
|
||||||
const [isResolvingUrls, setIsResolvingUrls] = useState(false);
|
const [isResolvingUrls, setIsResolvingUrls] = useState(false);
|
||||||
const [urlError, setUrlError] = useState<string | null>(null);
|
const [urlError, setUrlError] = useState<string | null>(null);
|
||||||
const [pendingPickStorageId, setPendingPickStorageId] = useState<Id<"_storage"> | null>(null);
|
const [pendingPickItemKey, setPendingPickItemKey] = useState<string | null>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let isCancelled = false;
|
let isCancelled = false;
|
||||||
@@ -155,17 +215,22 @@ export function MediaLibraryDialog({
|
|||||||
const isMetadataLoading = open && metadata === undefined;
|
const isMetadataLoading = open && metadata === undefined;
|
||||||
const isInitialLoading = isMetadataLoading || (metadata !== undefined && isResolvingUrls);
|
const isInitialLoading = isMetadataLoading || (metadata !== undefined && isResolvingUrls);
|
||||||
const isPreviewMode = typeof onPick !== "function";
|
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> {
|
async function handlePick(item: MediaLibraryItem): Promise<void> {
|
||||||
if (!onPick || pendingPickStorageId) {
|
if (!onPick || pendingPickItemKey) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
setPendingPickStorageId(item.storageId);
|
setPendingPickItemKey(getItemKey(item));
|
||||||
try {
|
try {
|
||||||
await onPick(item);
|
await onPick(item);
|
||||||
} finally {
|
} finally {
|
||||||
setPendingPickStorageId(null);
|
setPendingPickItemKey(null);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -174,7 +239,7 @@ export function MediaLibraryDialog({
|
|||||||
<DialogContent className="max-h-[85vh] sm:max-w-5xl" showCloseButton>
|
<DialogContent className="max-h-[85vh] sm:max-w-5xl" showCloseButton>
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle>{title}</DialogTitle>
|
<DialogTitle>{title}</DialogTitle>
|
||||||
<DialogDescription>{description}</DialogDescription>
|
<DialogDescription>{effectiveDescription}</DialogDescription>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
|
|
||||||
<div className="min-h-[320px] overflow-y-auto pr-1">
|
<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" />
|
<ImageIcon className="h-8 w-8 text-muted-foreground" />
|
||||||
<p className="text-sm font-medium">Keine Medien vorhanden</p>
|
<p className="text-sm font-medium">Keine Medien vorhanden</p>
|
||||||
<p className="text-xs text-muted-foreground">
|
<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>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="grid grid-cols-2 gap-3 sm:grid-cols-3 lg:grid-cols-4">
|
<div className="grid grid-cols-2 gap-3 sm:grid-cols-3 lg:grid-cols-4">
|
||||||
{items.map((item) => {
|
{items.map((item) => {
|
||||||
const dimensions = formatDimensions(item.width, item.height);
|
const itemKey = getItemKey(item);
|
||||||
const isPickingThis = pendingPickStorageId === item.storageId;
|
const isPickingThis = pendingPickItemKey === itemKey;
|
||||||
|
const itemLabel = getItemLabel(item);
|
||||||
|
const metaLabel = formatMediaMeta(item);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key={item.storageId}
|
key={itemKey}
|
||||||
className="group flex flex-col overflow-hidden rounded-lg border bg-card"
|
className="group flex flex-col overflow-hidden rounded-lg border bg-card"
|
||||||
>
|
>
|
||||||
<div className="relative aspect-square bg-muted/50">
|
<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
|
// eslint-disable-next-line @next/next/no-img-element
|
||||||
<img
|
<img
|
||||||
src={item.url}
|
src={item.url}
|
||||||
alt={item.filename ?? "Mediathek-Bild"}
|
alt={itemLabel}
|
||||||
className="h-full w-full object-cover"
|
className="h-full w-full object-cover"
|
||||||
loading="lazy"
|
loading="lazy"
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<div className="flex h-full w-full items-center justify-center text-muted-foreground">
|
<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>
|
</div>
|
||||||
|
|
||||||
<div className="flex flex-1 flex-col gap-1 p-2">
|
<div className="flex flex-1 flex-col gap-1 p-2">
|
||||||
<p className="truncate text-xs font-medium" title={item.filename}>
|
<p className="truncate text-xs font-medium" title={itemLabel}>
|
||||||
{item.filename ?? "Unbenanntes Bild"}
|
{itemLabel}
|
||||||
</p>
|
</p>
|
||||||
<p className="text-[11px] text-muted-foreground">
|
<p className="text-[11px] text-muted-foreground">
|
||||||
{dimensions ?? "Groesse unbekannt"}
|
{metaLabel}
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
{isPreviewMode ? (
|
{isPreviewMode ? (
|
||||||
@@ -247,7 +328,7 @@ export function MediaLibraryDialog({
|
|||||||
size="sm"
|
size="sm"
|
||||||
className="mt-2 h-7"
|
className="mt-2 h-7"
|
||||||
onClick={() => void handlePick(item)}
|
onClick={() => void handlePick(item)}
|
||||||
disabled={Boolean(pendingPickStorageId)}
|
disabled={Boolean(pendingPickItemKey)}
|
||||||
>
|
>
|
||||||
{isPickingThis ? (
|
{isPickingThis ? (
|
||||||
<>
|
<>
|
||||||
|
|||||||
@@ -1,6 +1,11 @@
|
|||||||
type MediaPreviewReference<TStorageId extends string = string> = {
|
type MediaPreviewReference<TStorageId extends string = string> = {
|
||||||
storageId: TStorageId;
|
kind?: "image" | "video" | "asset";
|
||||||
|
storageId?: TStorageId;
|
||||||
previewStorageId?: TStorageId;
|
previewStorageId?: TStorageId;
|
||||||
|
previewUrl?: string;
|
||||||
|
originalUrl?: string;
|
||||||
|
sourceUrl?: string;
|
||||||
|
url?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
export function collectMediaStorageIdsForResolution<TStorageId extends string>(
|
export function collectMediaStorageIdsForResolution<TStorageId extends string>(
|
||||||
@@ -25,6 +30,10 @@ export function resolveMediaPreviewUrl(
|
|||||||
item: MediaPreviewReference,
|
item: MediaPreviewReference,
|
||||||
urlMap: Record<string, string | undefined>,
|
urlMap: Record<string, string | undefined>,
|
||||||
): string | undefined {
|
): string | undefined {
|
||||||
|
if (item.previewUrl) {
|
||||||
|
return item.previewUrl;
|
||||||
|
}
|
||||||
|
|
||||||
if (item.previewStorageId) {
|
if (item.previewStorageId) {
|
||||||
const previewUrl = urlMap[item.previewStorageId];
|
const previewUrl = urlMap[item.previewStorageId];
|
||||||
if (previewUrl) {
|
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];
|
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 freepik from "../freepik.js";
|
||||||
import type * as helpers from "../helpers.js";
|
import type * as helpers from "../helpers.js";
|
||||||
import type * as http from "../http.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 node_type_validator from "../node_type_validator.js";
|
||||||
import type * as nodes from "../nodes.js";
|
import type * as nodes from "../nodes.js";
|
||||||
import type * as openrouter from "../openrouter.js";
|
import type * as openrouter from "../openrouter.js";
|
||||||
@@ -59,6 +61,8 @@ declare const fullApi: ApiFromModules<{
|
|||||||
freepik: typeof freepik;
|
freepik: typeof freepik;
|
||||||
helpers: typeof helpers;
|
helpers: typeof helpers;
|
||||||
http: typeof http;
|
http: typeof http;
|
||||||
|
media: typeof media;
|
||||||
|
migrations: typeof migrations;
|
||||||
node_type_validator: typeof node_type_validator;
|
node_type_validator: typeof node_type_validator;
|
||||||
nodes: typeof nodes;
|
nodes: typeof nodes;
|
||||||
openrouter: typeof openrouter;
|
openrouter: typeof openrouter;
|
||||||
|
|||||||
37
convex/ai.ts
37
convex/ai.ts
@@ -38,6 +38,8 @@ import {
|
|||||||
type VideoPollStatus,
|
type VideoPollStatus,
|
||||||
} from "../lib/video-poll-logging";
|
} from "../lib/video-poll-logging";
|
||||||
import { normalizePublicTier } from "../lib/tier-credits";
|
import { normalizePublicTier } from "../lib/tier-credits";
|
||||||
|
import { upsertMediaItemByOwnerAndDedupe } from "./media";
|
||||||
|
import { buildStoredMediaDedupeKey } from "../lib/media-archive";
|
||||||
|
|
||||||
const MAX_IMAGE_RETRIES = 2;
|
const MAX_IMAGE_RETRIES = 2;
|
||||||
const MAX_VIDEO_POLL_ATTEMPTS = 30;
|
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 };
|
return { creditCost };
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@@ -600,6 +619,24 @@ export const finalizeVideoSuccess = internalMutation({
|
|||||||
creditCost,
|
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 type { Doc, Id } from "./_generated/dataModel";
|
||||||
import { v } from "convex/values";
|
import { v } from "convex/values";
|
||||||
|
|
||||||
@@ -12,21 +12,80 @@ const DASHBOARD_MEDIA_PREVIEW_LIMIT = 8;
|
|||||||
const MEDIA_LIBRARY_DEFAULT_LIMIT = 200;
|
const MEDIA_LIBRARY_DEFAULT_LIMIT = 200;
|
||||||
const MEDIA_LIBRARY_MIN_LIMIT = 1;
|
const MEDIA_LIBRARY_MIN_LIMIT = 1;
|
||||||
const MEDIA_LIBRARY_MAX_LIMIT = 500;
|
const MEDIA_LIBRARY_MAX_LIMIT = 500;
|
||||||
|
const MEDIA_ARCHIVE_FETCH_MULTIPLIER = 4;
|
||||||
|
|
||||||
type MediaPreviewItem = {
|
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">;
|
previewStorageId?: Id<"_storage">;
|
||||||
|
originalUrl?: string;
|
||||||
|
previewUrl?: string;
|
||||||
|
sourceUrl?: string;
|
||||||
filename?: string;
|
filename?: string;
|
||||||
mimeType?: string;
|
mimeType?: string;
|
||||||
width?: number;
|
width?: number;
|
||||||
height?: number;
|
height?: number;
|
||||||
previewWidth?: number;
|
previewWidth?: number;
|
||||||
previewHeight?: number;
|
previewHeight?: number;
|
||||||
sourceCanvasId: Id<"canvases">;
|
sourceCanvasId?: Id<"canvases">;
|
||||||
sourceNodeId: Id<"nodes">;
|
sourceNodeId?: Id<"nodes">;
|
||||||
createdAt: number;
|
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 {
|
function readImageMediaPreview(node: Doc<"nodes">): MediaPreviewItem | null {
|
||||||
if (node.type !== "image") {
|
if (node.type !== "image") {
|
||||||
return null;
|
return null;
|
||||||
@@ -62,6 +121,8 @@ function readImageMediaPreview(node: Doc<"nodes">): MediaPreviewItem | null {
|
|||||||
: undefined;
|
: undefined;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
kind: "image",
|
||||||
|
source: "upload",
|
||||||
storageId: storageId as Id<"_storage">,
|
storageId: storageId as Id<"_storage">,
|
||||||
previewStorageId,
|
previewStorageId,
|
||||||
filename,
|
filename,
|
||||||
@@ -82,13 +143,14 @@ function buildMediaPreview(nodes: Array<Doc<"nodes">>, limit: number): MediaPrev
|
|||||||
.filter((item): item is MediaPreviewItem => item !== null)
|
.filter((item): item is MediaPreviewItem => item !== null)
|
||||||
.sort((a, b) => b.createdAt - a.createdAt);
|
.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) {
|
for (const item of candidates) {
|
||||||
if (deduped.has(item.storageId)) {
|
const dedupeKey = item.storageId ?? `${item.sourceCanvasId}:${item.sourceNodeId}`;
|
||||||
|
if (deduped.has(dedupeKey)) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
deduped.set(item.storageId, item);
|
deduped.set(dedupeKey, item);
|
||||||
if (deduped.size >= limit) {
|
if (deduped.size >= limit) {
|
||||||
break;
|
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)));
|
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({
|
export const getSnapshot = query({
|
||||||
args: {},
|
args: {},
|
||||||
handler: async (ctx) => {
|
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([
|
await Promise.all([
|
||||||
ctx.db
|
ctx.db
|
||||||
.query("creditBalances")
|
.query("creditBalances")
|
||||||
@@ -156,18 +255,17 @@ export const getSnapshot = query({
|
|||||||
.withIndex("by_owner_updated", (q) => q.eq("ownerId", user.userId))
|
.withIndex("by_owner_updated", (q) => q.eq("ownerId", user.userId))
|
||||||
.order("desc")
|
.order("desc")
|
||||||
.collect(),
|
.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(
|
let mediaPreview = buildMediaPreviewFromArchive(mediaArchiveRows, DASHBOARD_MEDIA_PREVIEW_LIMIT);
|
||||||
canvases.map((canvas) =>
|
if (mediaPreview.length === 0 && mediaArchiveRows.length === 0) {
|
||||||
ctx.db
|
mediaPreview = await buildMediaPreviewFromNodeFallback(ctx, canvases, DASHBOARD_MEDIA_PREVIEW_LIMIT);
|
||||||
.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);
|
|
||||||
|
|
||||||
const tier = normalizeBillingTier(subscriptionRow?.tier);
|
const tier = normalizeBillingTier(subscriptionRow?.tier);
|
||||||
const monthStart = new Date(new Date().getFullYear(), new Date().getMonth(), 1).getTime();
|
const monthStart = new Date(new Date().getFullYear(), new Date().getMonth(), 1).getTime();
|
||||||
@@ -215,34 +313,42 @@ export const getSnapshot = query({
|
|||||||
export const listMediaLibrary = query({
|
export const listMediaLibrary = query({
|
||||||
args: {
|
args: {
|
||||||
limit: v.optional(v.number()),
|
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);
|
const user = await optionalAuth(ctx);
|
||||||
if (!user) {
|
if (!user) {
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
|
|
||||||
const normalizedLimit = normalizeMediaLibraryLimit(limit);
|
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
|
const canvases = await ctx.db
|
||||||
.query("canvases")
|
.query("canvases")
|
||||||
.withIndex("by_owner_updated", (q) => q.eq("ownerId", user.userId))
|
.withIndex("by_owner_updated", (q) => q.eq("ownerId", user.userId))
|
||||||
.order("desc")
|
.order("desc")
|
||||||
.collect();
|
.collect();
|
||||||
|
|
||||||
if (canvases.length === 0) {
|
return await buildMediaPreviewFromNodeFallback(ctx, canvases, normalizedLimit);
|
||||||
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);
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
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")
|
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
|
// Node Data — typ-spezifische Payloads
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
@@ -187,6 +201,34 @@ export default defineSchema({
|
|||||||
.index("by_userId", ["userId"])
|
.index("by_userId", ["userId"])
|
||||||
.index("by_userId_nodeType", ["userId", "nodeType"]),
|
.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
|
// Credit-System
|
||||||
// ==========================================================================
|
// ==========================================================================
|
||||||
|
|||||||
@@ -2,6 +2,8 @@ import { mutation, type MutationCtx, type QueryCtx } from "./_generated/server";
|
|||||||
import { v } from "convex/values";
|
import { v } from "convex/values";
|
||||||
import { requireAuth } from "./helpers";
|
import { requireAuth } from "./helpers";
|
||||||
import type { Id } from "./_generated/dataModel";
|
import type { Id } from "./_generated/dataModel";
|
||||||
|
import { collectOwnedMediaStorageIds, upsertMediaItemByOwnerAndDedupe } from "./media";
|
||||||
|
import { buildStoredMediaDedupeKey } from "../lib/media-archive";
|
||||||
|
|
||||||
const STORAGE_URL_BATCH_SIZE = 12;
|
const STORAGE_URL_BATCH_SIZE = 12;
|
||||||
const PERFORMANCE_LOG_THRESHOLD_MS = 250;
|
const PERFORMANCE_LOG_THRESHOLD_MS = 250;
|
||||||
@@ -30,6 +32,24 @@ type StorageUrlResult =
|
|||||||
error: string;
|
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(
|
async function assertCanvasOwner(
|
||||||
ctx: QueryCtx | MutationCtx,
|
ctx: QueryCtx | MutationCtx,
|
||||||
canvasId: Id<"canvases">,
|
canvasId: Id<"canvases">,
|
||||||
@@ -170,20 +190,24 @@ export const batchGetUrlsForUserMedia = mutation({
|
|||||||
const startedAt = Date.now();
|
const startedAt = Date.now();
|
||||||
const user = await requireAuth(ctx);
|
const user = await requireAuth(ctx);
|
||||||
|
|
||||||
const uniqueSortedStorageIds = [...new Set(storageIds)].sort();
|
if (storageIds.length === 0) {
|
||||||
if (uniqueSortedStorageIds.length === 0) {
|
|
||||||
return {};
|
return {};
|
||||||
}
|
}
|
||||||
|
|
||||||
const ownedStorageIds = await collectOwnedImageStorageIdsForUser(ctx, user.userId);
|
const mediaItems = await ctx.db
|
||||||
const verifiedStorageIds = uniqueSortedStorageIds.filter((storageId) =>
|
.query("mediaItems")
|
||||||
ownedStorageIds.has(storageId),
|
.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) {
|
if (rejectedStorageIds > 0) {
|
||||||
console.warn("[storage.batchGetUrlsForUserMedia] rejected unowned storage ids", {
|
console.warn("[storage.batchGetUrlsForUserMedia] rejected unowned storage ids", {
|
||||||
userId: user.userId,
|
userId: user.userId,
|
||||||
requestedCount: uniqueSortedStorageIds.length,
|
requestedCount: storageIds.length,
|
||||||
rejectedStorageIds,
|
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", {
|
console.info("[storage.registerUploadedImageMedia] acknowledged", {
|
||||||
userId: user.userId,
|
userId: user.userId,
|
||||||
canvasId: args.canvasId,
|
canvasId: args.canvasId,
|
||||||
@@ -280,42 +320,3 @@ function collectStorageIds(
|
|||||||
|
|
||||||
return [...ids];
|
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>;
|
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): {
|
export function useDashboardSnapshot(userId?: string | null): {
|
||||||
snapshot: DashboardSnapshot | undefined;
|
snapshot: DashboardSnapshot | undefined;
|
||||||
source: "live" | "cache" | "none";
|
source: "live" | "cache" | "none";
|
||||||
@@ -25,7 +45,12 @@ export function useDashboardSnapshot(userId?: string | null): {
|
|||||||
return 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]);
|
}, [userId, cacheEpoch]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
const STORAGE_NAMESPACE = "lemonspace.dashboard";
|
const STORAGE_NAMESPACE = "lemonspace.dashboard";
|
||||||
const CACHE_VERSION = 1;
|
const CACHE_VERSION = 2;
|
||||||
const DEFAULT_TTL_MS = 12 * 60 * 60 * 1000;
|
const DEFAULT_TTL_MS = 12 * 60 * 60 * 1000;
|
||||||
const LAST_DASHBOARD_USER_KEY = "ls-last-dashboard-user";
|
const LAST_DASHBOARD_USER_KEY = "ls-last-dashboard-user";
|
||||||
const INVALIDATION_SIGNAL_KEY = `${STORAGE_NAMESPACE}:snapshot:invalidate:v${CACHE_VERSION}`;
|
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 = {
|
type HarnessProps = {
|
||||||
runCreateNodeOnlineOnly: RunCreateNodeOnlineOnly;
|
runCreateNodeOnlineOnly: RunCreateNodeOnlineOnly;
|
||||||
|
registerUploadedImageMedia?: Parameters<typeof useCanvasDrop>[0]["registerUploadedImageMedia"];
|
||||||
};
|
};
|
||||||
|
|
||||||
function HookHarness({ runCreateNodeOnlineOnly }: HarnessProps) {
|
function HookHarness({
|
||||||
|
runCreateNodeOnlineOnly,
|
||||||
|
registerUploadedImageMedia = async () => ({ ok: true }),
|
||||||
|
}: HarnessProps) {
|
||||||
const value = useCanvasDrop({
|
const value = useCanvasDrop({
|
||||||
canvasId: "canvas_1" as Id<"canvases">,
|
canvasId: "canvas_1" as Id<"canvases">,
|
||||||
isSyncOnline: true,
|
isSyncOnline: true,
|
||||||
@@ -50,7 +54,7 @@ function HookHarness({ runCreateNodeOnlineOnly }: HarnessProps) {
|
|||||||
edges: [],
|
edges: [],
|
||||||
screenToFlowPosition: ({ x, y }) => ({ x, y }),
|
screenToFlowPosition: ({ x, y }) => ({ x, y }),
|
||||||
generateUploadUrl: async () => "https://upload.example.com",
|
generateUploadUrl: async () => "https://upload.example.com",
|
||||||
registerUploadedImageMedia: async () => ({ ok: true }),
|
registerUploadedImageMedia,
|
||||||
runCreateNodeOnlineOnly,
|
runCreateNodeOnlineOnly,
|
||||||
runCreateNodeWithEdgeSplitOnlineOnly: async () => "node_split_1" as Id<"nodes">,
|
runCreateNodeWithEdgeSplitOnlineOnly: async () => "node_split_1" as Id<"nodes">,
|
||||||
notifyOfflineUnsupported: () => {},
|
notifyOfflineUnsupported: () => {},
|
||||||
@@ -116,6 +120,7 @@ describe("useCanvasDrop image upload path", () => {
|
|||||||
const runCreateNodeOnlineOnly = vi
|
const runCreateNodeOnlineOnly = vi
|
||||||
.fn<HarnessProps["runCreateNodeOnlineOnly"]>()
|
.fn<HarnessProps["runCreateNodeOnlineOnly"]>()
|
||||||
.mockResolvedValue("node_1" as Id<"nodes">);
|
.mockResolvedValue("node_1" as Id<"nodes">);
|
||||||
|
const registerUploadedImageMedia = vi.fn(async () => ({ ok: true as const }));
|
||||||
|
|
||||||
container = document.createElement("div");
|
container = document.createElement("div");
|
||||||
document.body.appendChild(container);
|
document.body.appendChild(container);
|
||||||
@@ -125,6 +130,7 @@ describe("useCanvasDrop image upload path", () => {
|
|||||||
root?.render(
|
root?.render(
|
||||||
React.createElement(HookHarness, {
|
React.createElement(HookHarness, {
|
||||||
runCreateNodeOnlineOnly,
|
runCreateNodeOnlineOnly,
|
||||||
|
registerUploadedImageMedia,
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
@@ -149,6 +155,15 @@ describe("useCanvasDrop image upload path", () => {
|
|||||||
|
|
||||||
expect(fetchMock).toHaveBeenCalledTimes(2);
|
expect(fetchMock).toHaveBeenCalledTimes(2);
|
||||||
expect(runCreateNodeOnlineOnly).toHaveBeenCalledTimes(1);
|
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(invalidateDashboardSnapshotForLastSignedInUserMock).toHaveBeenCalledTimes(1);
|
||||||
expect(emitDashboardSnapshotCacheInvalidationSignalMock).toHaveBeenCalledTimes(1);
|
expect(emitDashboardSnapshotCacheInvalidationSignalMock).toHaveBeenCalledTimes(1);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -49,7 +49,31 @@ function createCachedSnapshot() {
|
|||||||
},
|
},
|
||||||
],
|
],
|
||||||
canvases: [],
|
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(latestHookValue.current?.source).toBe("cache");
|
||||||
expect(firstSnapshot).toBe(secondSnapshot);
|
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-node-local-data.test.tsx",
|
||||||
"components/canvas/__tests__/use-canvas-sync-engine.test.ts",
|
"components/canvas/__tests__/use-canvas-sync-engine.test.ts",
|
||||||
"components/canvas/__tests__/use-canvas-sync-engine-hook.test.tsx",
|
"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",
|
"components/media/__tests__/media-preview-utils.test.ts",
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
|||||||
Reference in New Issue
Block a user