Enhance canvas functionality by adding media preview capabilities and image upload handling. Introduce compressed image previews during uploads, improve media library integration, and implement retry logic for bridge edge creation. Update dashboard to display media previews and optimize image node handling.

This commit is contained in:
Matthias
2026-04-08 20:44:31 +02:00
parent a7eb2bc99c
commit b7f24223f2
43 changed files with 4064 additions and 148 deletions

View File

@@ -1,4 +1,6 @@
import { query } from "./_generated/server";
import type { Doc, Id } from "./_generated/dataModel";
import { v } from "convex/values";
import { optionalAuth } from "./helpers";
import { prioritizeRecentCreditTransactions } from "../lib/credits-activity";
@@ -6,6 +8,102 @@ import { MONTHLY_TIER_CREDITS, normalizeBillingTier } from "../lib/tier-credits"
const DEFAULT_TIER = "free" as const;
const DEFAULT_SUBSCRIPTION_STATUS = "active" as const;
const DASHBOARD_MEDIA_PREVIEW_LIMIT = 8;
const MEDIA_LIBRARY_DEFAULT_LIMIT = 200;
const MEDIA_LIBRARY_MIN_LIMIT = 1;
const MEDIA_LIBRARY_MAX_LIMIT = 500;
type MediaPreviewItem = {
storageId: Id<"_storage">;
previewStorageId?: Id<"_storage">;
filename?: string;
mimeType?: string;
width?: number;
height?: number;
previewWidth?: number;
previewHeight?: number;
sourceCanvasId: Id<"canvases">;
sourceNodeId: Id<"nodes">;
createdAt: number;
};
function readImageMediaPreview(node: Doc<"nodes">): MediaPreviewItem | null {
if (node.type !== "image") {
return null;
}
const data = (node.data as Record<string, unknown> | undefined) ?? {};
const storageId = data.storageId;
if (typeof storageId !== "string" || storageId.length === 0) {
return null;
}
const previewStorageId =
typeof data.previewStorageId === "string" && data.previewStorageId.length > 0
? (data.previewStorageId as Id<"_storage">)
: undefined;
const filename =
typeof data.filename === "string"
? data.filename
: typeof data.originalFilename === "string"
? data.originalFilename
: undefined;
const mimeType = typeof data.mimeType === "string" ? data.mimeType : undefined;
const width = typeof data.width === "number" && Number.isFinite(data.width) ? data.width : undefined;
const height =
typeof data.height === "number" && Number.isFinite(data.height) ? data.height : undefined;
const previewWidth =
typeof data.previewWidth === "number" && Number.isFinite(data.previewWidth)
? data.previewWidth
: undefined;
const previewHeight =
typeof data.previewHeight === "number" && Number.isFinite(data.previewHeight)
? data.previewHeight
: undefined;
return {
storageId: storageId as Id<"_storage">,
previewStorageId,
filename,
mimeType,
width,
height,
previewWidth,
previewHeight,
sourceCanvasId: node.canvasId,
sourceNodeId: node._id,
createdAt: node._creationTime,
};
}
function buildMediaPreview(nodes: Array<Doc<"nodes">>, limit: number): MediaPreviewItem[] {
const candidates = nodes
.map(readImageMediaPreview)
.filter((item): item is MediaPreviewItem => item !== null)
.sort((a, b) => b.createdAt - a.createdAt);
const deduped = new Map<Id<"_storage">, MediaPreviewItem>();
for (const item of candidates) {
if (deduped.has(item.storageId)) {
continue;
}
deduped.set(item.storageId, item);
if (deduped.size >= limit) {
break;
}
}
return [...deduped.values()];
}
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)));
}
export const getSnapshot = query({
args: {},
@@ -27,6 +125,7 @@ export const getSnapshot = query({
},
recentTransactions: [],
canvases: [],
mediaPreview: [],
generatedAt: Date.now(),
};
}
@@ -59,6 +158,17 @@ export const getSnapshot = query({
.collect(),
]);
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);
const tier = normalizeBillingTier(subscriptionRow?.tier);
const monthStart = new Date(new Date().getFullYear(), new Date().getMonth(), 1).getTime();
let monthlyUsage = 0;
@@ -96,7 +206,43 @@ export const getSnapshot = query({
},
recentTransactions: prioritizeRecentCreditTransactions(recentTransactionsRaw, 20),
canvases,
mediaPreview,
generatedAt: Date.now(),
};
},
});
export const listMediaLibrary = query({
args: {
limit: v.optional(v.number()),
},
handler: async (ctx, { limit }) => {
const user = await optionalAuth(ctx);
if (!user) {
return [];
}
const normalizedLimit = normalizeMediaLibraryLimit(limit);
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);
},
});

View File

@@ -9,6 +9,7 @@ import {
validateCanvasConnectionPolicy,
} from "../lib/canvas-connection-policy";
import { nodeTypeValidator } from "./node_type_validator";
import { normalizeCropNodeData } from "../lib/image-pipeline/crop-node-data";
// ============================================================================
// Interne Helpers
@@ -391,6 +392,12 @@ function normalizeNodeDataForWrite(
nodeType: Doc<"nodes">["type"],
data: unknown,
): unknown {
if (nodeType === "crop") {
return normalizeCropNodeData(data, {
rejectDisallowedPayloadFields: true,
});
}
if (!isAdjustmentNodeType(nodeType)) {
return data;
}

View File

@@ -42,9 +42,11 @@ async function assertCanvasOwner(
}
async function resolveStorageUrls(
ctx: QueryCtx,
ctx: QueryCtx | MutationCtx,
storageIds: Array<Id<"_storage">>,
options?: { logLabel?: string },
): Promise<StorageUrlMap> {
const logLabel = options?.logLabel ?? "batchGetUrlsForCanvas";
const resolved: StorageUrlMap = {};
const operationStartedAt = Date.now();
let failedCount = 0;
@@ -75,7 +77,7 @@ async function resolveStorageUrls(
if (entry.error) {
failedCount += 1;
batchFailedCount += 1;
console.warn("[storage.batchGetUrlsForCanvas] getUrl failed", {
console.warn(`[storage.${logLabel}] getUrl failed`, {
storageId: entry.storageId,
error: entry.error,
});
@@ -89,7 +91,7 @@ async function resolveStorageUrls(
}
}
logSlowQuery("batchGetUrlsForCanvas::resolveStorageBatch", batchStartedAt, {
logSlowQuery(`${logLabel}::resolveStorageBatch`, batchStartedAt, {
batchSize: batch.length,
successCount: entries.length - batchFailedCount,
failedCount: batchFailedCount,
@@ -97,7 +99,7 @@ async function resolveStorageUrls(
});
}
logSlowQuery("batchGetUrlsForCanvas", operationStartedAt, {
logSlowQuery(logLabel, operationStartedAt, {
requestStorageCount: storageIds.length,
resolvedCount: totalResolved,
failedCount,
@@ -147,7 +149,9 @@ export const batchGetUrlsForCanvas = mutation({
});
}
const result = await resolveStorageUrls(ctx, verifiedStorageIds);
const result = await resolveStorageUrls(ctx, verifiedStorageIds, {
logLabel: "batchGetUrlsForCanvas",
});
logSlowQuery("batchGetUrlsForCanvas::total", startedAt, {
canvasId,
storageIdCount: verifiedStorageIds.length,
@@ -157,6 +161,96 @@ export const batchGetUrlsForCanvas = mutation({
return result;
},
});
export const batchGetUrlsForUserMedia = mutation({
args: {
storageIds: v.array(v.id("_storage")),
},
handler: async (ctx, { storageIds }) => {
const startedAt = Date.now();
const user = await requireAuth(ctx);
const uniqueSortedStorageIds = [...new Set(storageIds)].sort();
if (uniqueSortedStorageIds.length === 0) {
return {};
}
const ownedStorageIds = await collectOwnedImageStorageIdsForUser(ctx, user.userId);
const verifiedStorageIds = uniqueSortedStorageIds.filter((storageId) =>
ownedStorageIds.has(storageId),
);
const rejectedStorageIds = uniqueSortedStorageIds.length - verifiedStorageIds.length;
if (rejectedStorageIds > 0) {
console.warn("[storage.batchGetUrlsForUserMedia] rejected unowned storage ids", {
userId: user.userId,
requestedCount: uniqueSortedStorageIds.length,
rejectedStorageIds,
});
}
const result = await resolveStorageUrls(ctx, verifiedStorageIds, {
logLabel: "batchGetUrlsForUserMedia",
});
logSlowQuery("batchGetUrlsForUserMedia::total", startedAt, {
userId: user.userId,
storageIdCount: verifiedStorageIds.length,
rejectedStorageIds,
resolvedCount: Object.keys(result).length,
});
return result;
},
});
export const registerUploadedImageMedia = mutation({
args: {
canvasId: v.id("canvases"),
nodeId: v.optional(v.id("nodes")),
storageId: v.id("_storage"),
filename: v.optional(v.string()),
mimeType: v.optional(v.string()),
width: v.optional(v.number()),
height: v.optional(v.number()),
},
handler: async (ctx, args) => {
const user = await requireAuth(ctx);
await assertCanvasOwner(ctx, args.canvasId, user.userId);
if (args.nodeId) {
const node = await ctx.db.get(args.nodeId);
if (!node) {
console.warn("[storage.registerUploadedImageMedia] node not found", {
userId: user.userId,
canvasId: args.canvasId,
nodeId: args.nodeId,
storageId: args.storageId,
});
} else if (node.canvasId !== args.canvasId) {
console.warn("[storage.registerUploadedImageMedia] node/canvas mismatch", {
userId: user.userId,
canvasId: args.canvasId,
nodeId: args.nodeId,
nodeCanvasId: node.canvasId,
storageId: args.storageId,
});
}
}
console.info("[storage.registerUploadedImageMedia] acknowledged", {
userId: user.userId,
canvasId: args.canvasId,
nodeId: args.nodeId,
storageId: args.storageId,
filename: args.filename,
mimeType: args.mimeType,
width: args.width,
height: args.height,
});
return { ok: true as const };
},
});
async function listNodesForCanvas(
ctx: QueryCtx | MutationCtx,
canvasId: Id<"canvases">,
@@ -175,10 +269,53 @@ function collectStorageIds(
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) {
ids.add(storageId as Id<"_storage">);
}
if (typeof previewStorageId === "string" && previewStorageId.length > 0) {
ids.add(previewStorageId as Id<"_storage">);
}
}
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;
}