/** * Simple Node Executors * * Executors for node types that don't call external APIs: * annotation, prompt, promptConstructor, output, outputGallery, imageCompare. * * These are used by executeWorkflow (and some by regenerateNode). */ import type { AnnotationNodeData, ArrayNodeData, PromptConstructorNodeData, PromptNodeData, LLMGenerateNodeData, OutputNodeData, OutputGalleryNodeData, WorkflowNode, } from "@/types"; import type { NodeExecutionContext } from "./types"; import { resolveTextSourcesThroughRouters } from "@/store/utils/connectedInputs"; import { parseTextToArray } from "@/utils/arrayParser"; import { parseVarTags } from "@/utils/parseVarTags"; /** * Annotation node: receives upstream image as source, passes through if no annotations. */ export async function executeAnnotation(ctx: NodeExecutionContext): Promise { const { node, getConnectedInputs, updateNodeData } = ctx; try { const { images } = getConnectedInputs(node.id); const image = images[0] || null; if (image) { const nodeData = node.data as AnnotationNodeData; updateNodeData(node.id, { sourceImage: image, sourceImageRef: undefined }); // Pass through the image if no annotations exist, or if the previous // output was itself a pass-through of the old source image if (!nodeData.outputImage || nodeData.outputImage === nodeData.sourceImage) { updateNodeData(node.id, { outputImage: image, outputImageRef: undefined }); } } } catch (err) { const message = err instanceof Error ? err.message : String(err); console.error(`[Workflow] Annotation node ${node.id} failed:`, message); updateNodeData(node.id, { error: message }); } } /** * Prompt node: receives upstream text and updates its prompt field. */ export async function executePrompt(ctx: NodeExecutionContext): Promise { const { node, getConnectedInputs, updateNodeData } = ctx; try { const { text: connectedText } = getConnectedInputs(node.id); if (connectedText !== null) { updateNodeData(node.id, { prompt: connectedText }); } } catch (err) { const message = err instanceof Error ? err.message : String(err); console.error(`[Workflow] Prompt node ${node.id} failed:`, message); updateNodeData(node.id, { error: message }); } } /** * Array node: splits connected text into itemized text outputs. */ export async function executeArray(ctx: NodeExecutionContext): Promise { const { node, getConnectedInputs, updateNodeData, getFreshNode } = ctx; try { const freshNode = getFreshNode(node.id); const nodeData = (freshNode?.data || node.data) as ArrayNodeData; const { text: connectedText } = getConnectedInputs(node.id); const inputText = connectedText ?? nodeData.inputText ?? ""; const parsed = parseTextToArray(inputText, { splitMode: nodeData.splitMode, delimiter: nodeData.delimiter, regexPattern: nodeData.regexPattern, trimItems: nodeData.trimItems, removeEmpty: nodeData.removeEmpty, }); updateNodeData(node.id, { inputText, outputItems: parsed.items, outputText: JSON.stringify(parsed.items), error: parsed.error, }); } catch (err) { const message = err instanceof Error ? err.message : String(err); console.error(`[Workflow] Array node ${node.id} failed:`, message); updateNodeData(node.id, { error: message }); } } /** * PromptConstructor node: resolves @variables from connected prompt nodes. */ export async function executePromptConstructor(ctx: NodeExecutionContext): Promise { const { node, updateNodeData, getFreshNode, getEdges, getNodes } = ctx; try { // Get fresh node data from store const freshNode = getFreshNode(node.id); const nodeData = (freshNode?.data || node.data) as PromptConstructorNodeData; const template = nodeData.template; const edges = getEdges(); const nodes = getNodes(); // Find all connected text nodes (resolving through routers) const directTextNodes = edges .filter((e) => e.target === node.id && e.targetHandle === "text") .map((e) => nodes.find((n) => n.id === e.source)) .filter((n): n is WorkflowNode => n !== undefined); const connectedTextNodes = resolveTextSourcesThroughRouters(directTextNodes, nodes, edges); // Build variable map: named variables from Prompt nodes take precedence const variableMap: Record = {}; connectedTextNodes.forEach((srcNode) => { if (srcNode.type === "prompt") { const promptData = srcNode.data as PromptNodeData; if (promptData.variableName) { variableMap[promptData.variableName] = promptData.prompt; } } }); // Parse inline tags from all connected text nodes connectedTextNodes.forEach((srcNode) => { let text: string | null = null; if (srcNode.type === "prompt") { text = (srcNode.data as PromptNodeData).prompt || null; } else if (srcNode.type === "llmGenerate") { text = (srcNode.data as LLMGenerateNodeData).outputText || null; } else if (srcNode.type === "promptConstructor") { const pcData = srcNode.data as PromptConstructorNodeData; text = pcData.outputText ?? pcData.template ?? null; } if (text) { const parsed = parseVarTags(text); parsed.forEach(({ name, value }) => { if (variableMap[name] === undefined) { variableMap[name] = value; } }); } }); // Find all @variable patterns in template const varPattern = /@(\w+)/g; const unresolvedVars: string[] = []; let resolvedText = template; // Replace @variables with values or track unresolved const matches = template.matchAll(varPattern); for (const match of matches) { const varName = match[1]; if (variableMap[varName] !== undefined) { resolvedText = resolvedText.replaceAll(`@${varName}`, variableMap[varName]); } else { if (!unresolvedVars.includes(varName)) { unresolvedVars.push(varName); } } } updateNodeData(node.id, { outputText: resolvedText, unresolvedVars, }); } catch (err) { const message = err instanceof Error ? err.message : String(err); console.error(`[Workflow] PromptConstructor node ${node.id} failed:`, message); updateNodeData(node.id, { error: message }); } } /** * Output node: displays final image/video result. */ export async function executeOutput(ctx: NodeExecutionContext): Promise { const { node, getConnectedInputs, updateNodeData, saveDirectoryPath } = ctx; const { images, videos, audio } = getConnectedInputs(node.id); // Check audio array first if (audio.length > 0) { const audioContent = audio[0]; updateNodeData(node.id, { audio: audioContent, image: null, video: null, contentType: "audio", }); // Save to /outputs directory if we have a project path if (saveDirectoryPath) { const outputNodeData = node.data as OutputNodeData; const outputsPath = `${saveDirectoryPath}/outputs`; fetch("/api/save-generation", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ directoryPath: outputsPath, audio: audioContent, customFilename: outputNodeData.outputFilename || undefined, createDirectory: true, }), }).catch((err) => { console.error("Failed to save output:", err); }); } return; } // Check videos array (typed data from source) if (videos.length > 0) { const videoContent = videos[0]; updateNodeData(node.id, { image: videoContent, video: videoContent, contentType: "video", }); // Save to /outputs directory if we have a project path if (saveDirectoryPath) { const outputNodeData = node.data as OutputNodeData; const outputsPath = `${saveDirectoryPath}/outputs`; fetch("/api/save-generation", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ directoryPath: outputsPath, video: videoContent, customFilename: outputNodeData.outputFilename || undefined, createDirectory: true, }), }).catch((err) => { console.error("Failed to save output:", err); }); } } else if (images.length > 0) { const content = images[0]; // Fallback pattern matching for edge cases (video data that ended up in images array) const isVideoContent = content.startsWith("data:video/") || content.includes(".mp4") || content.includes(".webm") || content.includes("fal.media"); if (isVideoContent) { updateNodeData(node.id, { image: content, video: content, contentType: "video", }); } else { updateNodeData(node.id, { image: content, video: null, contentType: "image", }); } // Save to /outputs directory if we have a project path if (saveDirectoryPath) { const outputNodeData = node.data as OutputNodeData; const outputsPath = `${saveDirectoryPath}/outputs`; fetch("/api/save-generation", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ directoryPath: outputsPath, image: isVideoContent ? undefined : content, video: isVideoContent ? content : undefined, customFilename: outputNodeData.outputFilename || undefined, createDirectory: true, }), }).catch((err) => { console.error("Failed to save output:", err); }); } } } /** * OutputGallery node: accumulates images and videos from upstream nodes. */ export async function executeOutputGallery(ctx: NodeExecutionContext): Promise { const { node, getConnectedInputs, updateNodeData, getFreshNode } = ctx; const { images, videos } = getConnectedInputs(node.id); // Use fresh node data — the stale `node` from topological sort may be missing // images pushed by appendOutputGalleryImage during upstream batch execution. const freshNode = getFreshNode(node.id); const freshData = (freshNode?.data ?? node.data) as OutputGalleryNodeData; const galleryImages = freshData.images || []; const galleryVideos = freshData.videos || []; const updates: Partial = {}; const existingImages = new Set(galleryImages); const newImages = images.filter((img) => !existingImages.has(img)); if (newImages.length > 0) { updates.images = [...newImages, ...galleryImages]; } const existingVideos = new Set(galleryVideos); const newVideos = videos.filter((v) => !existingVideos.has(v)); if (newVideos.length > 0) { updates.videos = [...newVideos, ...galleryVideos]; } if (Object.keys(updates).length > 0) { updateNodeData(node.id, updates); } } /** * ImageCompare node: takes two upstream images for side-by-side comparison. */ export async function executeImageCompare(ctx: NodeExecutionContext): Promise { const { node, getConnectedInputs, updateNodeData } = ctx; const { images } = getConnectedInputs(node.id); updateNodeData(node.id, { imageA: images[0] || null, imageB: images[1] || null, }); } /** * Router node: pure passthrough with brief status flash. */ export async function executeRouter(ctx: NodeExecutionContext): Promise { // Router is pure passthrough — data flows via edge traversal in getConnectedInputs. // Brief status flash to show execution occurred. ctx.updateNodeData(ctx.node.id, { status: "loading" }); await new Promise(resolve => setTimeout(resolve, 50)); if (!ctx.signal?.aborted) { ctx.updateNodeData(ctx.node.id, { status: "complete" }); } } /** * Switch node: pure passthrough with toggle-controlled routing. */ export async function executeSwitch(ctx: NodeExecutionContext): Promise { // Switch is pure passthrough — data flows via edge traversal in getConnectedInputs. // Disabled outputs are filtered during traversal. ctx.updateNodeData(ctx.node.id, { status: "loading" }); await new Promise(resolve => setTimeout(resolve, 50)); if (!ctx.signal?.aborted) { ctx.updateNodeData(ctx.node.id, { status: "complete" }); } } /** * ConditionalSwitch node: pure passthrough with text-based rule matching. */ export async function executeConditionalSwitch(ctx: NodeExecutionContext): Promise { // ConditionalSwitch is pure passthrough — actual text matching happens during connectedInputs traversal. // Brief status flash to show execution occurred. ctx.updateNodeData(ctx.node.id, { status: "loading" }); await new Promise(resolve => setTimeout(resolve, 50)); if (!ctx.signal?.aborted) { ctx.updateNodeData(ctx.node.id, { status: "complete" }); } } /** * GLB Viewer node: receives 3D model URL from upstream, fetches and loads it. */ export async function executeGlbViewer(ctx: NodeExecutionContext): Promise { const { node, getConnectedInputs, updateNodeData, signal } = ctx; const { model3d } = getConnectedInputs(node.id); if (model3d) { // Fetch the GLB URL and create a blob URL for the viewer try { const response = await fetch(model3d, signal ? { signal } : {}); if (!response.ok) { throw new Error(`Failed to fetch 3D model: ${response.status}`); } const blob = await response.blob(); const blobUrl = URL.createObjectURL(blob); updateNodeData(node.id, { glbUrl: blobUrl, filename: "generated.glb", capturedImage: null, }); } catch (error) { // Don't set error state on abort if ((error instanceof DOMException && error.name === "AbortError") || signal?.aborted) { return; } const message = error instanceof Error ? error.message : String(error); console.error(`[Workflow] GLB Viewer node ${node.id} failed:`, message); updateNodeData(node.id, { error: message }); } } }