diff --git a/src/store/workflowStore.ts b/src/store/workflowStore.ts index 0c99d09c..04f2562f 100644 --- a/src/store/workflowStore.ts +++ b/src/store/workflowStore.ts @@ -28,7 +28,7 @@ import { } from "@/types"; import { useToast } from "@/components/Toast"; import { logger } from "@/utils/logger"; -import { externalizeWorkflowImages, hydrateWorkflowImages } from "@/utils/imageStorage"; +import { externalizeWorkflowMedia, hydrateWorkflowMedia } from "@/utils/mediaStorage"; import { EditOperation, applyEditOperations as executeEditOps } from "@/lib/chat/editOperations"; import { loadSaveConfigs, @@ -1754,13 +1754,13 @@ const workflowStoreImpl: StateCreator = (set, get) => ({ // Determine the workflow directory path (passed in, from saved config, or embedded in legacy workflow JSON) const directoryPath = workflowPath || savedConfig?.directoryPath || workflow.directoryPath || null; - // Hydrate images if we have a directory path and the workflow has image refs + // Hydrate media if we have a directory path and the workflow has media refs let hydratedWorkflow = workflow; if (directoryPath) { try { - hydratedWorkflow = await hydrateWorkflowImages(workflow, directoryPath); + hydratedWorkflow = await hydrateWorkflowMedia(workflow, directoryPath); } catch (error) { - console.error("Failed to hydrate workflow images:", error); + console.error("Failed to hydrate workflow media:", error); // Continue with original workflow if hydration fails } } @@ -1962,9 +1962,9 @@ const workflowStoreImpl: StateCreator = (set, get) => ({ groups: groups && Object.keys(groups).length > 0 ? groups : undefined, }; - // If external image storage is enabled, externalize images before saving + // If external media storage is enabled, externalize media before saving if (useExternalImageStorage) { - workflow = await externalizeWorkflowImages(workflow, saveDirectoryPath); + workflow = await externalizeWorkflowMedia(workflow, saveDirectoryPath); } const response = await fetch("/api/workflow", { @@ -1982,22 +1982,23 @@ const workflowStoreImpl: StateCreator = (set, get) => ({ if (result.success) { const timestamp = Date.now(); - // If we externalized images, update store nodes with the refs - // This prevents duplicate images on subsequent saves + // If we externalized media, update store nodes with the refs + // This prevents duplicate media on subsequent saves if (useExternalImageStorage && workflow.nodes !== currentNodes) { - // Merge refs from externalized nodes into current nodes (keeping image data) + // Merge refs from externalized nodes into current nodes (keeping media data) const nodesWithRefs = currentNodes.map((node, index) => { const externalizedNode = workflow.nodes[index]; if (!externalizedNode || node.id !== externalizedNode.id) { return node; // Safety check - nodes should match } - // Copy refs from externalized node while keeping current image data + // Copy refs from externalized node while keeping current media data // Use type assertion to access ref fields that may exist on various node types const mergedData = { ...node.data } as Record; const extData = externalizedNode.data as Record; // Copy ref fields based on node type + // Image refs if (extData.imageRef && typeof extData.imageRef === 'string') { mergedData.imageRef = extData.imageRef; } @@ -2010,6 +2011,26 @@ const workflowStoreImpl: StateCreator = (set, get) => ({ if (extData.inputImageRefs && Array.isArray(extData.inputImageRefs)) { mergedData.inputImageRefs = extData.inputImageRefs; } + if (extData.imageARef && typeof extData.imageARef === 'string') { + mergedData.imageARef = extData.imageARef; + } + if (extData.imageBRef && typeof extData.imageBRef === 'string') { + mergedData.imageBRef = extData.imageBRef; + } + if (extData.capturedImageRef && typeof extData.capturedImageRef === 'string') { + mergedData.capturedImageRef = extData.capturedImageRef; + } + // Video refs + if (extData.outputVideoRef && typeof extData.outputVideoRef === 'string') { + mergedData.outputVideoRef = extData.outputVideoRef; + } + // Audio refs + if (extData.audioFileRef && typeof extData.audioFileRef === 'string') { + mergedData.audioFileRef = extData.audioFileRef; + } + if (extData.outputAudioRef && typeof extData.outputAudioRef === 'string') { + mergedData.outputAudioRef = extData.outputAudioRef; + } return { ...node, data: mergedData as WorkflowNodeData } as WorkflowNode; }); diff --git a/src/utils/imageStorage.ts b/src/utils/imageStorage.ts deleted file mode 100644 index 5756c7b6..00000000 --- a/src/utils/imageStorage.ts +++ /dev/null @@ -1,585 +0,0 @@ -import { WorkflowNode, WorkflowNodeData } from "@/types"; -import { WorkflowFile } from "@/store/workflowStore"; -import crypto from "crypto"; - -/** - * Fetch with timeout support using AbortController - * @param url - The URL to fetch - * @param options - Fetch options (RequestInit) - * @param timeout - Timeout in milliseconds (default: 30000ms / 30 seconds) - * @returns Promise - * @throws Error if the request times out or fails - */ -async function fetchWithTimeout( - url: string, - options: RequestInit, - timeout: number = 30000 -): Promise { - const controller = new AbortController(); - const timeoutId = setTimeout(() => controller.abort(), timeout); - - try { - const response = await fetch(url, { - ...options, - signal: controller.signal, - }); - return response; - } catch (error) { - if (error instanceof Error && error.name === "AbortError") { - throw new Error(`Request timed out after ${timeout}ms: ${url}`); - } - throw error; - } finally { - clearTimeout(timeoutId); - } -} - -/** - * Compute MD5 hash of image content for deduplication - * Consistent with save-generation API (Phase 13 decision) - */ -function computeContentHash(data: string): string { - return crypto.createHash("md5").update(data).digest("hex"); -} - -/** - * Generate a unique image ID for external storage - */ -export function generateImageId(): string { - const timestamp = Date.now().toString(36); - const random = Math.random().toString(36).substring(2, 8); - return `img-${timestamp}-${random}`; -} - -/** - * Check if a string is a base64 data URL - */ -function isBase64DataUrl(str: string | null | undefined): str is string { - return typeof str === "string" && str.startsWith("data:"); -} - -/** - * Extract and save all images from a workflow, replacing base64 data with refs - * Returns a new workflow object with image refs instead of base64 data - */ -export async function externalizeWorkflowImages( - workflow: WorkflowFile, - workflowPath: string -): Promise { - const savedImageIds = new Map(); // base64 hash -> imageId (for deduplication) - - // Process nodes in parallel batches with controlled concurrency - const BATCH_SIZE = 3; - const externalizedNodes: WorkflowNode[] = new Array(workflow.nodes.length); - - for (let i = 0; i < workflow.nodes.length; i += BATCH_SIZE) { - const batch = workflow.nodes.slice(i, i + BATCH_SIZE); - const results = await Promise.all( - batch.map((node, batchIndex) => - externalizeNodeImages(node, workflowPath, savedImageIds) - .then(result => ({ index: i + batchIndex, result })) - ) - ); - - for (const { index, result } of results) { - externalizedNodes[index] = result; - } - } - - return { - ...workflow, - nodes: externalizedNodes, - }; -} - -/** - * Externalize images from a single node - */ -async function externalizeNodeImages( - node: WorkflowNode, - workflowPath: string, - savedImageIds: Map -): Promise { - const data = node.data as WorkflowNodeData; - let newData: WorkflowNodeData; - - switch (node.type) { - case "imageInput": { - const d = data as import("@/types").ImageInputNodeData; - // Skip if already has a valid imageRef (prevents duplicates on re-save after hydration) - if (d.imageRef && isBase64DataUrl(d.image)) { - newData = { ...d, image: null }; - } else if (isBase64DataUrl(d.image)) { - const imageId = await saveImageAndGetId(d.image, workflowPath, savedImageIds, "inputs"); - newData = { ...d, image: null, imageRef: imageId }; - } else { - newData = d; - } - break; - } - - case "annotation": { - const d = data as import("@/types").AnnotationNodeData; - let sourceImageRef = d.sourceImageRef; - let outputImageRef = d.outputImageRef; - let sourceImage = d.sourceImage; - let outputImage = d.outputImage; - - // Annotation images are user-created, save to inputs - // Skip if already has ref (prevents duplicates on re-save after hydration) - if (d.sourceImageRef && isBase64DataUrl(d.sourceImage)) { - sourceImage = null; - } else if (isBase64DataUrl(d.sourceImage)) { - sourceImageRef = await saveImageAndGetId(d.sourceImage, workflowPath, savedImageIds, "inputs"); - sourceImage = null; - } - if (d.outputImageRef && isBase64DataUrl(d.outputImage)) { - outputImage = null; - } else if (isBase64DataUrl(d.outputImage)) { - outputImageRef = await saveImageAndGetId(d.outputImage, workflowPath, savedImageIds, "inputs"); - outputImage = null; - } - - newData = { - ...d, - sourceImage, - sourceImageRef, - outputImage, - outputImageRef, - }; - break; - } - - case "nanoBanana": { - const d = data as import("@/types").NanoBananaNodeData; - let outputImageRef = d.outputImageRef; - let outputImage = d.outputImage; - let inputImageRefs = d.inputImageRefs ? [...d.inputImageRefs] : []; - const inputImages: string[] = []; - - // Handle output image - AI generated, save to generations - // Use selectedHistoryIndex to get the correct history entry (not hardcoded 0) - const selectedIndex = d.selectedHistoryIndex || 0; - const expectedRef = d.imageHistory?.[selectedIndex]?.id; - - if (d.outputImageRef && isBase64DataUrl(d.outputImage)) { - // Verify existing ref matches expected history ID - if (d.outputImageRef === expectedRef) { - outputImage = null; // Ref is correct, just clear base64 - } else { - // Ref doesn't match history - re-save with correct ID - outputImageRef = await saveImageAndGetId(d.outputImage, workflowPath, savedImageIds, "generations", expectedRef); - outputImage = null; - } - } else if (isBase64DataUrl(d.outputImage)) { - // No existing ref - save with expected history ID for consistency - outputImageRef = await saveImageAndGetId(d.outputImage, workflowPath, savedImageIds, "generations", expectedRef); - outputImage = null; - } - - // Handle input images array (these come from connected nodes, save to inputs if present) - // Skip if corresponding inputImageRef already exists - for (let i = 0; i < (d.inputImages?.length || 0); i++) { - const img = d.inputImages[i]; - const existingRef = d.inputImageRefs?.[i]; - if (existingRef && isBase64DataUrl(img)) { - inputImages.push(""); // Already has ref, just clear the base64 - } else if (isBase64DataUrl(img)) { - const ref = await saveImageAndGetId(img, workflowPath, savedImageIds, "inputs"); - inputImageRefs[i] = ref; - inputImages.push(""); // Empty placeholder - } else { - inputImages.push(img); - } - } - - newData = { - ...d, - inputImages: inputImages.length > 0 && inputImages.every(i => i === "") ? [] : inputImages, - inputImageRefs: inputImageRefs.length > 0 ? inputImageRefs : undefined, - outputImage, - outputImageRef, - }; - break; - } - - case "llmGenerate": { - const d = data as import("@/types").LLMGenerateNodeData; - let inputImageRefs = d.inputImageRefs ? [...d.inputImageRefs] : []; - const inputImages: string[] = []; - - // Handle input images array (save to inputs) - // Skip if corresponding inputImageRef already exists - for (let i = 0; i < (d.inputImages?.length || 0); i++) { - const img = d.inputImages[i]; - const existingRef = d.inputImageRefs?.[i]; - if (existingRef && isBase64DataUrl(img)) { - inputImages.push(""); // Already has ref, just clear the base64 - } else if (isBase64DataUrl(img)) { - const ref = await saveImageAndGetId(img, workflowPath, savedImageIds, "inputs"); - inputImageRefs[i] = ref; - inputImages.push(""); // Empty placeholder - } else { - inputImages.push(img); - } - } - - newData = { - ...d, - inputImages: inputImages.length > 0 && inputImages.every(i => i === "") ? [] : inputImages, - inputImageRefs: inputImageRefs.length > 0 ? inputImageRefs : undefined, - }; - break; - } - - case "generateVideo": { - const d = data as import("@/types").GenerateVideoNodeData; - let inputImageRefs = d.inputImageRefs ? [...d.inputImageRefs] : []; - const inputImages: string[] = []; - - // Handle input images array (save to inputs) - // Skip if corresponding inputImageRef already exists - for (let i = 0; i < (d.inputImages?.length || 0); i++) { - const img = d.inputImages[i]; - const existingRef = d.inputImageRefs?.[i]; - if (existingRef && isBase64DataUrl(img)) { - inputImages.push(""); // Already has ref, just clear the base64 - } else if (isBase64DataUrl(img)) { - const ref = await saveImageAndGetId(img, workflowPath, savedImageIds, "inputs"); - inputImageRefs[i] = ref; - inputImages.push(""); // Empty placeholder - } else { - inputImages.push(img); - } - } - - // Note: outputVideo is a video URL, not saved as an image - newData = { - ...d, - inputImages: inputImages.length > 0 && inputImages.every(i => i === "") ? [] : inputImages, - inputImageRefs: inputImageRefs.length > 0 ? inputImageRefs : undefined, - }; - break; - } - - case "output": { - const d = data as import("@/types").OutputNodeData; - // Output content is saved to /outputs during workflow execution, not here - // Clear image data to keep workflow file small - outputs are regenerated on each run - newData = { ...d, image: null, imageRef: undefined, video: null }; - break; - } - - case "outputGallery": { - const d = data as import("@/types").OutputGalleryNodeData; - // OutputGallery content is regenerated on each workflow run - // Clear images array to keep workflow file small - newData = { ...d, images: [] }; - break; - } - - case "splitGrid": { - const d = data as import("@/types").SplitGridNodeData; - // SplitGrid source is input content, save to inputs - // Skip if already has ref (prevents duplicates on re-save after hydration) - if (d.sourceImageRef && isBase64DataUrl(d.sourceImage)) { - newData = { ...d, sourceImage: null }; - } else if (isBase64DataUrl(d.sourceImage)) { - const imageId = await saveImageAndGetId(d.sourceImage, workflowPath, savedImageIds, "inputs"); - newData = { ...d, sourceImage: null, sourceImageRef: imageId }; - } else { - newData = d; - } - break; - } - - default: - newData = data; - } - - return { - ...node, - data: newData, - } as WorkflowNode; -} - -// In-flight saves guard to prevent duplicate concurrent uploads of the same image -const inFlightSaves = new Map>(); - -/** - * Save an image and return its ID (with deduplication) - * @param folder - "inputs" for user-uploaded images, "generations" for AI-generated images - * @param existingId - Optional ID to use instead of generating a new one (for consistency with history) - */ -async function saveImageAndGetId( - imageData: string, - workflowPath: string, - savedImageIds: Map, - folder: "inputs" | "generations" = "inputs", - existingId?: string -): Promise { - // Use MD5 hash for reliable deduplication (consistent with save-generation API, Phase 13 decision) - // Include folder in hash so same image in different folders gets different IDs - const hash = `${folder}-${computeContentHash(imageData)}`; - - // Skip deduplication if an explicit ID is requested - we must use that exact ID - // to maintain consistency with imageHistory. Otherwise, deduplicate by content. - if (!existingId && savedImageIds.has(hash)) { - return savedImageIds.get(hash)!; - } - - // Check if there's already an in-flight save for this hash - if (!existingId && inFlightSaves.has(hash)) { - return inFlightSaves.get(hash)!; - } - - // Use existing ID if provided (for consistency with imageHistory), otherwise generate new - const imageId = existingId || generateImageId(); - - const savePromise = (async () => { - const response = await fetchWithTimeout( - "/api/workflow-images", - { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ - workflowPath, - imageId, - imageData, - folder, - }), - } - ); - - const result = await response.json(); - - if (!result.success) { - throw new Error(`Failed to save image: ${result.error}`); - } - - savedImageIds.set(hash, imageId); - return imageId; - })(); - - if (!existingId) { - inFlightSaves.set(hash, savePromise); - } - - try { - return await savePromise; - } catch (error) { - throw error; - } finally { - inFlightSaves.delete(hash); - } -} - -/** - * Load all external images into a workflow, replacing refs with base64 data - * Returns a new workflow object with base64 data instead of refs - */ -export async function hydrateWorkflowImages( - workflow: WorkflowFile, - workflowPath: string -): Promise { - const hydratedNodes: WorkflowNode[] = []; - const loadedImages = new Map(); // imageId -> base64 (for caching) - - for (const node of workflow.nodes) { - const newNode = await hydrateNodeImages(node, workflowPath, loadedImages); - hydratedNodes.push(newNode); - } - - return { - ...workflow, - nodes: hydratedNodes, - }; -} - -/** - * Hydrate images for a single node - */ -async function hydrateNodeImages( - node: WorkflowNode, - workflowPath: string, - loadedImages: Map -): Promise { - const data = node.data as WorkflowNodeData; - let newData: WorkflowNodeData; - - switch (node.type) { - case "imageInput": { - const d = data as import("@/types").ImageInputNodeData; - if (d.imageRef && !d.image) { - const image = await loadImageById(d.imageRef, workflowPath, loadedImages, "inputs"); - newData = { - ...d, - image, - }; - } else { - newData = d; - } - break; - } - - case "annotation": { - const d = data as import("@/types").AnnotationNodeData; - let sourceImage = d.sourceImage; - let outputImage = d.outputImage; - - if (d.sourceImageRef && !d.sourceImage) { - sourceImage = await loadImageById(d.sourceImageRef, workflowPath, loadedImages, "inputs"); - } - if (d.outputImageRef && !d.outputImage) { - outputImage = await loadImageById(d.outputImageRef, workflowPath, loadedImages, "inputs"); - } - - newData = { - ...d, - sourceImage, - outputImage, - }; - break; - } - - case "nanoBanana": { - const d = data as import("@/types").NanoBananaNodeData; - let outputImage = d.outputImage; - const inputImages = [...(d.inputImages || [])]; - - if (d.outputImageRef && !d.outputImage) { - outputImage = await loadImageById(d.outputImageRef, workflowPath, loadedImages, "generations"); - } - - // Hydrate input images from refs - if (d.inputImageRefs && d.inputImageRefs.length > 0) { - for (let i = 0; i < d.inputImageRefs.length; i++) { - const ref = d.inputImageRefs[i]; - if (ref) { - inputImages[i] = await loadImageById(ref, workflowPath, loadedImages, "inputs"); - } - } - } - - newData = { - ...d, - inputImages, - outputImage, - }; - break; - } - - case "llmGenerate": { - const d = data as import("@/types").LLMGenerateNodeData; - const inputImages = [...(d.inputImages || [])]; - - // Hydrate input images from refs - if (d.inputImageRefs && d.inputImageRefs.length > 0) { - for (let i = 0; i < d.inputImageRefs.length; i++) { - const ref = d.inputImageRefs[i]; - if (ref) { - inputImages[i] = await loadImageById(ref, workflowPath, loadedImages, "inputs"); - } - } - } - - newData = { - ...d, - inputImages, - }; - break; - } - - case "generateVideo": { - const d = data as import("@/types").GenerateVideoNodeData; - const inputImages = [...(d.inputImages || [])]; - - // Hydrate input images from refs - if (d.inputImageRefs && d.inputImageRefs.length > 0) { - for (let i = 0; i < d.inputImageRefs.length; i++) { - const ref = d.inputImageRefs[i]; - if (ref) { - inputImages[i] = await loadImageById(ref, workflowPath, loadedImages, "inputs"); - } - } - } - - newData = { - ...d, - inputImages, - }; - break; - } - - case "output": { - // Output content is not persisted - it's regenerated on each workflow run - // and saved to /outputs directory during execution - newData = data; - break; - } - - case "outputGallery": { - // OutputGallery content is not persisted - it's regenerated on each workflow run - newData = data; - break; - } - - case "splitGrid": { - const d = data as import("@/types").SplitGridNodeData; - if (d.sourceImageRef && !d.sourceImage) { - const sourceImage = await loadImageById(d.sourceImageRef, workflowPath, loadedImages, "inputs"); - newData = { - ...d, - sourceImage, - }; - } else { - newData = d; - } - break; - } - - default: - newData = data; - } - - return { - ...node, - data: newData, - } as WorkflowNode; -} - -/** - * Load an image by ID (with caching) - * @param folder - Optional hint for which folder to check first - */ -async function loadImageById( - imageId: string, - workflowPath: string, - loadedImages: Map, - folder?: "inputs" | "generations" -): Promise { - if (loadedImages.has(imageId)) { - return loadedImages.get(imageId)!; - } - - const params = new URLSearchParams({ - workflowPath, - imageId, - }); - if (folder) { - params.set("folder", folder); - } - - const response = await fetch(`/api/workflow-images?${params.toString()}`); - - const result = await response.json(); - - if (!result.success) { - // Missing images are expected when refs point to deleted/moved files - console.log(`Image not found: ${imageId}`); - return ""; // Return empty string to avoid breaking the workflow - } - - loadedImages.set(imageId, result.image); - return result.image; -} diff --git a/src/utils/mediaStorage.ts b/src/utils/mediaStorage.ts new file mode 100644 index 00000000..79f8c4e7 --- /dev/null +++ b/src/utils/mediaStorage.ts @@ -0,0 +1,1058 @@ +import { WorkflowNode, WorkflowNodeData } from "@/types"; +import { WorkflowFile } from "@/store/workflowStore"; +import crypto from "crypto"; + +/** + * Fetch with timeout support using AbortController + * @param url - The URL to fetch + * @param options - Fetch options (RequestInit) + * @param timeout - Timeout in milliseconds (default: 30000ms / 30 seconds) + * @returns Promise + * @throws Error if the request times out or fails + */ +async function fetchWithTimeout( + url: string, + options: RequestInit, + timeout: number = 30000 +): Promise { + const controller = new AbortController(); + const timeoutId = setTimeout(() => controller.abort(), timeout); + + try { + const response = await fetch(url, { + ...options, + signal: controller.signal, + }); + return response; + } catch (error) { + if (error instanceof Error && error.name === "AbortError") { + throw new Error(`Request timed out after ${timeout}ms: ${url}`); + } + throw error; + } finally { + clearTimeout(timeoutId); + } +} + +/** + * Compute MD5 hash of content for deduplication + * Consistent with save-generation API + */ +function computeContentHash(data: string): string { + return crypto.createHash("md5").update(data).digest("hex"); +} + +/** + * Generate a unique media ID for external storage + */ +export function generateMediaId(prefix: "img" | "vid" | "aud" = "img"): string { + const timestamp = Date.now().toString(36); + const random = Math.random().toString(36).substring(2, 8); + return `${prefix}-${timestamp}-${random}`; +} + +/** + * Generate a unique image ID for external storage (backward compat) + */ +export function generateImageId(): string { + return generateMediaId("img"); +} + +/** + * Check if a string is an HTTP/HTTPS URL + */ +function isHttpUrl(str: string | null | undefined): boolean { + return typeof str === "string" && (str.startsWith("http://") || str.startsWith("https://")); +} + +/** + * Check if a string is a base64 data URL (any type) + */ +function isDataUrl(str: string | null | undefined): str is string { + return typeof str === "string" && str.startsWith("data:"); +} + +/** + * Legacy alias for isDataUrl (for backward compatibility) + */ +function isBase64DataUrl(str: string | null | undefined): str is string { + return isDataUrl(str); +} + +/** + * Extract and save all media from a workflow, replacing base64 data with refs + * Returns a new workflow object with media refs instead of base64 data + */ +export async function externalizeWorkflowMedia( + workflow: WorkflowFile, + workflowPath: string +): Promise { + const savedImageIds = new Map(); // base64 hash -> imageId (for deduplication) + const savedMediaIds = new Map(); // base64 hash -> mediaId (for video/audio deduplication) + + // Process nodes in parallel batches with controlled concurrency + const BATCH_SIZE = 3; + const externalizedNodes: WorkflowNode[] = new Array(workflow.nodes.length); + + for (let i = 0; i < workflow.nodes.length; i += BATCH_SIZE) { + const batch = workflow.nodes.slice(i, i + BATCH_SIZE); + const results = await Promise.all( + batch.map((node, batchIndex) => + externalizeNodeMedia(node, workflowPath, savedImageIds, savedMediaIds) + .then(result => ({ index: i + batchIndex, result })) + ) + ); + + for (const { index, result } of results) { + externalizedNodes[index] = result; + } + } + + return { + ...workflow, + nodes: externalizedNodes, + }; +} + +/** + * Legacy function name for backward compatibility + */ +export async function externalizeWorkflowImages( + workflow: WorkflowFile, + workflowPath: string +): Promise { + return externalizeWorkflowMedia(workflow, workflowPath); +} + +/** + * Externalize media from a single node + */ +async function externalizeNodeMedia( + node: WorkflowNode, + workflowPath: string, + savedImageIds: Map, + savedMediaIds: Map +): Promise { + const data = node.data as WorkflowNodeData; + let newData: WorkflowNodeData; + + switch (node.type) { + case "imageInput": { + const d = data as import("@/types").ImageInputNodeData; + // Skip if already has a valid imageRef (prevents duplicates on re-save after hydration) + if (d.imageRef && isDataUrl(d.image)) { + newData = { ...d, image: null }; + } else if (isDataUrl(d.image)) { + const imageId = await saveImageAndGetId(d.image, workflowPath, savedImageIds, "inputs"); + newData = { ...d, image: null, imageRef: imageId }; + } else { + newData = d; + } + break; + } + + case "audioInput": { + const d = data as import("@/types").AudioInputNodeData; + // Externalize base64 audio data + if (d.audioFileRef && isDataUrl(d.audioFile)) { + newData = { ...d, audioFile: null }; + } else if (isDataUrl(d.audioFile)) { + const audioRef = await saveAudioAndGetRef(d.audioFile, workflowPath, savedMediaIds); + newData = { ...d, audioFile: null, audioFileRef: audioRef }; + } else { + newData = d; + } + break; + } + + case "annotation": { + const d = data as import("@/types").AnnotationNodeData; + let sourceImageRef = d.sourceImageRef; + let outputImageRef = d.outputImageRef; + let sourceImage = d.sourceImage; + let outputImage = d.outputImage; + + // Annotation images are user-created, save to inputs + // Skip if already has ref (prevents duplicates on re-save after hydration) + if (d.sourceImageRef && isDataUrl(d.sourceImage)) { + sourceImage = null; + } else if (isDataUrl(d.sourceImage)) { + sourceImageRef = await saveImageAndGetId(d.sourceImage, workflowPath, savedImageIds, "inputs"); + sourceImage = null; + } + if (d.outputImageRef && isDataUrl(d.outputImage)) { + outputImage = null; + } else if (isDataUrl(d.outputImage)) { + outputImageRef = await saveImageAndGetId(d.outputImage, workflowPath, savedImageIds, "inputs"); + outputImage = null; + } + + newData = { + ...d, + sourceImage, + sourceImageRef, + outputImage, + outputImageRef, + }; + break; + } + + case "nanoBanana": { + const d = data as import("@/types").NanoBananaNodeData; + let outputImageRef = d.outputImageRef; + let outputImage = d.outputImage; + let inputImageRefs = d.inputImageRefs ? [...d.inputImageRefs] : []; + const inputImages: string[] = []; + + // Handle output image - AI generated, save to generations + // Use selectedHistoryIndex to get the correct history entry (not hardcoded 0) + const selectedIndex = d.selectedHistoryIndex || 0; + const expectedRef = d.imageHistory?.[selectedIndex]?.id; + + if (d.outputImageRef && isDataUrl(d.outputImage)) { + // Verify existing ref matches expected history ID + if (d.outputImageRef === expectedRef) { + outputImage = null; // Ref is correct, just clear base64 + } else { + // Ref doesn't match history - re-save with correct ID + outputImageRef = await saveImageAndGetId(d.outputImage, workflowPath, savedImageIds, "generations", expectedRef); + outputImage = null; + } + } else if (isDataUrl(d.outputImage)) { + // No existing ref - save with expected history ID for consistency + outputImageRef = await saveImageAndGetId(d.outputImage, workflowPath, savedImageIds, "generations", expectedRef); + outputImage = null; + } + + // Handle input images array (these come from connected nodes, save to inputs if present) + // Skip if corresponding inputImageRef already exists + for (let i = 0; i < (d.inputImages?.length || 0); i++) { + const img = d.inputImages[i]; + const existingRef = d.inputImageRefs?.[i]; + if (existingRef && isDataUrl(img)) { + inputImages.push(""); // Already has ref, just clear the base64 + } else if (isDataUrl(img)) { + const ref = await saveImageAndGetId(img, workflowPath, savedImageIds, "inputs"); + inputImageRefs[i] = ref; + inputImages.push(""); // Empty placeholder + } else { + inputImages.push(img); + } + } + + newData = { + ...d, + inputImages: inputImages.length > 0 && inputImages.every(i => i === "") ? [] : inputImages, + inputImageRefs: inputImageRefs.length > 0 ? inputImageRefs : undefined, + outputImage, + outputImageRef, + }; + break; + } + + case "llmGenerate": { + const d = data as import("@/types").LLMGenerateNodeData; + let inputImageRefs = d.inputImageRefs ? [...d.inputImageRefs] : []; + const inputImages: string[] = []; + + // Handle input images array (save to inputs) + // Skip if corresponding inputImageRef already exists + for (let i = 0; i < (d.inputImages?.length || 0); i++) { + const img = d.inputImages[i]; + const existingRef = d.inputImageRefs?.[i]; + if (existingRef && isDataUrl(img)) { + inputImages.push(""); // Already has ref, just clear the base64 + } else if (isDataUrl(img)) { + const ref = await saveImageAndGetId(img, workflowPath, savedImageIds, "inputs"); + inputImageRefs[i] = ref; + inputImages.push(""); // Empty placeholder + } else { + inputImages.push(img); + } + } + + newData = { + ...d, + inputImages: inputImages.length > 0 && inputImages.every(i => i === "") ? [] : inputImages, + inputImageRefs: inputImageRefs.length > 0 ? inputImageRefs : undefined, + }; + break; + } + + case "generateVideo": { + const d = data as import("@/types").GenerateVideoNodeData; + let inputImageRefs = d.inputImageRefs ? [...d.inputImageRefs] : []; + const inputImages: string[] = []; + let outputVideoRef = d.outputVideoRef; + let outputVideo = d.outputVideo; + + // Handle input images array (save to inputs) + // Skip if corresponding inputImageRef already exists + for (let i = 0; i < (d.inputImages?.length || 0); i++) { + const img = d.inputImages[i]; + const existingRef = d.inputImageRefs?.[i]; + if (existingRef && isDataUrl(img)) { + inputImages.push(""); // Already has ref, just clear the base64 + } else if (isDataUrl(img)) { + const ref = await saveImageAndGetId(img, workflowPath, savedImageIds, "inputs"); + inputImageRefs[i] = ref; + inputImages.push(""); // Empty placeholder + } else { + inputImages.push(img); + } + } + + // Handle output video - KEEP HTTP URLs for compatibility, externalize base64 + if (d.outputVideo) { + if (isHttpUrl(d.outputVideo)) { + // KEEP HTTP URLs - small size, preserves CDN references + outputVideo = d.outputVideo; // Keep as-is + outputVideoRef = undefined; // No ref needed + } else if (d.outputVideoRef && isDataUrl(d.outputVideo)) { + // Already has ref, just clear base64 + outputVideo = null; + } else if (isDataUrl(d.outputVideo)) { + const selectedIndex = d.selectedVideoHistoryIndex || 0; + const expectedRef = d.videoHistory?.[selectedIndex]?.id; + outputVideoRef = await saveVideoAndGetRef(d.outputVideo, workflowPath, savedMediaIds, expectedRef); + outputVideo = null; + } + } + + newData = { + ...d, + inputImages: inputImages.length > 0 && inputImages.every(i => i === "") ? [] : inputImages, + inputImageRefs: inputImageRefs.length > 0 ? inputImageRefs : undefined, + outputVideo, + outputVideoRef, + }; + break; + } + + case "generate3d": { + const d = data as import("@/types").Generate3DNodeData; + let inputImageRefs = d.inputImageRefs ? [...d.inputImageRefs] : []; + const inputImages: string[] = []; + + // Handle input images array (same pattern as generateVideo) + for (let i = 0; i < (d.inputImages?.length || 0); i++) { + const img = d.inputImages[i]; + const existingRef = d.inputImageRefs?.[i]; + if (existingRef && isDataUrl(img)) { + inputImages.push(""); + } else if (isDataUrl(img)) { + const ref = await saveImageAndGetId(img, workflowPath, savedImageIds, "inputs"); + inputImageRefs[i] = ref; + inputImages.push(""); + } else { + inputImages.push(img); + } + } + + newData = { + ...d, + inputImages: inputImages.length > 0 && inputImages.every(i => i === "") ? [] : inputImages, + inputImageRefs: inputImageRefs.length > 0 ? inputImageRefs : undefined, + }; + break; + } + + case "generateAudio": { + const d = data as import("@/types").GenerateAudioNodeData; + let outputAudioRef = d.outputAudioRef; + let outputAudio = d.outputAudio; + + // Handle output audio - KEEP HTTP URLs for compatibility, externalize base64 + if (d.outputAudio) { + if (isHttpUrl(d.outputAudio)) { + // KEEP HTTP URLs - small size, preserves CDN references + outputAudio = d.outputAudio; // Keep as-is + outputAudioRef = undefined; // No ref needed + } else if (d.outputAudioRef && isDataUrl(d.outputAudio)) { + // Already has ref, just clear base64 + outputAudio = null; + } else if (isDataUrl(d.outputAudio)) { + const selectedIndex = d.selectedAudioHistoryIndex || 0; + const expectedRef = d.audioHistory?.[selectedIndex]?.id; + outputAudioRef = await saveAudioAndGetRef(d.outputAudio, workflowPath, savedMediaIds, expectedRef); + outputAudio = null; + } + } + + newData = { + ...d, + outputAudio, + outputAudioRef, + }; + break; + } + + case "output": { + const d = data as import("@/types").OutputNodeData; + // Output content is saved to /outputs during workflow execution, not here + // Clear image data to keep workflow file small - outputs are regenerated on each run + newData = { ...d, image: null, imageRef: undefined, video: null, audio: null }; + break; + } + + case "outputGallery": { + const d = data as import("@/types").OutputGalleryNodeData; + // OutputGallery content is regenerated on each workflow run + // Clear images array to keep workflow file small + newData = { ...d, images: [] }; + break; + } + + case "imageCompare": { + const d = data as import("@/types").ImageCompareNodeData; + let imageA = d.imageA; + let imageB = d.imageB; + let imageARef = d.imageARef; + let imageBRef = d.imageBRef; + + if (d.imageARef && isDataUrl(d.imageA)) { + imageA = null; + } else if (isDataUrl(d.imageA)) { + imageARef = await saveImageAndGetId(d.imageA, workflowPath, savedImageIds, "inputs"); + imageA = null; + } + + if (d.imageBRef && isDataUrl(d.imageB)) { + imageB = null; + } else if (isDataUrl(d.imageB)) { + imageBRef = await saveImageAndGetId(d.imageB, workflowPath, savedImageIds, "inputs"); + imageB = null; + } + + newData = { + ...d, + imageA, + imageB, + imageARef, + imageBRef, + }; + break; + } + + case "videoStitch": { + const d = data as import("@/types").VideoStitchNodeData; + // Clear output video and all thumbnails (transient/derived content) + newData = { + ...d, + outputVideo: null, + clips: d.clips.map(clip => ({ ...clip, thumbnail: null })), + }; + break; + } + + case "easeCurve": { + const d = data as import("@/types").EaseCurveNodeData; + // Clear output video (derived from input video) + newData = { ...d, outputVideo: null }; + break; + } + + case "videoTrim": { + const d = data as import("@/types").VideoTrimNodeData; + // Clear output video (derived from input video) + newData = { ...d, outputVideo: null }; + break; + } + + case "videoFrameGrab": { + const d = data as import("@/types").VideoFrameGrabNodeData; + // Clear output image (derived from input video) + newData = { ...d, outputImage: null }; + break; + } + + case "glbViewer": { + const d = data as import("@/types").GLBViewerNodeData; + // Externalize captured viewport image + if (d.capturedImageRef && isDataUrl(d.capturedImage)) { + newData = { ...d, capturedImage: null }; + } else if (isDataUrl(d.capturedImage)) { + const imageId = await saveImageAndGetId(d.capturedImage, workflowPath, savedImageIds, "inputs"); + newData = { ...d, capturedImage: null, capturedImageRef: imageId }; + } else { + newData = d; + } + break; + } + + case "splitGrid": { + const d = data as import("@/types").SplitGridNodeData; + // SplitGrid source is input content, save to inputs + // Skip if already has ref (prevents duplicates on re-save after hydration) + if (d.sourceImageRef && isDataUrl(d.sourceImage)) { + newData = { ...d, sourceImage: null }; + } else if (isDataUrl(d.sourceImage)) { + const imageId = await saveImageAndGetId(d.sourceImage, workflowPath, savedImageIds, "inputs"); + newData = { ...d, sourceImage: null, sourceImageRef: imageId }; + } else { + newData = d; + } + break; + } + + default: + newData = data; + } + + return { + ...node, + data: newData, + } as WorkflowNode; +} + +// In-flight saves guard to prevent duplicate concurrent uploads of the same media +const inFlightSaves = new Map>(); + +/** + * Save an image and return its ID (with deduplication) + * @param folder - "inputs" for user-uploaded images, "generations" for AI-generated images + * @param existingId - Optional ID to use instead of generating a new one (for consistency with history) + */ +async function saveImageAndGetId( + imageData: string, + workflowPath: string, + savedImageIds: Map, + folder: "inputs" | "generations" = "inputs", + existingId?: string +): Promise { + // Use MD5 hash for reliable deduplication (consistent with save-generation API, Phase 13 decision) + // Include folder in hash so same image in different folders gets different IDs + const hash = `${folder}-${computeContentHash(imageData)}`; + + // Skip deduplication if an explicit ID is requested - we must use that exact ID + // to maintain consistency with imageHistory. Otherwise, deduplicate by content. + if (!existingId && savedImageIds.has(hash)) { + return savedImageIds.get(hash)!; + } + + // Check if there's already an in-flight save for this hash + if (!existingId && inFlightSaves.has(hash)) { + return inFlightSaves.get(hash)!; + } + + // Use existing ID if provided (for consistency with imageHistory), otherwise generate new + const imageId = existingId || generateImageId(); + + const savePromise = (async () => { + const response = await fetchWithTimeout( + "/api/workflow-images", + { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + workflowPath, + imageId, + imageData, + folder, + }), + } + ); + + const result = await response.json(); + + if (!result.success) { + throw new Error(`Failed to save image: ${result.error}`); + } + + savedImageIds.set(hash, imageId); + return imageId; + })(); + + if (!existingId) { + inFlightSaves.set(hash, savePromise); + } + + try { + return await savePromise; + } catch (error) { + throw error; + } finally { + inFlightSaves.delete(hash); + } +} + +/** + * Save video and return its ref (base64 only, preserve HTTP URLs) + * @returns ref ID if saved, null otherwise + */ +async function saveVideoAndGetRef( + videoData: string, + workflowPath: string, + savedMediaIds: Map, + existingId?: string +): Promise { + // Only externalize base64 data URLs + if (!isDataUrl(videoData)) { + return null; + } + + const hash = `video-${computeContentHash(videoData)}`; + + // Skip deduplication if an explicit ID is requested + if (!existingId && savedMediaIds.has(hash)) { + return savedMediaIds.get(hash)!; + } + + if (!existingId && inFlightSaves.has(hash)) { + return inFlightSaves.get(hash)!; + } + + const videoId = existingId || generateMediaId("vid"); + + const savePromise = (async () => { + const response = await fetchWithTimeout( + "/api/save-generation", + { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + workflowPath, + id: videoId, + data: videoData, + type: "video", + }), + }, + 60000 // 60s timeout for larger video files + ); + + const result = await response.json(); + + if (!result.success) { + throw new Error(`Failed to save video: ${result.error}`); + } + + savedMediaIds.set(hash, videoId); + return videoId; + })(); + + if (!existingId) { + inFlightSaves.set(hash, savePromise); + } + + try { + return await savePromise; + } catch (error) { + throw error; + } finally { + inFlightSaves.delete(hash); + } +} + +/** + * Save audio and return its ref (base64 only, preserve HTTP URLs) + * @returns ref ID if saved, null otherwise + */ +async function saveAudioAndGetRef( + audioData: string, + workflowPath: string, + savedMediaIds: Map, + existingId?: string +): Promise { + // Only externalize base64 data URLs + if (!isDataUrl(audioData)) { + return null; + } + + const hash = `audio-${computeContentHash(audioData)}`; + + // Skip deduplication if an explicit ID is requested + if (!existingId && savedMediaIds.has(hash)) { + return savedMediaIds.get(hash)!; + } + + if (!existingId && inFlightSaves.has(hash)) { + return inFlightSaves.get(hash)!; + } + + const audioId = existingId || generateMediaId("aud"); + + const savePromise = (async () => { + const response = await fetchWithTimeout( + "/api/save-generation", + { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + workflowPath, + id: audioId, + data: audioData, + type: "audio", + }), + }, + 60000 // 60s timeout for larger audio files + ); + + const result = await response.json(); + + if (!result.success) { + throw new Error(`Failed to save audio: ${result.error}`); + } + + savedMediaIds.set(hash, audioId); + return audioId; + })(); + + if (!existingId) { + inFlightSaves.set(hash, savePromise); + } + + try { + return await savePromise; + } catch (error) { + throw error; + } finally { + inFlightSaves.delete(hash); + } +} + +/** + * Load all external media into a workflow, replacing refs with base64 data + * Returns a new workflow object with base64 data instead of refs + */ +export async function hydrateWorkflowMedia( + workflow: WorkflowFile, + workflowPath: string +): Promise { + const hydratedNodes: WorkflowNode[] = []; + const loadedMedia = new Map(); // mediaId -> base64 (for caching) + + for (const node of workflow.nodes) { + const newNode = await hydrateNodeMedia(node, workflowPath, loadedMedia); + hydratedNodes.push(newNode); + } + + return { + ...workflow, + nodes: hydratedNodes, + }; +} + +/** + * Legacy function name for backward compatibility + */ +export async function hydrateWorkflowImages( + workflow: WorkflowFile, + workflowPath: string +): Promise { + return hydrateWorkflowMedia(workflow, workflowPath); +} + +/** + * Hydrate media for a single node + */ +async function hydrateNodeMedia( + node: WorkflowNode, + workflowPath: string, + loadedMedia: Map +): Promise { + const data = node.data as WorkflowNodeData; + let newData: WorkflowNodeData; + + switch (node.type) { + case "imageInput": { + const d = data as import("@/types").ImageInputNodeData; + if (d.imageRef && !d.image) { + const image = await loadMediaById(d.imageRef, workflowPath, loadedMedia, "image"); + newData = { + ...d, + image, + }; + } else { + newData = d; + } + break; + } + + case "audioInput": { + const d = data as import("@/types").AudioInputNodeData; + if (d.audioFileRef && !d.audioFile) { + const audioFile = await loadMediaById(d.audioFileRef, workflowPath, loadedMedia, "audio"); + newData = { + ...d, + audioFile, + }; + } else { + newData = d; + } + break; + } + + case "annotation": { + const d = data as import("@/types").AnnotationNodeData; + let sourceImage = d.sourceImage; + let outputImage = d.outputImage; + + if (d.sourceImageRef && !d.sourceImage) { + sourceImage = await loadMediaById(d.sourceImageRef, workflowPath, loadedMedia, "image"); + } + if (d.outputImageRef && !d.outputImage) { + outputImage = await loadMediaById(d.outputImageRef, workflowPath, loadedMedia, "image"); + } + + newData = { + ...d, + sourceImage, + outputImage, + }; + break; + } + + case "nanoBanana": { + const d = data as import("@/types").NanoBananaNodeData; + let outputImage = d.outputImage; + const inputImages = [...(d.inputImages || [])]; + + if (d.outputImageRef && !d.outputImage) { + outputImage = await loadMediaById(d.outputImageRef, workflowPath, loadedMedia, "image"); + } + + // Hydrate input images from refs + if (d.inputImageRefs && d.inputImageRefs.length > 0) { + for (let i = 0; i < d.inputImageRefs.length; i++) { + const ref = d.inputImageRefs[i]; + if (ref) { + inputImages[i] = await loadMediaById(ref, workflowPath, loadedMedia, "image"); + } + } + } + + newData = { + ...d, + inputImages, + outputImage, + }; + break; + } + + case "llmGenerate": { + const d = data as import("@/types").LLMGenerateNodeData; + const inputImages = [...(d.inputImages || [])]; + + // Hydrate input images from refs + if (d.inputImageRefs && d.inputImageRefs.length > 0) { + for (let i = 0; i < d.inputImageRefs.length; i++) { + const ref = d.inputImageRefs[i]; + if (ref) { + inputImages[i] = await loadMediaById(ref, workflowPath, loadedMedia, "image"); + } + } + } + + newData = { + ...d, + inputImages, + }; + break; + } + + case "generateVideo": { + const d = data as import("@/types").GenerateVideoNodeData; + const inputImages = [...(d.inputImages || [])]; + let outputVideo = d.outputVideo; + + // Hydrate input images from refs + if (d.inputImageRefs && d.inputImageRefs.length > 0) { + for (let i = 0; i < d.inputImageRefs.length; i++) { + const ref = d.inputImageRefs[i]; + if (ref) { + inputImages[i] = await loadMediaById(ref, workflowPath, loadedMedia, "image"); + } + } + } + + // Hydrate output video from ref (skip HTTP URLs) + if (d.outputVideoRef && !d.outputVideo) { + outputVideo = await loadMediaById(d.outputVideoRef, workflowPath, loadedMedia, "video"); + } + + newData = { + ...d, + inputImages, + outputVideo, + }; + break; + } + + case "generate3d": { + const d = data as import("@/types").Generate3DNodeData; + const inputImages = [...(d.inputImages || [])]; + + // Hydrate input images from refs + if (d.inputImageRefs && d.inputImageRefs.length > 0) { + for (let i = 0; i < d.inputImageRefs.length; i++) { + const ref = d.inputImageRefs[i]; + if (ref) { + inputImages[i] = await loadMediaById(ref, workflowPath, loadedMedia, "image"); + } + } + } + + newData = { + ...d, + inputImages, + }; + break; + } + + case "generateAudio": { + const d = data as import("@/types").GenerateAudioNodeData; + let outputAudio = d.outputAudio; + + // Hydrate output audio from ref (skip HTTP URLs) + if (d.outputAudioRef && !d.outputAudio) { + outputAudio = await loadMediaById(d.outputAudioRef, workflowPath, loadedMedia, "audio"); + } + + newData = { + ...d, + outputAudio, + }; + break; + } + + case "output": { + // Output content is not persisted - it's regenerated on each workflow run + // and saved to /outputs directory during execution + newData = data; + break; + } + + case "outputGallery": { + // OutputGallery content is not persisted - it's regenerated on each workflow run + newData = data; + break; + } + + case "imageCompare": { + const d = data as import("@/types").ImageCompareNodeData; + let imageA = d.imageA; + let imageB = d.imageB; + + if (d.imageARef && !d.imageA) { + imageA = await loadMediaById(d.imageARef, workflowPath, loadedMedia, "image"); + } + if (d.imageBRef && !d.imageB) { + imageB = await loadMediaById(d.imageBRef, workflowPath, loadedMedia, "image"); + } + + newData = { + ...d, + imageA, + imageB, + }; + break; + } + + case "videoStitch": { + // videoStitch content is not persisted - it's regenerated on each workflow run + newData = data; + break; + } + + case "easeCurve": { + // easeCurve content is not persisted - it's regenerated on each workflow run + newData = data; + break; + } + + case "videoTrim": { + // videoTrim content is not persisted - it's regenerated on each workflow run + newData = data; + break; + } + + case "videoFrameGrab": { + // videoFrameGrab content is not persisted - it's regenerated on each workflow run + newData = data; + break; + } + + case "glbViewer": { + const d = data as import("@/types").GLBViewerNodeData; + if (d.capturedImageRef && !d.capturedImage) { + const capturedImage = await loadMediaById(d.capturedImageRef, workflowPath, loadedMedia, "image"); + newData = { + ...d, + capturedImage, + }; + } else { + newData = d; + } + break; + } + + case "splitGrid": { + const d = data as import("@/types").SplitGridNodeData; + if (d.sourceImageRef && !d.sourceImage) { + const sourceImage = await loadMediaById(d.sourceImageRef, workflowPath, loadedMedia, "image"); + newData = { + ...d, + sourceImage, + }; + } else { + newData = d; + } + break; + } + + default: + newData = data; + } + + return { + ...node, + data: newData, + } as WorkflowNode; +} + +/** + * Load media by ID (with caching) + * @param mediaType - Type of media to load (image, video, audio) + */ +async function loadMediaById( + mediaId: string, + workflowPath: string, + loadedMedia: Map, + mediaType: "image" | "video" | "audio" +): Promise { + if (loadedMedia.has(mediaId)) { + return loadedMedia.get(mediaId)!; + } + + let response: Response; + + if (mediaType === "image") { + // Use workflow-images API for images (legacy path) + const params = new URLSearchParams({ + workflowPath, + imageId: mediaId, + }); + + response = await fetch(`/api/workflow-images?${params.toString()}`); + } else { + // Use load-generation API for videos and audio + const params = new URLSearchParams({ + workflowPath, + id: mediaId, + }); + + response = await fetch(`/api/load-generation?${params.toString()}`); + } + + const result = await response.json(); + + if (!result.success) { + // Missing media is expected when refs point to deleted/moved files + console.log(`${mediaType} not found: ${mediaId}`); + return ""; // Return empty string to avoid breaking the workflow + } + + const mediaData = mediaType === "image" ? result.image : result.data; + loadedMedia.set(mediaId, mediaData); + return mediaData; +}