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.
453 lines
17 KiB
453 lines
17 KiB
/**
|
|
* Connected Inputs & Validation
|
|
*
|
|
* Pure functions extracted from workflowStore for getting connected inputs
|
|
* and validating workflow structure. These can be tested without the store.
|
|
*/
|
|
|
|
import {
|
|
WorkflowNode,
|
|
WorkflowEdge,
|
|
ImageInputNodeData,
|
|
AudioInputNodeData,
|
|
VideoInputNodeData,
|
|
AnnotationNodeData,
|
|
NanoBananaNodeData,
|
|
GenerateVideoNodeData,
|
|
Generate3DNodeData,
|
|
GenerateAudioNodeData,
|
|
VideoStitchNodeData,
|
|
EaseCurveNodeData,
|
|
VideoTrimNodeData,
|
|
VideoFrameGrabNodeData,
|
|
PromptNodeData,
|
|
ArrayNodeData,
|
|
PromptConstructorNodeData,
|
|
LLMGenerateNodeData,
|
|
GLBViewerNodeData,
|
|
SwitchNodeData,
|
|
ConditionalSwitchNodeData,
|
|
MatchMode,
|
|
} from "@/types";
|
|
|
|
/**
|
|
* Return type for getConnectedInputs
|
|
*/
|
|
export interface ConnectedInputs {
|
|
images: string[];
|
|
videos: string[];
|
|
audio: string[];
|
|
model3d: string | null;
|
|
text: string | null;
|
|
textItems: string[]; // All items from array batch mode (empty when not in batch)
|
|
dynamicInputs: Record<string, string | string[]>;
|
|
easeCurve: { bezierHandles: [number, number, number, number]; easingPreset: string | null; outputDuration: number } | null;
|
|
}
|
|
|
|
/**
|
|
* Helper to determine if a handle ID is an image type
|
|
*/
|
|
function isImageHandle(handleId: string | null | undefined): boolean {
|
|
if (!handleId) return false;
|
|
return handleId === "image" || handleId.startsWith("image-") || handleId.includes("frame");
|
|
}
|
|
|
|
/**
|
|
* Helper to determine if a handle ID is a text type
|
|
*/
|
|
function isTextHandle(handleId: string | null | undefined): boolean {
|
|
if (!handleId) return false;
|
|
return handleId === "text" || handleId.startsWith("text-") || handleId.includes("prompt");
|
|
}
|
|
|
|
/**
|
|
* Extract output data and type from a source node
|
|
*/
|
|
export function getSourceOutput(
|
|
sourceNode: WorkflowNode,
|
|
sourceHandle: string | null | undefined,
|
|
edgeData?: Record<string, unknown>
|
|
): { type: "image" | "text" | "video" | "audio" | "3d"; value: string | null } {
|
|
if (sourceNode.type === "imageInput") {
|
|
return { type: "image", value: (sourceNode.data as ImageInputNodeData).image };
|
|
} else if (sourceNode.type === "videoInput") {
|
|
return { type: "video", value: (sourceNode.data as VideoInputNodeData).video };
|
|
} else if (sourceNode.type === "audioInput") {
|
|
return { type: "audio", value: (sourceNode.data as AudioInputNodeData).audioFile };
|
|
} else if (sourceNode.type === "annotation") {
|
|
return { type: "image", value: (sourceNode.data as AnnotationNodeData).outputImage };
|
|
} else if (sourceNode.type === "nanoBanana") {
|
|
const nbData = sourceNode.data as NanoBananaNodeData;
|
|
return { type: "image", value: nbData.outputImage };
|
|
} else if (sourceNode.type === "generate3d") {
|
|
const g3dData = sourceNode.data as Generate3DNodeData;
|
|
return { type: "3d", value: g3dData.output3dUrl };
|
|
} else if (sourceNode.type === "generateVideo") {
|
|
return { type: "video", value: (sourceNode.data as GenerateVideoNodeData).outputVideo };
|
|
} else if (sourceNode.type === "generateAudio") {
|
|
return { type: "audio", value: (sourceNode.data as GenerateAudioNodeData).outputAudio };
|
|
} else if (sourceNode.type === "videoStitch") {
|
|
return { type: "video", value: (sourceNode.data as VideoStitchNodeData).outputVideo };
|
|
} else if (sourceNode.type === "easeCurve") {
|
|
return { type: "video", value: (sourceNode.data as EaseCurveNodeData).outputVideo };
|
|
} else if (sourceNode.type === "videoTrim") {
|
|
return { type: "video", value: (sourceNode.data as VideoTrimNodeData).outputVideo };
|
|
} else if (sourceNode.type === "prompt") {
|
|
return { type: "text", value: (sourceNode.data as PromptNodeData).prompt };
|
|
} else if (sourceNode.type === "array") {
|
|
const arrayData = sourceNode.data as ArrayNodeData;
|
|
const dataIndex = edgeData?.arrayItemIndex;
|
|
if (typeof dataIndex === "number" && Number.isInteger(dataIndex) && dataIndex >= 0) {
|
|
const items = arrayData.outputItems;
|
|
if (items.length === 0) return { type: "text", value: null };
|
|
const clampedIndex = dataIndex % items.length;
|
|
return { type: "text", value: items[clampedIndex] ?? null };
|
|
}
|
|
if (sourceHandle?.startsWith("text-")) {
|
|
const index = Number(sourceHandle.replace("text-", ""));
|
|
if (Number.isInteger(index) && index >= 0) {
|
|
return { type: "text", value: arrayData.outputItems[index] ?? null };
|
|
}
|
|
}
|
|
return { type: "text", value: arrayData.outputText };
|
|
} else if (sourceNode.type === "promptConstructor") {
|
|
const pcData = sourceNode.data as PromptConstructorNodeData;
|
|
return { type: "text", value: pcData.outputText ?? pcData.template ?? null };
|
|
} else if (sourceNode.type === "llmGenerate") {
|
|
return { type: "text", value: (sourceNode.data as LLMGenerateNodeData).outputText };
|
|
} else if (sourceNode.type === "videoFrameGrab") {
|
|
return { type: "image", value: (sourceNode.data as VideoFrameGrabNodeData).outputImage };
|
|
} else if (sourceNode.type === "glbViewer") {
|
|
return { type: "image", value: (sourceNode.data as GLBViewerNodeData).capturedImage };
|
|
}
|
|
return { type: "image", value: null };
|
|
}
|
|
|
|
/**
|
|
* Resolves text source nodes through router (passthrough) nodes.
|
|
* Given a list of directly-connected source nodes, expands any router nodes
|
|
* by recursively following their upstream text connections to find the actual
|
|
* text-producing source nodes.
|
|
*/
|
|
export function resolveTextSourcesThroughRouters(
|
|
sourceNodes: WorkflowNode[],
|
|
allNodes: WorkflowNode[],
|
|
edges: { source: string; target: string; targetHandle?: string | null }[],
|
|
visited?: Set<string>
|
|
): WorkflowNode[] {
|
|
const seen = visited ?? new Set<string>();
|
|
const resolved: WorkflowNode[] = [];
|
|
|
|
for (const node of sourceNodes) {
|
|
if (seen.has(node.id)) continue;
|
|
seen.add(node.id);
|
|
|
|
if (node.type === "router" || node.type === "switch") {
|
|
const upstreamNodes = edges
|
|
.filter((e) => e.target === node.id && e.targetHandle === "text")
|
|
.map((e) => allNodes.find((n) => n.id === e.source))
|
|
.filter((n): n is WorkflowNode => n !== undefined);
|
|
resolved.push(
|
|
...resolveTextSourcesThroughRouters(upstreamNodes, allNodes, edges, seen)
|
|
);
|
|
} else {
|
|
resolved.push(node);
|
|
}
|
|
}
|
|
|
|
return resolved;
|
|
}
|
|
|
|
/**
|
|
* Get all connected inputs for a node.
|
|
* Pure function version of workflowStore.getConnectedInputs.
|
|
*/
|
|
export function getConnectedInputsPure(
|
|
nodeId: string,
|
|
nodes: WorkflowNode[],
|
|
edges: WorkflowEdge[],
|
|
visited?: Set<string>,
|
|
dimmedNodeIds?: Set<string>
|
|
): ConnectedInputs {
|
|
const _visited = visited || new Set<string>();
|
|
if (_visited.has(nodeId)) return { images: [], videos: [], audio: [], model3d: null, text: null, textItems: [], dynamicInputs: {}, easeCurve: null };
|
|
_visited.add(nodeId);
|
|
const images: string[] = [];
|
|
const videos: string[] = [];
|
|
const audio: string[] = [];
|
|
let model3d: string | null = null;
|
|
let text: string | null = null;
|
|
const textItems: string[] = [];
|
|
const dynamicInputs: Record<string, string | string[]> = {};
|
|
let easeCurve: ConnectedInputs["easeCurve"] = null;
|
|
|
|
// Get the target node to check for inputSchema
|
|
const targetNode = nodes.find((n) => n.id === nodeId);
|
|
const inputSchema = (targetNode?.data as { inputSchema?: Array<{ name: string; type: string }> })?.inputSchema;
|
|
|
|
// Build mapping from normalized handle IDs to schema names if schema exists
|
|
const handleToSchemaName: Record<string, string> = {};
|
|
if (inputSchema && inputSchema.length > 0) {
|
|
const imageInputs = inputSchema.filter(i => i.type === "image");
|
|
const textInputs = inputSchema.filter(i => i.type === "text");
|
|
const audioInputs = inputSchema.filter(i => i.type === "audio");
|
|
|
|
imageInputs.forEach((input, index) => {
|
|
handleToSchemaName[`image-${index}`] = input.name;
|
|
if (index === 0) {
|
|
handleToSchemaName["image"] = input.name;
|
|
}
|
|
});
|
|
|
|
textInputs.forEach((input, index) => {
|
|
handleToSchemaName[`text-${index}`] = input.name;
|
|
if (index === 0) {
|
|
handleToSchemaName["text"] = input.name;
|
|
}
|
|
});
|
|
|
|
audioInputs.forEach((input, index) => {
|
|
handleToSchemaName[`audio-${index}`] = input.name;
|
|
if (index === 0) {
|
|
handleToSchemaName["audio"] = input.name;
|
|
}
|
|
});
|
|
}
|
|
|
|
// Cache passthrough node results so multiple edges from the same router/switch
|
|
// all receive correct data (the _visited set prevents re-traversal, so we cache
|
|
// the result from the first traversal and reuse it for subsequent edges).
|
|
const passthroughCache = new Map<string, ConnectedInputs>();
|
|
|
|
edges
|
|
.filter((edge) => edge.target === nodeId && !edge.data?.isLoop)
|
|
.forEach((edge) => {
|
|
const sourceNode = nodes.find((n) => n.id === edge.source);
|
|
if (!sourceNode) return;
|
|
|
|
// Skip dimmed source nodes — their data should not flow downstream
|
|
if (dimmedNodeIds && dimmedNodeIds.has(sourceNode.id)) return;
|
|
|
|
// Array batch mode — send all items as textItems instead of a single item
|
|
// Derive from source node's current batchMode (not edge metadata which can go stale)
|
|
if (sourceNode.type === "array" && (sourceNode.data as ArrayNodeData).batchMode === true) {
|
|
const arrayData = sourceNode.data as ArrayNodeData;
|
|
const items = arrayData.outputItems;
|
|
if (items.length > 0) {
|
|
textItems.push(...items);
|
|
// Set text to first item for backward compatibility
|
|
if (text === null) text = items[0];
|
|
}
|
|
return; // Skip normal getSourceOutput processing
|
|
}
|
|
|
|
// Router passthrough — traverse upstream to find actual data source
|
|
if (sourceNode.type === "router") {
|
|
const routerInputs = passthroughCache.get(sourceNode.id)
|
|
?? getConnectedInputsPure(sourceNode.id, nodes, edges, _visited, dimmedNodeIds);
|
|
passthroughCache.set(sourceNode.id, routerInputs);
|
|
// Determine which type this edge carries based on the source handle
|
|
const edgeType = edge.sourceHandle; // Will be "image", "text", "video", "audio", "3d", or "easeCurve"
|
|
|
|
if (edgeType === "image" || (!edgeType && isImageHandle(edge.sourceHandle))) {
|
|
images.push(...routerInputs.images);
|
|
} else if (edgeType === "text" || (!edgeType && isTextHandle(edge.sourceHandle))) {
|
|
if (routerInputs.text) text = routerInputs.text;
|
|
} else if (edgeType === "video") {
|
|
videos.push(...routerInputs.videos);
|
|
} else if (edgeType === "audio") {
|
|
audio.push(...routerInputs.audio);
|
|
} else if (edgeType === "3d") {
|
|
if (routerInputs.model3d) model3d = routerInputs.model3d;
|
|
} else if (edgeType === "easeCurve") {
|
|
// EaseCurve passthrough
|
|
if (routerInputs.easeCurve) easeCurve = routerInputs.easeCurve;
|
|
}
|
|
return; // Skip normal getSourceOutput processing for this edge
|
|
}
|
|
|
|
// Switch passthrough — traverse upstream if output is enabled
|
|
if (sourceNode.type === "switch") {
|
|
const switchData = sourceNode.data as SwitchNodeData;
|
|
const switchId = edge.sourceHandle; // Handle ID matches switch entry id
|
|
const switchEntry = switchData.switches?.find(s => s.id === switchId);
|
|
|
|
// Skip disabled outputs — data does not flow through disabled switches
|
|
if (!switchEntry || !switchEntry.enabled) {
|
|
return; // Block this path
|
|
}
|
|
|
|
// Enabled switch: recursively get upstream data (same pattern as router)
|
|
const switchInputs = passthroughCache.get(sourceNode.id)
|
|
?? getConnectedInputsPure(sourceNode.id, nodes, edges, _visited, dimmedNodeIds);
|
|
passthroughCache.set(sourceNode.id, switchInputs);
|
|
const edgeType = switchData.inputType;
|
|
|
|
if (edgeType === "image") {
|
|
images.push(...switchInputs.images);
|
|
} else if (edgeType === "text") {
|
|
if (switchInputs.text) text = switchInputs.text;
|
|
} else if (edgeType === "video") {
|
|
videos.push(...switchInputs.videos);
|
|
} else if (edgeType === "audio") {
|
|
audio.push(...switchInputs.audio);
|
|
} else if (edgeType === "3d") {
|
|
if (switchInputs.model3d) model3d = switchInputs.model3d;
|
|
} else if (edgeType === "easeCurve") {
|
|
if (switchInputs.easeCurve) easeCurve = switchInputs.easeCurve;
|
|
}
|
|
return; // Skip normal getSourceOutput processing
|
|
}
|
|
|
|
// Conditional Switch passthrough — traverse upstream if output is active (matched or default)
|
|
if (sourceNode.type === "conditionalSwitch") {
|
|
const condData = sourceNode.data as ConditionalSwitchNodeData;
|
|
|
|
// When evaluation is paused, all outputs are active (gate is open)
|
|
if (!condData.evaluationPaused) {
|
|
const sourceHandle = edge.sourceHandle;
|
|
|
|
// Find matching rule or check if default
|
|
const rule = condData.rules.find(r => r.id === sourceHandle);
|
|
const isDefaultHandle = sourceHandle === "default";
|
|
|
|
// Determine if this output is active
|
|
let isActive = false;
|
|
if (rule) {
|
|
isActive = rule.isMatched;
|
|
} else if (isDefaultHandle) {
|
|
// Default is active when NO rules match
|
|
isActive = !condData.rules.some(r => r.isMatched);
|
|
}
|
|
|
|
// Block non-active outputs (data does not flow through non-matching rules)
|
|
if (!isActive) return;
|
|
}
|
|
|
|
// Active output (or paused): ConditionalSwitch is a gate — trigger downstream but don't pass data through
|
|
return;
|
|
}
|
|
|
|
const handleId = edge.targetHandle;
|
|
const { type, value } = getSourceOutput(
|
|
sourceNode,
|
|
edge.sourceHandle,
|
|
(edge.data as Record<string, unknown> | undefined)
|
|
);
|
|
|
|
if (!value) return;
|
|
|
|
// Map normalized handle ID to schema name for dynamicInputs
|
|
if (handleId && handleToSchemaName[handleId]) {
|
|
const schemaName = handleToSchemaName[handleId];
|
|
const existing = dynamicInputs[schemaName];
|
|
if (existing !== undefined) {
|
|
dynamicInputs[schemaName] = Array.isArray(existing)
|
|
? [...existing, value]
|
|
: [existing, value];
|
|
} else {
|
|
dynamicInputs[schemaName] = value;
|
|
}
|
|
}
|
|
|
|
// Route to typed arrays based on source output type
|
|
if (type === "3d") {
|
|
model3d = value;
|
|
} else if (type === "video") {
|
|
videos.push(value);
|
|
} else if (type === "audio") {
|
|
audio.push(value);
|
|
} else if (type === "text" || isTextHandle(handleId)) {
|
|
// Defensive: ensure text values are always strings
|
|
// (Guards against corrupted node data during parallel execution)
|
|
text = typeof value === 'string' ? value : String(value);
|
|
} else if (isImageHandle(handleId) || !handleId) {
|
|
images.push(value);
|
|
}
|
|
});
|
|
|
|
// Extract easeCurve data from parent EaseCurve node (if not already set by router passthrough)
|
|
if (!easeCurve) {
|
|
const easeCurveEdge = edges.find(
|
|
(e) => e.target === nodeId && e.targetHandle === "easeCurve" && !e.data?.isLoop
|
|
);
|
|
if (easeCurveEdge) {
|
|
const sourceNode = nodes.find((n) => n.id === easeCurveEdge.source);
|
|
if (sourceNode?.type === "easeCurve") {
|
|
const sourceData = sourceNode.data as EaseCurveNodeData;
|
|
easeCurve = {
|
|
bezierHandles: sourceData.bezierHandles,
|
|
easingPreset: sourceData.easingPreset,
|
|
outputDuration: sourceData.outputDuration,
|
|
};
|
|
}
|
|
}
|
|
}
|
|
|
|
return { images, videos, audio, model3d, text, textItems, dynamicInputs, easeCurve };
|
|
}
|
|
|
|
/**
|
|
* Validate workflow structure.
|
|
* Pure function version of workflowStore.validateWorkflow.
|
|
*/
|
|
export function validateWorkflowPure(
|
|
nodes: WorkflowNode[],
|
|
edges: WorkflowEdge[]
|
|
): { valid: boolean; errors: string[] } {
|
|
const errors: string[] = [];
|
|
|
|
if (nodes.length === 0) {
|
|
errors.push("Workflow is empty");
|
|
return { valid: false, errors };
|
|
}
|
|
|
|
// Check each Nano Banana node has required inputs (text required, image optional)
|
|
nodes
|
|
.filter((n) => n.type === "nanoBanana")
|
|
.forEach((node) => {
|
|
const textConnected = edges.some(
|
|
(e) => e.target === node.id &&
|
|
(e.targetHandle === "text" || e.targetHandle?.startsWith("text-"))
|
|
);
|
|
if (!textConnected) {
|
|
errors.push(`Generate node "${node.id}" missing text input`);
|
|
}
|
|
});
|
|
|
|
// Check generateVideo nodes have required text input
|
|
nodes
|
|
.filter((n) => n.type === "generateVideo")
|
|
.forEach((node) => {
|
|
const textConnected = edges.some(
|
|
(e) => e.target === node.id &&
|
|
(e.targetHandle === "text" || e.targetHandle?.startsWith("text-"))
|
|
);
|
|
if (!textConnected) {
|
|
errors.push(`Video node "${node.id}" missing text input`);
|
|
}
|
|
});
|
|
|
|
// Check annotation nodes have image input (either connected or manually loaded)
|
|
nodes
|
|
.filter((n) => n.type === "annotation")
|
|
.forEach((node) => {
|
|
const imageConnected = edges.some((e) => e.target === node.id);
|
|
const hasManualImage = (node.data as AnnotationNodeData).sourceImage !== null;
|
|
if (!imageConnected && !hasManualImage) {
|
|
errors.push(`Annotation node "${node.id}" missing image input`);
|
|
}
|
|
});
|
|
|
|
// Check output nodes have image input
|
|
nodes
|
|
.filter((n) => n.type === "output")
|
|
.forEach((node) => {
|
|
const imageConnected = edges.some((e) => e.target === node.id);
|
|
if (!imageConnected) {
|
|
errors.push(`Output node "${node.id}" missing image input`);
|
|
}
|
|
});
|
|
|
|
return { valid: errors.length === 0, errors };
|
|
}
|
|
|