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.
 
 

456 lines
13 KiB

import { WorkflowFile } from "@/store/workflowStore";
import { NodeType, WorkflowNodeData } from "@/types";
interface ValidationError {
path: string;
message: string;
}
interface ValidationResult {
valid: boolean;
errors: ValidationError[];
}
const VALID_NODE_TYPES: NodeType[] = [
"imageInput",
"annotation",
"prompt",
"nanoBanana",
"generateVideo",
"llmGenerate",
"splitGrid",
"output",
];
const VALID_HANDLE_TYPES = ["image", "text", "reference"];
// Default node dimensions
const DEFAULT_DIMENSIONS: Record<NodeType, { width: number; height: number }> = {
imageInput: { width: 300, height: 280 },
audioInput: { width: 300, height: 200 },
annotation: { width: 300, height: 280 },
prompt: { width: 320, height: 220 },
promptConstructor: { width: 340, height: 280 },
nanoBanana: { width: 300, height: 300 },
generateVideo: { width: 300, height: 300 },
llmGenerate: { width: 320, height: 360 },
splitGrid: { width: 300, height: 320 },
output: { width: 320, height: 320 },
outputGallery: { width: 320, height: 360 },
imageCompare: { width: 400, height: 360 },
videoStitch: { width: 400, height: 280 },
easeCurve: { width: 340, height: 480 },
glbViewer: { width: 360, height: 380 },
};
/**
* Validate a workflow JSON object
*/
export function validateWorkflowJSON(workflow: unknown): ValidationResult {
const errors: ValidationError[] = [];
// Check root object
if (!workflow || typeof workflow !== "object") {
errors.push({ path: "", message: "Workflow must be an object" });
return { valid: false, errors };
}
const wf = workflow as Record<string, unknown>;
// Validate version
if (wf.version !== 1) {
errors.push({ path: "version", message: "Version must be 1" });
}
// Validate name
if (!wf.name || typeof wf.name !== "string") {
errors.push({ path: "name", message: "Name must be a non-empty string" });
}
// Validate nodes array
if (!Array.isArray(wf.nodes)) {
errors.push({ path: "nodes", message: "Nodes must be an array" });
} else {
const nodeIds = new Set<string>();
(wf.nodes as unknown[]).forEach((node: unknown, i: number) => {
if (!node || typeof node !== "object") {
errors.push({ path: `nodes[${i}]`, message: "Node must be an object" });
return;
}
const n = node as Record<string, unknown>;
// Validate node id
if (!n.id || typeof n.id !== "string") {
errors.push({ path: `nodes[${i}].id`, message: "Node must have a string id" });
} else {
if (nodeIds.has(n.id)) {
errors.push({ path: `nodes[${i}].id`, message: `Duplicate node id: ${n.id}` });
}
nodeIds.add(n.id);
}
// Validate node type
if (!VALID_NODE_TYPES.includes(n.type as NodeType)) {
errors.push({
path: `nodes[${i}].type`,
message: `Invalid node type: ${n.type}. Valid types: ${VALID_NODE_TYPES.join(", ")}`,
});
}
// Validate position
if (!n.position || typeof n.position !== "object") {
errors.push({ path: `nodes[${i}].position`, message: "Node must have a position object" });
} else {
const pos = n.position as Record<string, unknown>;
if (typeof pos.x !== "number" || typeof pos.y !== "number") {
errors.push({
path: `nodes[${i}].position`,
message: "Position must have numeric x and y values",
});
}
}
// Validate data exists
if (!n.data || typeof n.data !== "object") {
errors.push({ path: `nodes[${i}].data`, message: "Node must have a data object" });
}
});
}
// Validate edges array
if (!Array.isArray(wf.edges)) {
errors.push({ path: "edges", message: "Edges must be an array" });
} else {
const nodeIds = new Set(
(Array.isArray(wf.nodes) ? wf.nodes : []).map((n: { id: string }) => n.id)
);
(wf.edges as unknown[]).forEach((edge: unknown, i: number) => {
if (!edge || typeof edge !== "object") {
errors.push({ path: `edges[${i}]`, message: "Edge must be an object" });
return;
}
const e = edge as Record<string, unknown>;
// Validate source exists
if (!e.source || typeof e.source !== "string") {
errors.push({ path: `edges[${i}].source`, message: "Edge must have a source id" });
} else if (!nodeIds.has(e.source)) {
errors.push({
path: `edges[${i}].source`,
message: `Source node not found: ${e.source}`,
});
}
// Validate target exists
if (!e.target || typeof e.target !== "string") {
errors.push({ path: `edges[${i}].target`, message: "Edge must have a target id" });
} else if (!nodeIds.has(e.target)) {
errors.push({
path: `edges[${i}].target`,
message: `Target node not found: ${e.target}`,
});
}
// Validate handle types
if (e.sourceHandle && !VALID_HANDLE_TYPES.includes(e.sourceHandle as string)) {
errors.push({
path: `edges[${i}].sourceHandle`,
message: `Invalid sourceHandle: ${e.sourceHandle}`,
});
}
if (e.targetHandle && !VALID_HANDLE_TYPES.includes(e.targetHandle as string)) {
errors.push({
path: `edges[${i}].targetHandle`,
message: `Invalid targetHandle: ${e.targetHandle}`,
});
}
// Validate handle types match (except reference which is special)
const srcHandle = e.sourceHandle as string;
const tgtHandle = e.targetHandle as string;
if (
srcHandle &&
tgtHandle &&
srcHandle !== "reference" &&
tgtHandle !== "reference" &&
srcHandle !== tgtHandle
) {
errors.push({
path: `edges[${i}]`,
message: `Handle type mismatch: ${srcHandle}${tgtHandle}`,
});
}
});
}
return { valid: errors.length === 0, errors };
}
/**
* Create default node data based on type
*/
function createDefaultNodeData(type: NodeType): WorkflowNodeData {
switch (type) {
case "imageInput":
return {
image: null,
filename: null,
dimensions: null,
};
case "audioInput":
return {
audioFile: null,
filename: null,
duration: null,
format: null,
};
case "annotation":
return {
sourceImage: null,
annotations: [],
outputImage: null,
};
case "prompt":
return {
prompt: "",
};
case "promptConstructor":
return {
template: "",
outputText: null,
unresolvedVars: [],
};
case "nanoBanana":
return {
inputImages: [],
inputPrompt: null,
outputImage: null,
aspectRatio: "1:1",
resolution: "1K",
model: "nano-banana-pro",
useGoogleSearch: false,
status: "idle",
error: null,
imageHistory: [],
selectedHistoryIndex: 0,
};
case "generateVideo":
return {
inputImages: [],
inputPrompt: null,
outputVideo: null,
selectedModel: undefined,
status: "idle",
error: null,
videoHistory: [],
selectedVideoHistoryIndex: 0,
};
case "llmGenerate":
return {
inputPrompt: null,
inputImages: [],
outputText: null,
provider: "google",
model: "gemini-3-flash-preview",
temperature: 0.7,
maxTokens: 8192,
status: "idle",
error: null,
};
case "splitGrid":
return {
sourceImage: null,
targetCount: 6,
defaultPrompt: "",
generateSettings: {
aspectRatio: "1:1",
resolution: "1K",
model: "nano-banana-pro",
useGoogleSearch: false,
},
childNodeIds: [],
gridRows: 2,
gridCols: 3,
isConfigured: false,
status: "idle",
error: null,
};
case "output":
return {
image: null,
};
case "outputGallery":
return {
images: [],
};
case "imageCompare":
return {
imageA: null,
imageB: null,
};
case "videoStitch":
return {
clips: [],
clipOrder: [],
outputVideo: null,
loopCount: 1,
status: "idle",
error: null,
progress: 0,
encoderSupported: null,
};
case "easeCurve":
return {
bezierHandles: [0.445, 0.05, 0.55, 0.95] as [number, number, number, number],
easingPreset: "easeInOutSine",
inheritedFrom: null,
outputDuration: 1.5,
outputVideo: null,
status: "idle",
error: null,
progress: 0,
encoderSupported: null,
};
case "glbViewer":
return {
glbUrl: null,
filename: null,
capturedImage: null,
};
}
}
/**
* Repair a workflow JSON object by filling in missing fields and removing invalid edges
*/
export function repairWorkflowJSON(workflow: unknown): WorkflowFile {
const wf = (workflow || {}) as Record<string, unknown>;
// Ensure required fields
const repaired: WorkflowFile = {
version: 1,
id: (wf.id as string) || `wf_${Date.now()}_repaired`,
name: (wf.name as string) || "Generated Workflow",
nodes: [],
edges: [],
edgeStyle: (wf.edgeStyle as "angular" | "curved") || "curved",
};
// Repair nodes
if (Array.isArray(wf.nodes)) {
repaired.nodes = wf.nodes
.filter((n): n is Record<string, unknown> => n && typeof n === "object")
.map((node, index) => {
const type = VALID_NODE_TYPES.includes(node.type as NodeType)
? (node.type as NodeType)
: "prompt";
const id =
typeof node.id === "string" && node.id
? node.id
: `${type}-${index + 1}`;
const position =
node.position && typeof node.position === "object"
? {
x:
typeof (node.position as Record<string, unknown>).x === "number"
? ((node.position as Record<string, unknown>).x as number)
: 50 + index * 400,
y:
typeof (node.position as Record<string, unknown>).y === "number"
? ((node.position as Record<string, unknown>).y as number)
: 100,
}
: { x: 50 + index * 400, y: 100 };
// Merge existing data with defaults
const defaultData = createDefaultNodeData(type);
const existingData = node.data && typeof node.data === "object" ? node.data : {};
const data = { ...defaultData, ...existingData };
return {
id,
type,
position,
data,
style: DEFAULT_DIMENSIONS[type],
};
}) as WorkflowFile["nodes"];
}
// Repair edges
if (Array.isArray(wf.edges)) {
const nodeIds = new Set(repaired.nodes.map((n) => n.id));
repaired.edges = wf.edges
.filter((e): e is Record<string, unknown> => e && typeof e === "object")
.filter((edge) => {
// Only keep edges where both source and target exist
const sourceExists = nodeIds.has(edge.source as string);
const targetExists = nodeIds.has(edge.target as string);
// Only keep edges where handle types match (or are reference)
const srcHandle = edge.sourceHandle as string;
const tgtHandle = edge.targetHandle as string;
const handlesMatch =
!srcHandle ||
!tgtHandle ||
srcHandle === "reference" ||
tgtHandle === "reference" ||
srcHandle === tgtHandle;
return sourceExists && targetExists && handlesMatch;
})
.map((edge) => ({
id:
(edge.id as string) ||
`edge-${edge.source}-${edge.target}-${edge.sourceHandle || "default"}-${edge.targetHandle || "default"}`,
source: edge.source as string,
sourceHandle: (edge.sourceHandle as string) || undefined,
target: edge.target as string,
targetHandle: (edge.targetHandle as string) || undefined,
})) as WorkflowFile["edges"];
}
return repaired;
}
/**
* Parse JSON from LLM response, handling various formats
*/
export function parseJSONFromResponse(text: string): unknown {
// Try direct parse
try {
return JSON.parse(text);
} catch {
// Continue to other methods
}
// Try extracting from markdown code blocks
const jsonBlockMatch = text.match(/```(?:json)?\s*([\s\S]*?)```/);
if (jsonBlockMatch) {
try {
return JSON.parse(jsonBlockMatch[1].trim());
} catch {
// Continue to other methods
}
}
// Try finding a JSON object in the text
const objectMatch = text.match(/\{[\s\S]*\}/);
if (objectMatch) {
try {
return JSON.parse(objectMatch[0]);
} catch {
// Continue to other methods
}
}
throw new Error("Could not parse JSON from response");
}