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.
238 lines
8.0 KiB
238 lines
8.0 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,
|
|
AnnotationNodeData,
|
|
NanoBananaNodeData,
|
|
GenerateVideoNodeData,
|
|
VideoStitchNodeData,
|
|
EaseCurveNodeData,
|
|
PromptNodeData,
|
|
PromptConstructorNodeData,
|
|
LLMGenerateNodeData,
|
|
} from "@/types";
|
|
|
|
/**
|
|
* Return type for getConnectedInputs
|
|
*/
|
|
export interface ConnectedInputs {
|
|
images: string[];
|
|
videos: string[];
|
|
audio: string[];
|
|
text: string | null;
|
|
dynamicInputs: Record<string, string | string[]>;
|
|
easeCurve: { bezierHandles: [number, number, number, number]; easingPreset: string | null } | 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
|
|
*/
|
|
function getSourceOutput(sourceNode: WorkflowNode): { type: "image" | "text" | "video" | "audio"; value: string | null } {
|
|
if (sourceNode.type === "imageInput") {
|
|
return { type: "image", value: (sourceNode.data as ImageInputNodeData).image };
|
|
} 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") {
|
|
return { type: "image", value: (sourceNode.data as NanoBananaNodeData).outputImage };
|
|
} else if (sourceNode.type === "generateVideo") {
|
|
return { type: "video", value: (sourceNode.data as GenerateVideoNodeData).outputVideo };
|
|
} 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 === "prompt") {
|
|
return { type: "text", value: (sourceNode.data as PromptNodeData).prompt };
|
|
} 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 };
|
|
}
|
|
return { type: "image", value: null };
|
|
}
|
|
|
|
/**
|
|
* Get all connected inputs for a node.
|
|
* Pure function version of workflowStore.getConnectedInputs.
|
|
*/
|
|
export function getConnectedInputsPure(
|
|
nodeId: string,
|
|
nodes: WorkflowNode[],
|
|
edges: WorkflowEdge[]
|
|
): ConnectedInputs {
|
|
const images: string[] = [];
|
|
const videos: string[] = [];
|
|
const audio: string[] = [];
|
|
let text: string | null = null;
|
|
const dynamicInputs: Record<string, string | string[]> = {};
|
|
|
|
// 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");
|
|
|
|
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;
|
|
}
|
|
});
|
|
}
|
|
|
|
edges
|
|
.filter((edge) => edge.target === nodeId)
|
|
.forEach((edge) => {
|
|
const sourceNode = nodes.find((n) => n.id === edge.source);
|
|
if (!sourceNode) return;
|
|
|
|
const handleId = edge.targetHandle;
|
|
const { type, value } = getSourceOutput(sourceNode);
|
|
|
|
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 === "video") {
|
|
videos.push(value);
|
|
} else if (type === "audio") {
|
|
audio.push(value);
|
|
} else if (type === "text" || isTextHandle(handleId)) {
|
|
text = value;
|
|
} else if (isImageHandle(handleId) || !handleId) {
|
|
images.push(value);
|
|
}
|
|
});
|
|
|
|
// Extract easeCurve data from parent EaseCurve node
|
|
let easeCurve: ConnectedInputs["easeCurve"] = null;
|
|
const easeCurveEdge = edges.find(
|
|
(e) => e.target === nodeId && e.targetHandle === "easeCurve"
|
|
);
|
|
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,
|
|
};
|
|
}
|
|
}
|
|
|
|
return { images, videos, audio, text, 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 };
|
|
}
|
|
|