"use node"; // convex/export.ts // // Server-side frame export via jimp (pure JS, no native binaries). // Loads all image nodes within a frame, composites them onto a canvas, // stores the result in Convex Storage, and returns a short-lived download URL. // // Install: pnpm add jimp import { v } from "convex/values"; import { action } from "./_generated/server"; import { api } from "./_generated/api"; import type { Id } from "./_generated/dataModel"; import { Jimp } from "jimp"; export const exportFrame = action({ args: { frameNodeId: v.id("nodes"), }, handler: async (ctx, args) => { const startedAt = Date.now(); const identity = await ctx.auth.getUserIdentity(); if (!identity) throw new Error("Not authenticated"); // ── 1. Load the frame node ───────────────────────────────────────────── const frame = await ctx.runQuery(api.nodes.get, { nodeId: args.frameNodeId, includeStorageUrl: false, }); if (!frame) throw new Error("Frame node not found"); if (frame.type !== "frame") throw new Error("Node is not a frame"); const authorizedCanvas = await ctx.runQuery(api.canvases.get, { canvasId: frame.canvasId, }); if (!authorizedCanvas) { throw new Error("Not authorized for canvas"); } const frameData = frame.data as { label?: string; width?: number; height?: number; }; const exportWidth = frameData.width ?? frame.width ?? 1920; const exportHeight = frameData.height ?? frame.height ?? 1080; const frameX = frame.positionX; const frameY = frame.positionY; // ── 2. Load all nodes in this canvas ─────────────────────────────────── const allNodes = await ctx.runQuery(api.nodes.list, { canvasId: frame.canvasId, }); // Find image/ai-image nodes visually within the frame const imageNodes = allNodes.filter((node: (typeof allNodes)[number]) => { if (node.type !== "image" && node.type !== "ai-image") return false; const data = node.data as { storageId?: string }; if (!data.storageId) return false; const nodeRight = node.positionX + node.width; const nodeBottom = node.positionY + node.height; const frameRight = frameX + exportWidth; const frameBottom = frameY + exportHeight; return ( node.positionX < frameRight && nodeRight > frameX && node.positionY < frameBottom && nodeBottom > frameY ); }); if (imageNodes.length === 0) { throw new Error("No images found within this frame"); } // ── 3. Create base canvas ────────────────────────────────────────────── const base = new Jimp({ width: exportWidth, height: exportHeight, color: 0xffffffff, // white background }); const resolveUrlsAt = Date.now(); const imageNodeUrlEntries = await Promise.all( imageNodes.map(async (node) => { const data = node.data as { storageId: string }; try { const url = await ctx.storage.getUrl(data.storageId as Id<"_storage">); return { nodeId: node._id, url }; } catch (error) { console.warn("[exportFrame] failed to resolve storage URL", { nodeId: node._id, storageId: data.storageId, error: String(error), }); return { nodeId: node._id, url: null }; } }), ); const resolveUrlsDurationMs = Date.now() - resolveUrlsAt; if (resolveUrlsDurationMs >= 250) { console.warn("[exportFrame] slow storage URL resolution", { frameNodeId: args.frameNodeId, imageCount: imageNodes.length, resolvedCount: imageNodeUrlEntries.filter((entry) => entry?.url).length, durationMs: resolveUrlsDurationMs, }); } // ── 4. Fetch, resize and composite each image ────────────────────────── const urlByNodeId = new Map(imageNodeUrlEntries.map((entry) => [entry.nodeId, entry.url])); const resolveImageDataAt = Date.now(); for (const node of imageNodes) { const url = urlByNodeId.get(node._id) ?? null; if (!url) continue; const response = await fetch(url); if (!response.ok) continue; const arrayBuffer = await response.arrayBuffer(); const buffer = Buffer.from(arrayBuffer); const relX = Math.max(0, Math.round(node.positionX - frameX)); const relY = Math.max(0, Math.round(node.positionY - frameY)); const nodeW = Math.round(node.width); const nodeH = Math.round(node.height); const img = await Jimp.fromBuffer(buffer); img.resize({ w: nodeW, h: nodeH }); base.composite(img, relX, relY); } const resolveImageDataDurationMs = Date.now() - resolveImageDataAt; if (resolveImageDataDurationMs >= 250) { console.warn("[exportFrame] slow image download loop", { frameNodeId: args.frameNodeId, durationMs: resolveImageDataDurationMs, }); } // ── 5. Encode to PNG buffer ──────────────────────────────────────────── const outputBuffer = await base.getBuffer("image/png"); // ── 6. Store in Convex Storage ───────────────────────────────────────── const blob = new Blob([new Uint8Array(outputBuffer)], { type: "image/png" }); const storageId = await ctx.storage.store(blob); const downloadUrl = await ctx.storage.getUrl(storageId); if (!downloadUrl) throw new Error("Failed to generate download URL"); const totalDurationMs = Date.now() - startedAt; if (totalDurationMs >= 500) { console.warn("[exportFrame] slow total export execution", { frameNodeId: args.frameNodeId, durationMs: totalDurationMs, }); } return { url: downloadUrl, storageId, filename: `${frameData.label ?? "frame"}-export.png`, }; }, });