feat(media): add Convex media archive with backfill and mixed-media library
This commit is contained in:
@@ -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);
|
||||
},
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user