From f50c98418037c30812e7b446517ce53db1b0f728 Mon Sep 17 00:00:00 2001 From: shrimbly Date: Mon, 12 Jan 2026 23:36:09 +1300 Subject: [PATCH] fix(14-01): fix drag-connect node creation and handle persistence - Always render image/text handles with placeholders for unused inputs - Placeholder handles (dimmed 30%) preserve connections for models that don't use that input type (e.g., text-to-image models) - Normalize dynamic handle IDs to standard values (image, text, image-0) - Update findCompatibleHandle to return normalized IDs - Map normalized handles to schema names in getConnectedInputs - Exclude image_size from image input detection - Switch save-generation to hash suffix format for better readability Co-Authored-By: Claude --- src/app/api/models/[modelId]/route.ts | 9 ++ src/app/api/save-generation/route.ts | 13 ++- src/components/WorkflowCanvas.tsx | 9 +- src/components/nodes/GenerateImageNode.tsx | 104 +++++++++++++++++---- src/components/nodes/GenerateVideoNode.tsx | 104 +++++++++++++++++---- src/store/workflowStore.ts | 36 ++++++- 6 files changed, 229 insertions(+), 46 deletions(-) diff --git a/src/app/api/models/[modelId]/route.ts b/src/app/api/models/[modelId]/route.ts index 8de9e99f..2036ed15 100644 --- a/src/app/api/models/[modelId]/route.ts +++ b/src/app/api/models/[modelId]/route.ts @@ -45,6 +45,9 @@ const IMAGE_INPUT_PATTERNS = [ // Text input properties const TEXT_INPUT_NAMES = ["prompt", "negative_prompt"]; +// Properties that start with "image_" but are NOT image inputs +const IMAGE_PREFIX_EXCLUSIONS = ["image_size"]; + // Parameters to filter out (internal/system params) const EXCLUDED_PARAMS = new Set([ "webhook", @@ -69,6 +72,7 @@ const PRIORITY_PARAMS = new Set([ "negative_prompt", "width", "height", + "image_size", "num_outputs", "num_images", "scheduler", @@ -110,6 +114,11 @@ function isImageInput(name: string): boolean { return true; } + // Check exclusions (e.g., image_size is a parameter, not an image input) + if (IMAGE_PREFIX_EXCLUSIONS.includes(name)) { + return false; + } + // Check for "image" as a word boundary: // - ends with _image (e.g., start_image, control_image) // - starts with image_ (e.g., image_url, image_urls) diff --git a/src/app/api/save-generation/route.ts b/src/app/api/save-generation/route.ts index e2df53f4..c92aee6e 100644 --- a/src/app/api/save-generation/route.ts +++ b/src/app/api/save-generation/route.ts @@ -28,7 +28,7 @@ function computeContentHash(buffer: Buffer): string { return crypto.createHash("md5").update(buffer).digest("hex"); } -// Helper to find existing file by hash prefix +// Helper to find existing file by hash suffix async function findExistingFileByHash( directoryPath: string, hash: string, @@ -36,10 +36,9 @@ async function findExistingFileByHash( ): Promise { try { const files = await fs.readdir(directoryPath); - // Look for files starting with this hash - const matching = files.find( - (f) => f.startsWith(hash) && f.endsWith(`.${extension}`) - ); + // Look for files ending with this hash before extension + const hashSuffix = `_${hash}.${extension}`; + const matching = files.find((f) => f.endsWith(hashSuffix)); return matching || null; } catch { return null; @@ -154,7 +153,7 @@ export async function POST(request: NextRequest) { }); } - // Generate filename with hash prefix for fast future lookups + // Generate filename with hash suffix for deduplication const promptSnippet = prompt ? prompt .slice(0, 30) @@ -163,7 +162,7 @@ export async function POST(request: NextRequest) { .replace(/^_|_$/g, "") .toLowerCase() : "generation"; - const filename = `${contentHash}_${promptSnippet}.${extension}`; + const filename = `${promptSnippet}_${contentHash}.${extension}`; const filePath = path.join(directoryPath, filename); // Write the file diff --git a/src/components/WorkflowCanvas.tsx b/src/components/WorkflowCanvas.tsx index 26961f07..289b558e 100644 --- a/src/components/WorkflowCanvas.tsx +++ b/src/components/WorkflowCanvas.tsx @@ -334,8 +334,13 @@ export function WorkflowCanvas() { if (nodeData.inputSchema && nodeData.inputSchema.length > 0) { if (needInput) { // Find first input handle matching the type - const match = nodeData.inputSchema.find(i => i.type === handleType); - if (match) return match.name; + // Count how many of this type exist to determine if we need indexed handle ID + const matchingInputs = nodeData.inputSchema.filter(i => i.type === handleType); + if (matchingInputs.length > 0) { + // Return normalized handle ID, not schema name + // Nodes use "image"/"text" for single inputs, "image-0"/"text-0" for multiple + return matchingInputs.length > 1 ? `${handleType}-0` : handleType; + } } // Output handle - check for video or image type if (handleType === "video") return "video"; diff --git a/src/components/nodes/GenerateImageNode.tsx b/src/components/nodes/GenerateImageNode.tsx index ebd0cce8..352dea77 100644 --- a/src/components/nodes/GenerateImageNode.tsx +++ b/src/components/nodes/GenerateImageNode.tsx @@ -464,32 +464,103 @@ export function GenerateImageNode({ id, data, selected }: NodeProps 0 ? ( // Render handles from schema, sorted by type (images first, text second) + // IMPORTANT: Always render "image" and "text" handles to maintain connection + // compatibility. Schema may only have text inputs (text-to-image models) but + // we still need the image handle to preserve connections made before model selection. (() => { const imageInputs = nodeData.inputSchema!.filter(i => i.type === "image"); const textInputs = nodeData.inputSchema!.filter(i => i.type === "text"); - const sortedInputs = [...imageInputs, ...textInputs]; - // Calculate positions with gap between image and text groups - const imageCount = imageInputs.length; - const textCount = textInputs.length; - const totalSlots = imageCount + textCount + (imageCount > 0 && textCount > 0 ? 1 : 0); // +1 for gap - - return sortedInputs.map((input, index) => { - // Add 1 to index for text inputs if there are image inputs (to account for gap) - const adjustedIndex = input.type === "text" && imageCount > 0 ? index + 1 : index; + // Always include at least one image and one text handle for connection stability + const hasImageInput = imageInputs.length > 0; + const hasTextInput = textInputs.length > 0; + + // Build the handles array: schema inputs + fallback defaults if missing + const handles: Array<{ + id: string; + type: "image" | "text"; + label: string; + schemaName: string | null; + description: string | null; + isPlaceholder: boolean; + }> = []; + + // Add image handles from schema, or a placeholder if none exist + if (hasImageInput) { + imageInputs.forEach((input, index) => { + handles.push({ + id: imageInputs.length > 1 ? `image-${index}` : "image", + type: "image", + label: input.label, + schemaName: input.name, + description: input.description || null, + isPlaceholder: false, + }); + }); + } else { + // No image inputs in schema - add placeholder to preserve connections + handles.push({ + id: "image", + type: "image", + label: "Image", + schemaName: null, + description: "Not used by this model", + isPlaceholder: true, + }); + } + + // Add text handles from schema, or a placeholder if none exist + if (hasTextInput) { + textInputs.forEach((input, index) => { + handles.push({ + id: textInputs.length > 1 ? `text-${index}` : "text", + type: "text", + label: input.label, + schemaName: input.name, + description: input.description || null, + isPlaceholder: false, + }); + }); + } else { + // No text inputs in schema - add placeholder to preserve connections + handles.push({ + id: "text", + type: "text", + label: "Prompt", + schemaName: null, + description: "Not used by this model", + isPlaceholder: true, + }); + } + + // Calculate positions + const imageHandles = handles.filter(h => h.type === "image"); + const textHandles = handles.filter(h => h.type === "text"); + const totalSlots = imageHandles.length + textHandles.length + 1; // +1 for gap + + return handles.map((handle, index) => { + // Position: images first, then gap, then text + const isImage = handle.type === "image"; + const typeIndex = isImage + ? imageHandles.findIndex(h => h.id === handle.id) + : textHandles.findIndex(h => h.id === handle.id); + const adjustedIndex = isImage ? typeIndex : imageHandles.length + 1 + typeIndex; const topPercent = ((adjustedIndex + 1) / (totalSlots + 1)) * 100; - const isImage = input.type === "image"; return ( - + {/* Handle label - positioned outside node, above the connector */}
- {input.label} + {handle.label}
); diff --git a/src/components/nodes/GenerateVideoNode.tsx b/src/components/nodes/GenerateVideoNode.tsx index 1019a327..12dbd1f7 100644 --- a/src/components/nodes/GenerateVideoNode.tsx +++ b/src/components/nodes/GenerateVideoNode.tsx @@ -354,32 +354,103 @@ export function GenerateVideoNode({ id, data, selected }: NodeProps 0 ? ( // Render handles from schema, sorted by type (images first, text second) + // IMPORTANT: Always render "image" and "text" handles to maintain connection + // compatibility. Schema may only have text inputs (text-to-video models) but + // we still need the image handle to preserve connections made before model selection. (() => { const imageInputs = nodeData.inputSchema!.filter(i => i.type === "image"); const textInputs = nodeData.inputSchema!.filter(i => i.type === "text"); - const sortedInputs = [...imageInputs, ...textInputs]; - // Calculate positions with gap between image and text groups - const imageCount = imageInputs.length; - const textCount = textInputs.length; - const totalSlots = imageCount + textCount + (imageCount > 0 && textCount > 0 ? 1 : 0); // +1 for gap - - return sortedInputs.map((input, index) => { - // Add 1 to index for text inputs if there are image inputs (to account for gap) - const adjustedIndex = input.type === "text" && imageCount > 0 ? index + 1 : index; + // Always include at least one image and one text handle for connection stability + const hasImageInput = imageInputs.length > 0; + const hasTextInput = textInputs.length > 0; + + // Build the handles array: schema inputs + fallback defaults if missing + const handles: Array<{ + id: string; + type: "image" | "text"; + label: string; + schemaName: string | null; + description: string | null; + isPlaceholder: boolean; + }> = []; + + // Add image handles from schema, or a placeholder if none exist + if (hasImageInput) { + imageInputs.forEach((input, index) => { + handles.push({ + id: imageInputs.length > 1 ? `image-${index}` : "image", + type: "image", + label: input.label, + schemaName: input.name, + description: input.description || null, + isPlaceholder: false, + }); + }); + } else { + // No image inputs in schema - add placeholder to preserve connections + handles.push({ + id: "image", + type: "image", + label: "Image", + schemaName: null, + description: "Not used by this model", + isPlaceholder: true, + }); + } + + // Add text handles from schema, or a placeholder if none exist + if (hasTextInput) { + textInputs.forEach((input, index) => { + handles.push({ + id: textInputs.length > 1 ? `text-${index}` : "text", + type: "text", + label: input.label, + schemaName: input.name, + description: input.description || null, + isPlaceholder: false, + }); + }); + } else { + // No text inputs in schema - add placeholder to preserve connections + handles.push({ + id: "text", + type: "text", + label: "Prompt", + schemaName: null, + description: "Not used by this model", + isPlaceholder: true, + }); + } + + // Calculate positions + const imageHandles = handles.filter(h => h.type === "image"); + const textHandles = handles.filter(h => h.type === "text"); + const totalSlots = imageHandles.length + textHandles.length + 1; // +1 for gap + + return handles.map((handle, index) => { + // Position: images first, then gap, then text + const isImage = handle.type === "image"; + const typeIndex = isImage + ? imageHandles.findIndex(h => h.id === handle.id) + : textHandles.findIndex(h => h.id === handle.id); + const adjustedIndex = isImage ? typeIndex : imageHandles.length + 1 + typeIndex; const topPercent = ((adjustedIndex + 1) / (totalSlots + 1)) * 100; - const isImage = input.type === "image"; return ( - + {/* Handle label - positioned outside node, above the connector */}
- {input.label} + {handle.label}
); diff --git a/src/store/workflowStore.ts b/src/store/workflowStore.ts index 701289c9..9d9d516e 100644 --- a/src/store/workflowStore.ts +++ b/src/store/workflowStore.ts @@ -828,16 +828,41 @@ export const useWorkflowStore = create((set, get) => ({ let text: string | null = null; const dynamicInputs: Record = {}; + // 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 + // Handles use normalized IDs ("image", "image-0", "text", "text-0") + // but API needs schema names ("image_url", "first_frame", "prompt", etc.) + const handleToSchemaName: Record = {}; + if (inputSchema && inputSchema.length > 0) { + const imageInputs = inputSchema.filter(i => i.type === "image"); + const textInputs = inputSchema.filter(i => i.type === "text"); + + // Map image handles to schema names + imageInputs.forEach((input, index) => { + const handleId = imageInputs.length > 1 ? `image-${index}` : "image"; + handleToSchemaName[handleId] = input.name; + }); + + // Map text handles to schema names + textInputs.forEach((input, index) => { + const handleId = textInputs.length > 1 ? `text-${index}` : "text"; + handleToSchemaName[handleId] = input.name; + }); + } + // Helper to determine if a handle ID is an image type const isImageHandle = (handleId: string | null | undefined): boolean => { if (!handleId) return false; - return handleId === "image" || handleId.includes("image") || handleId.includes("frame"); + return handleId === "image" || handleId.startsWith("image-") || handleId.includes("frame"); }; // Helper to determine if a handle ID is a text type const isTextHandle = (handleId: string | null | undefined): boolean => { if (!handleId) return false; - return handleId === "text" || handleId.includes("prompt"); + return handleId === "text" || handleId.startsWith("text-") || handleId.includes("prompt"); }; // Helper to extract output from source node @@ -870,9 +895,10 @@ export const useWorkflowStore = create((set, get) => ({ if (!value) return; - // Store in dynamic inputs if handle ID is specific (not just "image" or "text") - if (handleId && handleId !== "image" && handleId !== "text") { - dynamicInputs[handleId] = value; + // Map normalized handle ID to schema name for dynamicInputs + // This allows API to receive schema-specific parameter names + if (handleId && handleToSchemaName[handleId]) { + dynamicInputs[handleToSchemaName[handleId]] = value; } // Also populate legacy arrays for backward compatibility