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

@@ -25,6 +25,8 @@ import type * as export_ from "../export.js";
import type * as freepik from "../freepik.js";
import type * as helpers from "../helpers.js";
import type * as http from "../http.js";
import type * as media from "../media.js";
import type * as migrations from "../migrations.js";
import type * as node_type_validator from "../node_type_validator.js";
import type * as nodes from "../nodes.js";
import type * as openrouter from "../openrouter.js";
@@ -59,6 +61,8 @@ declare const fullApi: ApiFromModules<{
freepik: typeof freepik;
helpers: typeof helpers;
http: typeof http;
media: typeof media;
migrations: typeof migrations;
node_type_validator: typeof node_type_validator;
nodes: typeof nodes;
openrouter: typeof openrouter;

View File

@@ -38,6 +38,8 @@ import {
type VideoPollStatus,
} from "../lib/video-poll-logging";
import { normalizePublicTier } from "../lib/tier-credits";
import { upsertMediaItemByOwnerAndDedupe } from "./media";
import { buildStoredMediaDedupeKey } from "../lib/media-archive";
const MAX_IMAGE_RETRIES = 2;
const MAX_VIDEO_POLL_ATTEMPTS = 30;
@@ -160,6 +162,23 @@ export const finalizeImageSuccess = internalMutation({
},
});
const canvas = await ctx.db.get(existing.canvasId);
if (!canvas) {
throw new Error("Canvas not found");
}
await upsertMediaItemByOwnerAndDedupe(ctx, {
ownerId: canvas.ownerId,
input: {
kind: "image",
source: "ai-image",
dedupeKey: buildStoredMediaDedupeKey(storageId),
storageId,
firstSourceCanvasId: existing.canvasId,
firstSourceNodeId: nodeId,
},
});
return { creditCost };
},
});
@@ -600,6 +619,24 @@ export const finalizeVideoSuccess = internalMutation({
creditCost,
},
});
const canvas = await ctx.db.get(existing.canvasId);
if (!canvas) {
throw new Error("Canvas not found");
}
await upsertMediaItemByOwnerAndDedupe(ctx, {
ownerId: canvas.ownerId,
input: {
kind: "video",
source: "ai-video",
dedupeKey: buildStoredMediaDedupeKey(storageId),
storageId,
durationSeconds,
firstSourceCanvasId: existing.canvasId,
firstSourceNodeId: nodeId,
},
});
},
});

View File

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

412
convex/media.ts Normal file
View File

@@ -0,0 +1,412 @@
import { internalMutation, internalQuery, mutation, query, type MutationCtx } from "./_generated/server";
import type { Doc, Id } from "./_generated/dataModel";
import { v } from "convex/values";
import { requireAuth } from "./helpers";
import {
buildFreepikAssetDedupeKey,
buildPexelsVideoDedupeKey,
buildStoredMediaDedupeKey,
mapMediaArchiveRowToListItem,
normalizeMediaArchiveInput,
type MediaArchiveInput,
type MediaArchiveKind,
type MediaArchiveListItem,
} from "../lib/media-archive";
const MEDIA_LIBRARY_DEFAULT_LIMIT = 200;
const MEDIA_LIBRARY_MIN_LIMIT = 1;
const MEDIA_LIBRARY_MAX_LIMIT = 500;
const mediaArchiveInputValidator = v.object({
kind: v.union(v.literal("image"), v.literal("video"), v.literal("asset")),
source: v.union(
v.literal("upload"),
v.literal("ai-image"),
v.literal("ai-video"),
v.literal("freepik-asset"),
v.literal("pexels-video"),
),
dedupeKey: v.string(),
title: v.optional(v.string()),
filename: v.optional(v.string()),
mimeType: v.optional(v.string()),
storageId: v.optional(v.id("_storage")),
previewStorageId: v.optional(v.id("_storage")),
originalUrl: v.optional(v.string()),
previewUrl: v.optional(v.string()),
sourceUrl: v.optional(v.string()),
providerAssetId: v.optional(v.string()),
width: v.optional(v.number()),
height: v.optional(v.number()),
durationSeconds: v.optional(v.number()),
metadata: v.optional(v.any()),
firstSourceCanvasId: v.optional(v.id("canvases")),
firstSourceNodeId: v.optional(v.id("nodes")),
});
type MediaItemStorageRef = {
storageId?: Id<"_storage">;
previewStorageId?: Id<"_storage">;
};
type UpsertMediaArgs = {
ownerId: string;
input: MediaArchiveInput;
now?: number;
};
type MediaInsertValue = Omit<Doc<"mediaItems">, "_id" | "_creationTime">;
type LegacyMediaBackfillCanvas = Pick<Doc<"canvases">, "_id" | "ownerId">;
type LegacyMediaBackfillNode = Pick<Doc<"nodes">, "_id" | "canvasId" | "type" | "data">;
export type LegacyMediaBackfillCanvasResult = {
scannedNodeCount: number;
upsertedItemCount: number;
};
function normalizeMediaLibraryLimit(limit: number | undefined): number {
if (typeof limit !== "number" || !Number.isFinite(limit)) {
return MEDIA_LIBRARY_DEFAULT_LIMIT;
}
return Math.min(MEDIA_LIBRARY_MAX_LIMIT, Math.max(MEDIA_LIBRARY_MIN_LIMIT, Math.floor(limit)));
}
function compactUndefined<T extends Record<string, unknown>>(value: T): Partial<T> {
const entries = Object.entries(value).filter(([, entryValue]) => entryValue !== undefined);
return Object.fromEntries(entries) as Partial<T>;
}
function asNonEmptyString(value: unknown): string | undefined {
if (typeof value !== "string") {
return undefined;
}
const trimmed = value.trim();
return trimmed.length > 0 ? trimmed : undefined;
}
function asPositiveNumber(value: unknown): number | undefined {
if (typeof value !== "number" || !Number.isFinite(value) || value <= 0) {
return undefined;
}
return value;
}
function toStorageId(value: unknown): Id<"_storage"> | undefined {
const storageId = asNonEmptyString(value);
return storageId as Id<"_storage"> | undefined;
}
export function mapLegacyNodeToMediaArchiveInput(node: LegacyMediaBackfillNode): MediaArchiveInput | null {
const data = (node.data ?? {}) as Record<string, unknown>;
const firstSourceCanvasId = node.canvasId;
const firstSourceNodeId = node._id;
if (node.type === "image") {
const storageId = toStorageId(data.storageId);
const legacyUrl = asNonEmptyString(data.url);
if (!storageId && !legacyUrl) {
return null;
}
return {
kind: "image",
source: "upload",
dedupeKey: storageId ? buildStoredMediaDedupeKey(storageId) : `legacy:image-url:${legacyUrl}`,
storageId,
filename: asNonEmptyString(data.originalFilename) ?? asNonEmptyString(data.filename),
mimeType: asNonEmptyString(data.mimeType),
width: asPositiveNumber(data.width),
height: asPositiveNumber(data.height),
metadata: legacyUrl ? { legacyUrl } : undefined,
firstSourceCanvasId,
firstSourceNodeId,
};
}
if (node.type === "ai-image") {
const storageId = toStorageId(data.storageId);
if (!storageId) {
return null;
}
return {
kind: "image",
source: "ai-image",
dedupeKey: buildStoredMediaDedupeKey(storageId),
storageId,
width: asPositiveNumber(data.width),
height: asPositiveNumber(data.height),
firstSourceCanvasId,
firstSourceNodeId,
};
}
if (node.type === "ai-video") {
const storageId = toStorageId(data.storageId);
if (!storageId) {
return null;
}
return {
kind: "video",
source: "ai-video",
dedupeKey: buildStoredMediaDedupeKey(storageId),
storageId,
durationSeconds: asPositiveNumber(data.durationSeconds),
firstSourceCanvasId,
firstSourceNodeId,
};
}
if (node.type === "asset") {
const sourceUrl = asNonEmptyString(data.sourceUrl);
const assetType = asNonEmptyString(data.assetType) ?? "photo";
const providerAssetId =
typeof data.assetId === "number" || typeof data.assetId === "string"
? String(data.assetId)
: undefined;
const dedupeKey = providerAssetId
? buildFreepikAssetDedupeKey(assetType, providerAssetId)
: sourceUrl
? `freepik:url:${sourceUrl}`
: undefined;
if (!dedupeKey) {
return null;
}
return {
kind: "asset",
source: "freepik-asset",
dedupeKey,
title: asNonEmptyString(data.title),
originalUrl: asNonEmptyString(data.url),
previewUrl: asNonEmptyString(data.previewUrl) ?? asNonEmptyString(data.url),
sourceUrl,
providerAssetId,
width: asPositiveNumber(data.intrinsicWidth),
height: asPositiveNumber(data.intrinsicHeight),
metadata: compactUndefined({
assetType,
authorName: asNonEmptyString(data.authorName),
license: asNonEmptyString(data.license),
orientation: asNonEmptyString(data.orientation),
}),
firstSourceCanvasId,
firstSourceNodeId,
};
}
if (node.type === "video") {
const originalUrl = asNonEmptyString(data.mp4Url);
const sourceUrl =
asNonEmptyString((data.attribution as { videoUrl?: unknown } | undefined)?.videoUrl) ??
asNonEmptyString(data.sourceUrl);
const providerAssetId =
typeof data.pexelsId === "number" || typeof data.pexelsId === "string"
? String(data.pexelsId)
: undefined;
const dedupeKey = providerAssetId
? buildPexelsVideoDedupeKey(providerAssetId)
: sourceUrl
? `pexels:url:${sourceUrl}`
: originalUrl
? `pexels:mp4:${originalUrl}`
: undefined;
if (!dedupeKey) {
return null;
}
return {
kind: "video",
source: "pexels-video",
dedupeKey,
originalUrl,
previewUrl: asNonEmptyString(data.thumbnailUrl),
sourceUrl,
providerAssetId,
width: asPositiveNumber(data.width),
height: asPositiveNumber(data.height),
durationSeconds: asPositiveNumber(data.duration),
firstSourceCanvasId,
firstSourceNodeId,
};
}
return null;
}
export async function backfillLegacyMediaForCanvas(
ctx: MutationCtx,
args: {
canvas: LegacyMediaBackfillCanvas;
nodes: LegacyMediaBackfillNode[];
now?: number;
},
): Promise<LegacyMediaBackfillCanvasResult> {
const now = args.now ?? Date.now();
let upsertedItemCount = 0;
for (const node of args.nodes) {
const input = mapLegacyNodeToMediaArchiveInput(node);
if (!input) {
continue;
}
await upsertMediaItemByOwnerAndDedupe(ctx, {
ownerId: args.canvas.ownerId,
input,
now,
});
upsertedItemCount += 1;
}
return {
scannedNodeCount: args.nodes.length,
upsertedItemCount,
};
}
export function collectOwnedMediaStorageIds(items: Array<MediaItemStorageRef>): Set<Id<"_storage">> {
const ids = new Set<Id<"_storage">>();
for (const item of items) {
if (item.storageId) {
ids.add(item.storageId);
}
if (item.previewStorageId) {
ids.add(item.previewStorageId);
}
}
return ids;
}
export function listMediaArchiveItems(
rows: Array<Doc<"mediaItems">>,
options?: { kind?: MediaArchiveKind; limit?: number },
): MediaArchiveListItem[] {
const normalizedLimit = normalizeMediaLibraryLimit(options?.limit);
const filteredRows = rows
.filter((row) => (options?.kind ? row.kind === options.kind : true))
.sort((a, b) => b.updatedAt - a.updatedAt)
.slice(0, normalizedLimit);
return filteredRows.map((row) =>
mapMediaArchiveRowToListItem({
...row,
_id: row._id,
}),
);
}
export async function upsertMediaItemByOwnerAndDedupe(
ctx: MutationCtx,
{ ownerId, input, now = Date.now() }: UpsertMediaArgs,
): Promise<Doc<"mediaItems">> {
const normalizedInput = normalizeMediaArchiveInput(input);
const existing = await ctx.db
.query("mediaItems")
.withIndex("by_owner_dedupe", (q) => q.eq("ownerId", ownerId).eq("dedupeKey", input.dedupeKey))
.unique();
if (existing) {
const patchValue = compactUndefined({
...normalizedInput,
updatedAt: now,
lastUsedAt: now,
}) as Partial<MediaInsertValue>;
await ctx.db.patch(
existing._id,
patchValue,
);
const updated = await ctx.db.get(existing._id);
if (!updated) {
throw new Error("media item vanished after patch");
}
return updated;
}
const insertValue: MediaInsertValue = compactUndefined({
ownerId,
...normalizedInput,
createdAt: now,
updatedAt: now,
lastUsedAt: now,
}) as MediaInsertValue;
const insertedId = await ctx.db.insert("mediaItems", insertValue);
const inserted = await ctx.db.get(insertedId);
if (!inserted) {
throw new Error("failed to read inserted media item");
}
return inserted;
}
export const list = query({
args: {
kind: v.optional(v.union(v.literal("image"), v.literal("video"), v.literal("asset"))),
limit: v.optional(v.number()),
},
handler: async (ctx, { kind, limit }) => {
const user = await requireAuth(ctx);
const rows = await ctx.db
.query("mediaItems")
.withIndex("by_owner_updated", (q) => q.eq("ownerId", user.userId))
.order("desc")
.take(normalizeMediaLibraryLimit(limit));
return listMediaArchiveItems(rows, { kind, limit });
},
});
export const listByOwnerInternal = internalQuery({
args: {
ownerId: v.string(),
kind: v.optional(v.union(v.literal("image"), v.literal("video"), v.literal("asset"))),
limit: v.optional(v.number()),
},
handler: async (ctx, { ownerId, kind, limit }) => {
const rows = await ctx.db
.query("mediaItems")
.withIndex("by_owner_updated", (q) => q.eq("ownerId", ownerId))
.order("desc")
.take(normalizeMediaLibraryLimit(limit));
return listMediaArchiveItems(rows, { kind, limit });
},
});
export const upsert = mutation({
args: {
input: mediaArchiveInputValidator,
},
handler: async (ctx, { input }) => {
const user = await requireAuth(ctx);
return await upsertMediaItemByOwnerAndDedupe(ctx, {
ownerId: user.userId,
input,
});
},
});
export const upsertForOwnerInternal = internalMutation({
args: {
ownerId: v.string(),
input: mediaArchiveInputValidator,
},
handler: async (ctx, { ownerId, input }) => {
return await upsertMediaItemByOwnerAndDedupe(ctx, {
ownerId,
input,
});
},
});

109
convex/migrations.ts Normal file
View File

@@ -0,0 +1,109 @@
import { v } from "convex/values";
import { internalMutation, type MutationCtx } from "./_generated/server";
import type { Id } from "./_generated/dataModel";
import { backfillLegacyMediaForCanvas } from "./media";
const MEDIA_BACKFILL_DEFAULT_BATCH_SIZE = 25;
const MEDIA_BACKFILL_MIN_BATCH_SIZE = 1;
const MEDIA_BACKFILL_MAX_BATCH_SIZE = 200;
export type MediaArchiveBackfillBatchArgs = {
cursor?: Id<"canvases">;
batchSize?: number;
now?: number;
};
export type MediaArchiveBackfillBatchResult = {
processedCanvasCount: number;
scannedNodeCount: number;
upsertedItemCount: number;
nextCursor: Id<"canvases"> | null;
done: boolean;
};
function normalizeBatchSize(batchSize: number | undefined): number {
if (typeof batchSize !== "number" || !Number.isFinite(batchSize)) {
return MEDIA_BACKFILL_DEFAULT_BATCH_SIZE;
}
return Math.min(
MEDIA_BACKFILL_MAX_BATCH_SIZE,
Math.max(MEDIA_BACKFILL_MIN_BATCH_SIZE, Math.floor(batchSize)),
);
}
function computeStartIndex(
canvasIds: Array<Id<"canvases">>,
cursor: Id<"canvases"> | undefined,
): number {
if (!cursor) {
return 0;
}
const exactCursorIndex = canvasIds.findIndex((canvasId) => canvasId === cursor);
if (exactCursorIndex >= 0) {
return exactCursorIndex + 1;
}
const fallbackIndex = canvasIds.findIndex((canvasId) => canvasId > cursor);
return fallbackIndex >= 0 ? fallbackIndex : canvasIds.length;
}
export async function backfillMediaArchiveBatch(
ctx: MutationCtx,
{ cursor, batchSize, now = Date.now() }: MediaArchiveBackfillBatchArgs,
): Promise<MediaArchiveBackfillBatchResult> {
const normalizedBatchSize = normalizeBatchSize(batchSize);
const canvases = await ctx.db.query("canvases").order("asc").collect();
const canvasIds = canvases.map((canvas) => canvas._id);
const startIndex = computeStartIndex(canvasIds, cursor);
const batch = canvases.slice(startIndex, startIndex + normalizedBatchSize);
let scannedNodeCount = 0;
let upsertedItemCount = 0;
for (const canvas of batch) {
const nodes = await ctx.db
.query("nodes")
.withIndex("by_canvas", (q) => q.eq("canvasId", canvas._id))
.collect();
const canvasResult = await backfillLegacyMediaForCanvas(ctx, {
canvas: {
_id: canvas._id,
ownerId: canvas.ownerId,
},
nodes,
now,
});
scannedNodeCount += canvasResult.scannedNodeCount;
upsertedItemCount += canvasResult.upsertedItemCount;
}
const processedCanvasCount = batch.length;
const done = startIndex + processedCanvasCount >= canvases.length;
const nextCursor =
processedCanvasCount > 0 ? batch[processedCanvasCount - 1]._id : (cursor ?? null);
return {
processedCanvasCount,
scannedNodeCount,
upsertedItemCount,
nextCursor,
done,
};
}
export const backfillMediaArchiveBatchInternal = internalMutation({
args: {
cursor: v.optional(v.id("canvases")),
batchSize: v.optional(v.number()),
now: v.optional(v.number()),
},
handler: async (ctx, args) => {
return await backfillMediaArchiveBatch(ctx, args);
},
});

View File

@@ -32,6 +32,20 @@ const nodeStatus = v.union(
v.literal("error")
);
const mediaItemKind = v.union(
v.literal("image"),
v.literal("video"),
v.literal("asset")
);
const mediaItemSource = v.union(
v.literal("upload"),
v.literal("ai-image"),
v.literal("ai-video"),
v.literal("freepik-asset"),
v.literal("pexels-video")
);
// ============================================================================
// Node Data — typ-spezifische Payloads
// ============================================================================
@@ -187,6 +201,34 @@ export default defineSchema({
.index("by_userId", ["userId"])
.index("by_userId_nodeType", ["userId", "nodeType"]),
mediaItems: defineTable({
ownerId: v.string(),
kind: mediaItemKind,
source: mediaItemSource,
dedupeKey: v.string(),
title: v.optional(v.string()),
filename: v.optional(v.string()),
mimeType: v.optional(v.string()),
storageId: v.optional(v.id("_storage")),
previewStorageId: v.optional(v.id("_storage")),
originalUrl: v.optional(v.string()),
previewUrl: v.optional(v.string()),
sourceUrl: v.optional(v.string()),
providerAssetId: v.optional(v.string()),
width: v.optional(v.number()),
height: v.optional(v.number()),
durationSeconds: v.optional(v.number()),
metadata: v.optional(v.any()),
firstSourceCanvasId: v.optional(v.id("canvases")),
firstSourceNodeId: v.optional(v.id("nodes")),
createdAt: v.number(),
updatedAt: v.number(),
lastUsedAt: v.number(),
})
.index("by_owner_updated", ["ownerId", "updatedAt"])
.index("by_owner_kind_updated", ["ownerId", "kind", "updatedAt"])
.index("by_owner_dedupe", ["ownerId", "dedupeKey"]),
// ==========================================================================
// Credit-System
// ==========================================================================

View File

@@ -2,6 +2,8 @@ import { mutation, type MutationCtx, type QueryCtx } from "./_generated/server";
import { v } from "convex/values";
import { requireAuth } from "./helpers";
import type { Id } from "./_generated/dataModel";
import { collectOwnedMediaStorageIds, upsertMediaItemByOwnerAndDedupe } from "./media";
import { buildStoredMediaDedupeKey } from "../lib/media-archive";
const STORAGE_URL_BATCH_SIZE = 12;
const PERFORMANCE_LOG_THRESHOLD_MS = 250;
@@ -30,6 +32,24 @@ type StorageUrlResult =
error: string;
};
export function verifyOwnedStorageIds(
requestedStorageIds: Array<Id<"_storage">>,
ownedStorageIds: Set<Id<"_storage">>,
): {
verifiedStorageIds: Array<Id<"_storage">>;
rejectedStorageIds: number;
} {
const uniqueSortedStorageIds = [...new Set(requestedStorageIds)].sort();
const verifiedStorageIds = uniqueSortedStorageIds.filter((storageId) =>
ownedStorageIds.has(storageId),
);
return {
verifiedStorageIds,
rejectedStorageIds: uniqueSortedStorageIds.length - verifiedStorageIds.length,
};
}
async function assertCanvasOwner(
ctx: QueryCtx | MutationCtx,
canvasId: Id<"canvases">,
@@ -170,20 +190,24 @@ export const batchGetUrlsForUserMedia = mutation({
const startedAt = Date.now();
const user = await requireAuth(ctx);
const uniqueSortedStorageIds = [...new Set(storageIds)].sort();
if (uniqueSortedStorageIds.length === 0) {
if (storageIds.length === 0) {
return {};
}
const ownedStorageIds = await collectOwnedImageStorageIdsForUser(ctx, user.userId);
const verifiedStorageIds = uniqueSortedStorageIds.filter((storageId) =>
ownedStorageIds.has(storageId),
const mediaItems = await ctx.db
.query("mediaItems")
.withIndex("by_owner_updated", (q) => q.eq("ownerId", user.userId))
.collect();
const ownedStorageIds = collectOwnedMediaStorageIds(mediaItems);
const { verifiedStorageIds, rejectedStorageIds } = verifyOwnedStorageIds(
storageIds,
ownedStorageIds,
);
const rejectedStorageIds = uniqueSortedStorageIds.length - verifiedStorageIds.length;
if (rejectedStorageIds > 0) {
console.warn("[storage.batchGetUrlsForUserMedia] rejected unowned storage ids", {
userId: user.userId,
requestedCount: uniqueSortedStorageIds.length,
requestedCount: storageIds.length,
rejectedStorageIds,
});
}
@@ -236,6 +260,22 @@ export const registerUploadedImageMedia = mutation({
}
}
await upsertMediaItemByOwnerAndDedupe(ctx, {
ownerId: user.userId,
input: {
kind: "image",
source: "upload",
dedupeKey: buildStoredMediaDedupeKey(args.storageId),
storageId: args.storageId,
filename: args.filename,
mimeType: args.mimeType,
width: args.width,
height: args.height,
firstSourceCanvasId: args.canvasId,
firstSourceNodeId: args.nodeId,
},
});
console.info("[storage.registerUploadedImageMedia] acknowledged", {
userId: user.userId,
canvasId: args.canvasId,
@@ -280,42 +320,3 @@ function collectStorageIds(
return [...ids];
}
async function collectOwnedImageStorageIdsForUser(
ctx: QueryCtx | MutationCtx,
userId: string,
): Promise<Set<Id<"_storage">>> {
const canvases = await ctx.db
.query("canvases")
.withIndex("by_owner", (q) => q.eq("ownerId", userId))
.collect();
if (canvases.length === 0) {
return new Set();
}
const imageNodesByCanvas = await Promise.all(
canvases.map((canvas) =>
ctx.db
.query("nodes")
.withIndex("by_canvas_type", (q) => q.eq("canvasId", canvas._id).eq("type", "image"))
.collect(),
),
);
const imageStorageIds = new Set<Id<"_storage">>();
for (const nodes of imageNodesByCanvas) {
for (const node of nodes) {
const data = node.data as Record<string, unknown> | undefined;
const storageId = data?.storageId;
const previewStorageId = data?.previewStorageId;
if (typeof storageId === "string" && storageId.length > 0) {
imageStorageIds.add(storageId as Id<"_storage">);
}
if (typeof previewStorageId === "string" && previewStorageId.length > 0) {
imageStorageIds.add(previewStorageId as Id<"_storage">);
}
}
}
return imageStorageIds;
}