You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
405 lines
14 KiB
405 lines
14 KiB
/**
|
|
* 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<void> {
|
|
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<void> {
|
|
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<void> {
|
|
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<void> {
|
|
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<string, string> = {};
|
|
connectedTextNodes.forEach((srcNode) => {
|
|
if (srcNode.type === "prompt") {
|
|
const promptData = srcNode.data as PromptNodeData;
|
|
if (promptData.variableName) {
|
|
variableMap[promptData.variableName] = promptData.prompt;
|
|
}
|
|
}
|
|
});
|
|
|
|
// Parse inline <var> 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<void> {
|
|
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<void> {
|
|
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<OutputGalleryNodeData> = {};
|
|
|
|
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<void> {
|
|
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<void> {
|
|
// 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<void> {
|
|
// 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<void> {
|
|
// 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<void> {
|
|
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 });
|
|
}
|
|
}
|
|
}
|
|
|