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

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

View File

@@ -1,4 +1,4 @@
import { query } from "./_generated/server";
import { query, type QueryCtx } from "./_generated/server";
import type { Doc, Id } from "./_generated/dataModel";
import { v } from "convex/values";
@@ -12,21 +12,80 @@ const DASHBOARD_MEDIA_PREVIEW_LIMIT = 8;
const MEDIA_LIBRARY_DEFAULT_LIMIT = 200;
const MEDIA_LIBRARY_MIN_LIMIT = 1;
const MEDIA_LIBRARY_MAX_LIMIT = 500;
const MEDIA_ARCHIVE_FETCH_MULTIPLIER = 4;
type MediaPreviewItem = {
storageId: Id<"_storage">;
kind: "image" | "video" | "asset";
source: "upload" | "ai-image" | "ai-video" | "freepik-asset" | "pexels-video";
storageId?: Id<"_storage">;
previewStorageId?: Id<"_storage">;
originalUrl?: string;
previewUrl?: string;
sourceUrl?: string;
filename?: string;
mimeType?: string;
width?: number;
height?: number;
previewWidth?: number;
previewHeight?: number;
sourceCanvasId: Id<"canvases">;
sourceNodeId: Id<"nodes">;
sourceCanvasId?: Id<"canvases">;
sourceNodeId?: Id<"nodes">;
createdAt: number;
};
function readArchivedMediaPreview(item: Doc<"mediaItems">): MediaPreviewItem | null {
if (!item.storageId && !item.previewStorageId && !item.previewUrl && !item.originalUrl && !item.sourceUrl) {
return null;
}
return {
kind: item.kind,
source: item.source,
storageId: item.storageId,
previewStorageId: item.previewStorageId,
originalUrl: item.originalUrl,
previewUrl: item.previewUrl,
sourceUrl: item.sourceUrl,
filename: item.filename ?? item.title,
mimeType: item.mimeType,
width: item.width,
height: item.height,
sourceCanvasId: item.firstSourceCanvasId,
sourceNodeId: item.firstSourceNodeId,
createdAt: item.updatedAt,
};
}
function buildMediaPreviewFromArchive(
mediaItems: Array<Doc<"mediaItems">>,
limit: number,
kindFilter?: "image" | "video" | "asset",
): MediaPreviewItem[] {
const sortedRows = mediaItems
.filter((item) => (kindFilter ? item.kind === kindFilter : true))
.sort((a, b) => b.updatedAt - a.updatedAt);
const deduped = new Map<string, MediaPreviewItem>();
for (const item of sortedRows) {
const dedupeKey = item.storageId ?? item.dedupeKey;
if (deduped.has(dedupeKey)) {
continue;
}
const preview = readArchivedMediaPreview(item);
if (!preview) {
continue;
}
deduped.set(dedupeKey, preview);
if (deduped.size >= limit) {
break;
}
}
return [...deduped.values()];
}
function readImageMediaPreview(node: Doc<"nodes">): MediaPreviewItem | null {
if (node.type !== "image") {
return null;
@@ -62,6 +121,8 @@ function readImageMediaPreview(node: Doc<"nodes">): MediaPreviewItem | null {
: undefined;
return {
kind: "image",
source: "upload",
storageId: storageId as Id<"_storage">,
previewStorageId,
filename,
@@ -82,13 +143,14 @@ function buildMediaPreview(nodes: Array<Doc<"nodes">>, limit: number): MediaPrev
.filter((item): item is MediaPreviewItem => item !== null)
.sort((a, b) => b.createdAt - a.createdAt);
const deduped = new Map<Id<"_storage">, MediaPreviewItem>();
const deduped = new Map<string, MediaPreviewItem>();
for (const item of candidates) {
if (deduped.has(item.storageId)) {
const dedupeKey = item.storageId ?? `${item.sourceCanvasId}:${item.sourceNodeId}`;
if (deduped.has(dedupeKey)) {
continue;
}
deduped.set(item.storageId, item);
deduped.set(dedupeKey, item);
if (deduped.size >= limit) {
break;
}
@@ -105,6 +167,43 @@ function normalizeMediaLibraryLimit(limit: number | undefined): number {
return Math.min(MEDIA_LIBRARY_MAX_LIMIT, Math.max(MEDIA_LIBRARY_MIN_LIMIT, Math.floor(limit)));
}
async function buildMediaPreviewFromNodeFallback(
ctx: QueryCtx,
canvases: Array<Doc<"canvases">>,
limit: number,
): Promise<MediaPreviewItem[]> {
if (canvases.length === 0 || limit <= 0) {
return [];
}
const deduped = new Map<string, MediaPreviewItem>();
for (const canvas of canvases.slice(0, 12)) {
if (deduped.size >= limit) {
break;
}
const nodes = await ctx.db
.query("nodes")
.withIndex("by_canvas_type", (q) => q.eq("canvasId", canvas._id).eq("type", "image"))
.order("desc")
.take(Math.max(limit * 2, 16));
const candidates = buildMediaPreview(nodes, limit);
for (const candidate of candidates) {
const dedupeKey = candidate.storageId ?? `${candidate.sourceCanvasId}:${candidate.sourceNodeId}`;
if (deduped.has(dedupeKey)) {
continue;
}
deduped.set(dedupeKey, candidate);
if (deduped.size >= limit) {
break;
}
}
}
return [...deduped.values()].slice(0, limit);
}
export const getSnapshot = query({
args: {},
handler: async (ctx) => {
@@ -130,7 +229,7 @@ export const getSnapshot = query({
};
}
const [balanceRow, subscriptionRow, usageTransactions, recentTransactionsRaw, canvases] =
const [balanceRow, subscriptionRow, usageTransactions, recentTransactionsRaw, canvases, mediaArchiveRows] =
await Promise.all([
ctx.db
.query("creditBalances")
@@ -156,18 +255,17 @@ export const getSnapshot = query({
.withIndex("by_owner_updated", (q) => q.eq("ownerId", user.userId))
.order("desc")
.collect(),
ctx.db
.query("mediaItems")
.withIndex("by_owner_updated", (q) => q.eq("ownerId", user.userId))
.order("desc")
.take(Math.max(DASHBOARD_MEDIA_PREVIEW_LIMIT * MEDIA_ARCHIVE_FETCH_MULTIPLIER, 32)),
]);
const imageNodesByCanvas = await Promise.all(
canvases.map((canvas) =>
ctx.db
.query("nodes")
.withIndex("by_canvas_type", (q) => q.eq("canvasId", canvas._id).eq("type", "image"))
.order("desc")
.collect(),
),
);
const mediaPreview = buildMediaPreview(imageNodesByCanvas.flat(), DASHBOARD_MEDIA_PREVIEW_LIMIT);
let mediaPreview = buildMediaPreviewFromArchive(mediaArchiveRows, DASHBOARD_MEDIA_PREVIEW_LIMIT);
if (mediaPreview.length === 0 && mediaArchiveRows.length === 0) {
mediaPreview = await buildMediaPreviewFromNodeFallback(ctx, canvases, DASHBOARD_MEDIA_PREVIEW_LIMIT);
}
const tier = normalizeBillingTier(subscriptionRow?.tier);
const monthStart = new Date(new Date().getFullYear(), new Date().getMonth(), 1).getTime();
@@ -215,34 +313,42 @@ export const getSnapshot = query({
export const listMediaLibrary = query({
args: {
limit: v.optional(v.number()),
kindFilter: v.optional(v.union(v.literal("image"), v.literal("video"), v.literal("asset"))),
},
handler: async (ctx, { limit }) => {
handler: async (ctx, { limit, kindFilter }) => {
const user = await optionalAuth(ctx);
if (!user) {
return [];
}
const normalizedLimit = normalizeMediaLibraryLimit(limit);
const baseTake = Math.max(normalizedLimit * MEDIA_ARCHIVE_FETCH_MULTIPLIER, normalizedLimit);
const mediaArchiveRows = kindFilter
? await ctx.db
.query("mediaItems")
.withIndex("by_owner_kind_updated", (q) => q.eq("ownerId", user.userId).eq("kind", kindFilter))
.order("desc")
.take(baseTake)
: await ctx.db
.query("mediaItems")
.withIndex("by_owner_updated", (q) => q.eq("ownerId", user.userId))
.order("desc")
.take(baseTake);
const mediaFromArchive = buildMediaPreviewFromArchive(mediaArchiveRows, normalizedLimit, kindFilter);
if (mediaFromArchive.length > 0 || mediaArchiveRows.length > 0) {
return mediaFromArchive;
}
if (kindFilter && kindFilter !== "image") {
return [];
}
const canvases = await ctx.db
.query("canvases")
.withIndex("by_owner_updated", (q) => q.eq("ownerId", user.userId))
.order("desc")
.collect();
if (canvases.length === 0) {
return [];
}
const imageNodesByCanvas = await Promise.all(
canvases.map((canvas) =>
ctx.db
.query("nodes")
.withIndex("by_canvas_type", (q) => q.eq("canvasId", canvas._id).eq("type", "image"))
.order("desc")
.collect(),
),
);
return buildMediaPreview(imageNodesByCanvas.flat(), normalizedLimit);
return await buildMediaPreviewFromNodeFallback(ctx, canvases, normalizedLimit);
},
});