From 5a6c8ee44e827f7ba2026f837bd1f6c5e5c4ada0 Mon Sep 17 00:00:00 2001 From: Shrimbly Date: Sun, 22 Feb 2026 20:36:17 +1300 Subject: [PATCH 01/23] fix: output node now auto-loads data when connected and adds Run button - Output Node now automatically triggers execution when a new connection is made - Added Run button to Output Node header for manual triggering - Updated regenerateNode to support output node type - Prevents re-triggering on initial mount or disconnections using edge count tracking Resolves issue where Output Node would not display data from already-generated upstream nodes unless connected before generation. Co-Authored-By: Claude Sonnet 4.5 --- src/components/nodes/OutputNode.tsx | 31 ++++++++++++++++++++++++++++- src/store/workflowStore.ts | 5 +++++ 2 files changed, 35 insertions(+), 1 deletion(-) diff --git a/src/components/nodes/OutputNode.tsx b/src/components/nodes/OutputNode.tsx index 463bf13b..81dc46cb 100644 --- a/src/components/nodes/OutputNode.tsx +++ b/src/components/nodes/OutputNode.tsx @@ -1,6 +1,6 @@ "use client"; -import { useCallback, useState, useMemo } from "react"; +import { useCallback, useState, useMemo, useEffect, useRef } from "react"; import { Handle, Position, NodeProps, Node } from "@xyflow/react"; import { BaseNode } from "./BaseNode"; import { useCommentNavigation } from "@/hooks/useCommentNavigation"; @@ -14,7 +14,10 @@ export function OutputNode({ id, data, selected }: NodeProps) { const nodeData = data; const commentNavigation = useCommentNavigation(id); const updateNodeData = useWorkflowStore((state) => state.updateNodeData); + const regenerateNode = useWorkflowStore((state) => state.regenerateNode); + const edges = useWorkflowStore((state) => state.edges); const [showLightbox, setShowLightbox] = useState(false); + const previousEdgeCountRef = useRef(0); // Determine if content is audio const isAudio = useMemo(() => { @@ -43,6 +46,31 @@ export function OutputNode({ id, data, selected }: NodeProps) { const videoBlobUrl = useVideoBlobUrl(isVideo ? contentSrc ?? null : null); + // Initialize edge count ref on mount + useEffect(() => { + const connectedEdges = edges.filter((edge) => edge.target === id); + previousEdgeCountRef.current = connectedEdges.length; + }, []); // eslint-disable-line react-hooks/exhaustive-deps + + // Auto-trigger execution when connected + useEffect(() => { + const connectedEdges = edges.filter((edge) => edge.target === id); + const currentEdgeCount = connectedEdges.length; + + // Only trigger if we gained a new connection (not on initial mount or disconnect) + if (currentEdgeCount > previousEdgeCountRef.current) { + // Auto-run when a new connection is made + regenerateNode(id); + } + + previousEdgeCountRef.current = currentEdgeCount; + }, [edges, id, regenerateNode]); + + // Handle Run button click + const handleRun = useCallback(() => { + regenerateNode(id); + }, [id, regenerateNode]); + const handleDownload = useCallback(async () => { if (!contentSrc) return; @@ -91,6 +119,7 @@ export function OutputNode({ id, data, selected }: NodeProps) { comment={nodeData.comment} onCustomTitleChange={(title) => updateNodeData(id, { customTitle: title || undefined })} onCommentChange={(comment) => updateNodeData(id, { comment: comment || undefined })} + onRun={handleRun} selected={selected} className="min-w-[200px]" commentNavigation={commentNavigation ?? undefined} diff --git a/src/store/workflowStore.ts b/src/store/workflowStore.ts index 8df3f97e..84579680 100644 --- a/src/store/workflowStore.ts +++ b/src/store/workflowStore.ts @@ -1167,6 +1167,11 @@ export const useWorkflowStore = create((set, get) => ({ set({ isRunning: false, currentNodeIds: [] }); await logger.endSession(); return; + } else if (node.type === "output") { + await executeOutput(executionCtx); + set({ isRunning: false, currentNodeIds: [] }); + await logger.endSession(); + return; } // After regeneration, execute directly connected downstream consumer nodes From 1075f0daf4e6bf488879f776cbb010d2aeac9742 Mon Sep 17 00:00:00 2001 From: shrimbly Date: Sun, 22 Feb 2026 21:12:38 +1300 Subject: [PATCH 02/23] fix: narrow edge subscription, eliminate mount race, and add disabled state to Output node - Replace broad `state.edges` subscription with count-only selector scoped to this node's target edges, preventing unnecessary re-renders from unrelated edge changes - Merge two useEffect hooks into one with a null sentinel ref to prevent false regeneration on mount when loading saved workflows with existing connections - Pass isExecuting={isRunning} to BaseNode so the Run button grays out during execution - Update test mock to include edges, regenerateNode, and isRunning Co-Authored-By: Claude Opus 4.6 --- src/components/__tests__/OutputNode.test.tsx | 3 ++ src/components/nodes/OutputNode.tsx | 33 +++++++++----------- 2 files changed, 18 insertions(+), 18 deletions(-) diff --git a/src/components/__tests__/OutputNode.test.tsx b/src/components/__tests__/OutputNode.test.tsx index ed0cf507..7260f34b 100644 --- a/src/components/__tests__/OutputNode.test.tsx +++ b/src/components/__tests__/OutputNode.test.tsx @@ -39,6 +39,9 @@ describe("OutputNode", () => { mockUseWorkflowStore.mockImplementation((selector) => { const state = { updateNodeData: mockUpdateNodeData, + regenerateNode: vi.fn(), + edges: [], + isRunning: false, currentNodeIds: [], groups: {}, nodes: [], diff --git a/src/components/nodes/OutputNode.tsx b/src/components/nodes/OutputNode.tsx index 81dc46cb..83bc1c97 100644 --- a/src/components/nodes/OutputNode.tsx +++ b/src/components/nodes/OutputNode.tsx @@ -15,9 +15,12 @@ export function OutputNode({ id, data, selected }: NodeProps) { const commentNavigation = useCommentNavigation(id); const updateNodeData = useWorkflowStore((state) => state.updateNodeData); const regenerateNode = useWorkflowStore((state) => state.regenerateNode); - const edges = useWorkflowStore((state) => state.edges); + const connectedEdgeCount = useWorkflowStore( + (state) => state.edges.filter((edge) => edge.target === id).length + ); + const isRunning = useWorkflowStore((state) => state.isRunning); const [showLightbox, setShowLightbox] = useState(false); - const previousEdgeCountRef = useRef(0); + const previousEdgeCountRef = useRef(null); // Determine if content is audio const isAudio = useMemo(() => { @@ -46,25 +49,18 @@ export function OutputNode({ id, data, selected }: NodeProps) { const videoBlobUrl = useVideoBlobUrl(isVideo ? contentSrc ?? null : null); - // Initialize edge count ref on mount - useEffect(() => { - const connectedEdges = edges.filter((edge) => edge.target === id); - previousEdgeCountRef.current = connectedEdges.length; - }, []); // eslint-disable-line react-hooks/exhaustive-deps - - // Auto-trigger execution when connected + // Auto-trigger execution when a new connection is made useEffect(() => { - const connectedEdges = edges.filter((edge) => edge.target === id); - const currentEdgeCount = connectedEdges.length; - - // Only trigger if we gained a new connection (not on initial mount or disconnect) - if (currentEdgeCount > previousEdgeCountRef.current) { - // Auto-run when a new connection is made + if (previousEdgeCountRef.current === null) { + // First run — just record the baseline, don't trigger + previousEdgeCountRef.current = connectedEdgeCount; + return; + } + if (connectedEdgeCount > previousEdgeCountRef.current) { regenerateNode(id); } - - previousEdgeCountRef.current = currentEdgeCount; - }, [edges, id, regenerateNode]); + previousEdgeCountRef.current = connectedEdgeCount; + }, [connectedEdgeCount, id, regenerateNode]); // Handle Run button click const handleRun = useCallback(() => { @@ -120,6 +116,7 @@ export function OutputNode({ id, data, selected }: NodeProps) { onCustomTitleChange={(title) => updateNodeData(id, { customTitle: title || undefined })} onCommentChange={(comment) => updateNodeData(id, { comment: comment || undefined })} onRun={handleRun} + isExecuting={isRunning} selected={selected} className="min-w-[200px]" commentNavigation={commentNavigation ?? undefined} From c0ec984192fbb650e05a47c5e8ac348c15b1cfaf Mon Sep 17 00:00:00 2001 From: shrimbly Date: Mon, 23 Feb 2026 12:29:59 +1300 Subject: [PATCH 03/23] feat(quick-010): install zundo and create binary data stripping utility - Install zundo temporal middleware for Zustand - Create src/store/undoUtils.ts with: - BINARY_DATA_KEYS: Set of 20+ binary field names to exclude - stripBinaryData(): Deep-clone nodes without binary data - partializeForUndo(): Extract trackable state slice - undoStateEquality(): Fast referential equality check - UndoState type for type safety --- package-lock.json | 21 ++++++- package.json | 3 +- src/store/undoUtils.ts | 121 +++++++++++++++++++++++++++++++++++++++++ 3 files changed, 143 insertions(+), 2 deletions(-) create mode 100644 src/store/undoUtils.ts diff --git a/package-lock.json b/package-lock.json index 8818b7bd..d51dc97a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -14,7 +14,6 @@ "@react-three/drei": "^10.7.7", "@react-three/fiber": "^9.5.0", "@tailwindcss/postcss": "^4.1.17", - "@types/three": "^0.182.0", "@xyflow/react": "^12.9.3", "ai": "^6.0.49", "autoprefixer": "^10.4.22", @@ -30,6 +29,7 @@ "react-markdown": "^10.1.0", "tailwindcss": "^4.1.17", "three": "^0.182.0", + "zundo": "^2.3.0", "zustand": "^5.0.9" }, "devDependencies": { @@ -39,6 +39,7 @@ "@types/node": "^24.10.1", "@types/react": "^19.2.7", "@types/react-dom": "^19.2.3", + "@types/three": "^0.182.0", "@vitejs/plugin-react": "^4.7.0", "@vitest/coverage-v8": "^4.0.16", "jsdom": "^27.4.0", @@ -7810,6 +7811,24 @@ "url": "https://github.com/sponsors/colinhacks" } }, + "node_modules/zundo": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/zundo/-/zundo-2.3.0.tgz", + "integrity": "sha512-4GXYxXA17SIKYhVbWHdSEU04P697IMyVGXrC2TnzoyohEAWytFNOKqOp5gTGvaW93F/PM5Y0evbGtOPF0PWQwQ==", + "license": "MIT", + "funding": { + "type": "individual", + "url": "https://github.com/sponsors/charkour" + }, + "peerDependencies": { + "zustand": "^4.3.0 || ^5.0.0" + }, + "peerDependenciesMeta": { + "zustand": { + "optional": false + } + } + }, "node_modules/zustand": { "version": "5.0.9", "resolved": "https://registry.npmjs.org/zustand/-/zustand-5.0.9.tgz", diff --git a/package.json b/package.json index 9e21827e..e3765c2e 100644 --- a/package.json +++ b/package.json @@ -33,6 +33,7 @@ "react-markdown": "^10.1.0", "tailwindcss": "^4.1.17", "three": "^0.182.0", + "zundo": "^2.3.0", "zustand": "^5.0.9" }, "devDependencies": { @@ -40,9 +41,9 @@ "@testing-library/react": "^16.3.1", "@types/jszip": "^3.4.0", "@types/node": "^24.10.1", - "@types/three": "^0.182.0", "@types/react": "^19.2.7", "@types/react-dom": "^19.2.3", + "@types/three": "^0.182.0", "@vitejs/plugin-react": "^4.7.0", "@vitest/coverage-v8": "^4.0.16", "jsdom": "^27.4.0", diff --git a/src/store/undoUtils.ts b/src/store/undoUtils.ts new file mode 100644 index 00000000..c576221c --- /dev/null +++ b/src/store/undoUtils.ts @@ -0,0 +1,121 @@ +import type { WorkflowNode, WorkflowEdge, NodeGroup } from "@/types"; +import type { EdgeStyle } from "./workflowStore"; + +/** + * Binary data field names that must be excluded from undo snapshots. + * These fields contain base64 data URLs that are large and unnecessary for undo/redo. + */ +export const BINARY_DATA_KEYS = new Set([ + // Image fields + "image", + "outputImage", + "sourceImage", + "inputImages", + "images", + "imageA", + "imageB", + "capturedImage", + + // Video fields + "outputVideo", + "video", + + // Audio fields + "audioFile", + "outputAudio", + "audio", + + // 3D fields + "glbUrl", + "output3dUrl", + + // History arrays (can be large) + "imageHistory", + "videoHistory", + "audioHistory", + "globalImageHistory", + + // Thumbnail fields + "thumbnail", + "thumbnails", +]); + +/** + * Strips binary data from node data objects to reduce undo snapshot size. + * Creates new node objects without mutating the originals. + * + * @param nodes - Array of workflow nodes + * @returns New array of nodes with binary data fields removed from node.data + */ +export function stripBinaryData(nodes: WorkflowNode[]): WorkflowNode[] { + return nodes.map(node => { + const strippedData: Record = {}; + + // Copy all non-binary fields + for (const key of Object.keys(node.data)) { + if (!BINARY_DATA_KEYS.has(key)) { + strippedData[key] = node.data[key]; + } + // Binary fields are simply omitted (will be undefined in the result) + } + + return { + ...node, + data: strippedData as typeof node.data, + }; + }); +} + +/** + * State shape tracked by undo/redo. + * Only includes fields that affect the workflow graph structure. + */ +export type UndoState = { + nodes: WorkflowNode[]; + edges: WorkflowEdge[]; + edgeStyle: EdgeStyle; + groups: Record; +}; + +/** + * Partializes the store state for undo tracking. + * Returns only the fields we want to track in undo history. + * + * @param state - Full workflow store state + * @returns Partialized state with binary data stripped from nodes + */ +export function partializeForUndo(state: any): UndoState { + return { + nodes: stripBinaryData(state.nodes), + edges: state.edges, + edgeStyle: state.edgeStyle, + groups: state.groups, + }; +} + +/** + * Fast equality check for undo states using referential comparison. + * Zustand creates new object/array references on every mutation, + * so we can use === checks instead of deep equality. + * + * @param past - Previous undo state + * @param current - Current undo state + * @returns true if states are equal (skip snapshot), false otherwise + */ +export function undoStateEquality(past: UndoState, current: UndoState): boolean { + // Fast checks for primitive/reference changes + if (past.edges !== current.edges) return false; + if (past.edgeStyle !== current.edgeStyle) return false; + if (past.groups !== current.groups) return false; + + // Check nodes array + if (past.nodes.length !== current.nodes.length) return false; + + // Check each node reference (Zustand creates new refs on change) + for (let i = 0; i < past.nodes.length; i++) { + if (past.nodes[i] !== current.nodes[i]) return false; + } + + // All checks passed - states are equal + return true; +} From 5af5e345275109ac37c960fd2ab8b5c187fbac5c Mon Sep 17 00:00:00 2001 From: shrimbly Date: Mon, 23 Feb 2026 12:34:33 +1300 Subject: [PATCH 04/23] feat(quick-010): wrap workflowStore with temporal middleware and wire keyboard shortcuts - Wrap store creation with temporal() middleware - partialize: Only track nodes (binary-stripped), edges, edgeStyle, groups - limit: 20 undo steps max - equality: Fast referential check using undoStateEquality() - Add keyboard shortcuts: - Ctrl/Cmd+Z: Undo last change - Ctrl/Cmd+Shift+Z: Redo - Ctrl/Cmd+Y: Redo (Windows alt) - Add drag debouncing to prevent per-pixel snapshots: - onNodeDragStart: Pause temporal tracking - onNodeDragStop: Resume tracking and capture final position - Add _nudgeForSnapshot() helper to force snapshot capture - Mark workflow as unsaved after undo/redo --- src/components/WorkflowCanvas.tsx | 43 +++++++++++++++++++++++++++++++ src/store/workflowStore.ts | 31 +++++++++++++++++++--- 2 files changed, 71 insertions(+), 3 deletions(-) diff --git a/src/components/WorkflowCanvas.tsx b/src/components/WorkflowCanvas.tsx index 2ae6d4fd..ca3693fa 100644 --- a/src/components/WorkflowCanvas.tsx +++ b/src/components/WorkflowCanvas.tsx @@ -281,6 +281,10 @@ export function WorkflowCanvas() { // Check if a node was dropped into a group and add it to that group + const handleNodeDragStart = useCallback(() => { + useWorkflowStore.temporal.getState().pause(); + }, []); + const handleNodeDragStop = useCallback( (_event: React.MouseEvent, node: Node) => { // Skip if it's a group node @@ -313,6 +317,11 @@ export function WorkflowCanvas() { if (targetGroupId !== currentGroupId) { setNodeGroupId(node.id, targetGroupId); } + + // Resume undo tracking after drag and capture final position + const temporal = useWorkflowStore.temporal.getState(); + temporal.resume(); + useWorkflowStore.getState()._nudgeForSnapshot(); }, [groups, nodes, setNodeGroupId] ); @@ -1081,6 +1090,39 @@ export function WorkflowCanvas() { return; } + // Handle undo (Ctrl/Cmd + Z, but NOT Ctrl/Cmd + Shift + Z) + if ((event.ctrlKey || event.metaKey) && (event.key === "z" || event.key === "Z") && !event.shiftKey) { + event.preventDefault(); + const { undo, pastStates } = useWorkflowStore.temporal.getState(); + if (pastStates.length > 0) { + undo(); + useWorkflowStore.setState({ hasUnsavedChanges: true }); + } + return; + } + + // Handle redo (Ctrl/Cmd + Shift + Z) + if ((event.ctrlKey || event.metaKey) && (event.key === "z" || event.key === "Z") && event.shiftKey) { + event.preventDefault(); + const { redo, futureStates } = useWorkflowStore.temporal.getState(); + if (futureStates.length > 0) { + redo(); + useWorkflowStore.setState({ hasUnsavedChanges: true }); + } + return; + } + + // Handle redo alt (Ctrl + Y — Windows convention) + if ((event.ctrlKey || event.metaKey) && event.key === "y") { + event.preventDefault(); + const { redo, futureStates } = useWorkflowStore.temporal.getState(); + if (futureStates.length > 0) { + redo(); + useWorkflowStore.setState({ hasUnsavedChanges: true }); + } + return; + } + // Handle copy (Ctrl/Cmd + C) if ((event.ctrlKey || event.metaKey) && event.key === "c") { event.preventDefault(); @@ -1643,6 +1685,7 @@ export function WorkflowCanvas() { onEdgesChange={onEdgesChange} onConnect={handleConnect} onConnectEnd={handleConnectEnd} + onNodeDragStart={handleNodeDragStart} onNodeDragStop={handleNodeDragStop} onSelectionChange={handleSelectionChange} nodeTypes={nodeTypes} diff --git a/src/store/workflowStore.ts b/src/store/workflowStore.ts index 8df3f97e..705479c9 100644 --- a/src/store/workflowStore.ts +++ b/src/store/workflowStore.ts @@ -1,5 +1,6 @@ -import { create } from "zustand"; +import { create, StateCreator } from "zustand"; import { useShallow } from "zustand/shallow"; +import { temporal, TemporalState } from "zundo"; import { Connection, EdgeChange, @@ -43,6 +44,7 @@ import { getCanvasNavigationSettings, saveCanvasNavigationSettings, } from "./utils/localStorage"; +import { partializeForUndo, undoStateEquality } from "./undoUtils"; import { createDefaultNodeData, defaultNodeDimensions, @@ -333,8 +335,14 @@ interface WorkflowStore { // Canvas navigation settings actions updateCanvasNavigationSettings: (settings: CanvasNavigationSettings) => void; + + // Undo/Redo helper actions + _nudgeForSnapshot: () => void; } +// Extend with TemporalState for undo/redo functionality +export type WorkflowStoreWithTemporal = WorkflowStore & TemporalState; + let nodeIdCounter = 0; let groupIdCounter = 0; let autoSaveIntervalId: ReturnType | null = null; @@ -369,7 +377,8 @@ async function waitForPendingImageSyncs(timeout: number = 60000): Promise export { generateWorkflowId, saveGenerateImageDefaults, saveNanoBananaDefaults } from "./utils/localStorage"; export { GROUP_COLORS } from "./utils/nodeDefaults"; -export const useWorkflowStore = create((set, get) => ({ +// Store implementation with temporal middleware for undo/redo +const workflowStoreImpl: StateCreator = (set, get) => ({ nodes: [], edges: [], edgeStyle: "curved" as EdgeStyle, @@ -2111,7 +2120,23 @@ export const useWorkflowStore = create((set, get) => ({ set({ canvasNavigationSettings: settings }); saveCanvasNavigationSettings(settings); }, -})); + + // Undo/Redo helper actions + _nudgeForSnapshot: () => { + set((state) => ({ nodes: state.nodes })); + }, +}); + +export const useWorkflowStore = create()( + temporal( + workflowStoreImpl, + { + partialize: partializeForUndo as any, + limit: 20, + equality: undoStateEquality as any, + } + ) +); /** * Stable hook for provider API keys. From f520fb0378e0a9485b70960f73504d3501eeaf4d Mon Sep 17 00:00:00 2001 From: shrimbly Date: Mon, 23 Feb 2026 12:35:36 +1300 Subject: [PATCH 05/23] test(quick-010): add unit tests for binary stripping and equality utilities - 21 tests covering: - stripBinaryData: Image, video, audio, 3D, history, thumbnail stripping - Preservation of non-binary fields and ref fields - No mutation of original nodes - Empty array handling - undoStateEquality: Referential equality checks for all state fields - partializeForUndo: Output shape verification and binary stripping - BINARY_DATA_KEYS: Complete key set validation - All tests passing --- src/store/__tests__/undoUtils.test.ts | 454 ++++++++++++++++++++++++++ 1 file changed, 454 insertions(+) create mode 100644 src/store/__tests__/undoUtils.test.ts diff --git a/src/store/__tests__/undoUtils.test.ts b/src/store/__tests__/undoUtils.test.ts new file mode 100644 index 00000000..b9c8b2be --- /dev/null +++ b/src/store/__tests__/undoUtils.test.ts @@ -0,0 +1,454 @@ +import { describe, it, expect } from "vitest"; +import { stripBinaryData, undoStateEquality, partializeForUndo, BINARY_DATA_KEYS } from "../undoUtils"; +import type { WorkflowNode, WorkflowEdge, NodeGroup } from "@/types"; +import type { EdgeStyle } from "../workflowStore"; + +describe("undoUtils", () => { + describe("stripBinaryData", () => { + it("strips image fields from node data", () => { + const nodes: WorkflowNode[] = [ + { + id: "node-1", + type: "imageInput", + position: { x: 0, y: 0 }, + data: { + image: "data:image/png;base64,ABC123", + outputImage: "data:image/png;base64,DEF456", + sourceImage: "data:image/png;base64,GHI789", + imageRef: "img_001", + prompt: "test prompt", + }, + }, + ]; + + const stripped = stripBinaryData(nodes); + + expect(stripped[0].data).not.toHaveProperty("image"); + expect(stripped[0].data).not.toHaveProperty("outputImage"); + expect(stripped[0].data).not.toHaveProperty("sourceImage"); + expect(stripped[0].data).toHaveProperty("imageRef", "img_001"); + expect(stripped[0].data).toHaveProperty("prompt", "test prompt"); + }); + + it("strips video fields from node data", () => { + const nodes: WorkflowNode[] = [ + { + id: "node-1", + type: "generateVideo", + position: { x: 0, y: 0 }, + data: { + outputVideo: "data:video/mp4;base64,VIDEO123", + video: "data:video/mp4;base64,VIDEO456", + outputVideoRef: "vid_001", + model: "test-model", + }, + }, + ]; + + const stripped = stripBinaryData(nodes); + + expect(stripped[0].data).not.toHaveProperty("outputVideo"); + expect(stripped[0].data).not.toHaveProperty("video"); + expect(stripped[0].data).toHaveProperty("outputVideoRef", "vid_001"); + expect(stripped[0].data).toHaveProperty("model", "test-model"); + }); + + it("strips audio fields from node data", () => { + const nodes: WorkflowNode[] = [ + { + id: "node-1", + type: "audioInput", + position: { x: 0, y: 0 }, + data: { + audioFile: "data:audio/mp3;base64,AUDIO123", + outputAudio: "data:audio/mp3;base64,AUDIO456", + audio: "data:audio/mp3;base64,AUDIO789", + fileName: "test.mp3", + }, + }, + ]; + + const stripped = stripBinaryData(nodes); + + expect(stripped[0].data).not.toHaveProperty("audioFile"); + expect(stripped[0].data).not.toHaveProperty("outputAudio"); + expect(stripped[0].data).not.toHaveProperty("audio"); + expect(stripped[0].data).toHaveProperty("fileName", "test.mp3"); + }); + + it("strips 3D model fields from node data", () => { + const nodes: WorkflowNode[] = [ + { + id: "node-1", + type: "glbViewer", + position: { x: 0, y: 0 }, + data: { + glbUrl: "data:model/gltf-binary;base64,GLB123", + output3dUrl: "data:model/gltf-binary;base64,GLB456", + modelName: "test.glb", + }, + }, + ]; + + const stripped = stripBinaryData(nodes); + + expect(stripped[0].data).not.toHaveProperty("glbUrl"); + expect(stripped[0].data).not.toHaveProperty("output3dUrl"); + expect(stripped[0].data).toHaveProperty("modelName", "test.glb"); + }); + + it("strips history arrays from node data", () => { + const nodes: WorkflowNode[] = [ + { + id: "node-1", + type: "nanoBanana", + position: { x: 0, y: 0 }, + data: { + imageHistory: [ + { timestamp: "2024-01-01", image: "data:image/png;base64,IMG1" }, + { timestamp: "2024-01-02", image: "data:image/png;base64,IMG2" }, + ], + videoHistory: [ + { timestamp: "2024-01-01", video: "data:video/mp4;base64,VID1" }, + ], + audioHistory: [ + { timestamp: "2024-01-01", audio: "data:audio/mp3;base64,AUD1" }, + ], + prompt: "test prompt", + }, + }, + ]; + + const stripped = stripBinaryData(nodes); + + expect(stripped[0].data).not.toHaveProperty("imageHistory"); + expect(stripped[0].data).not.toHaveProperty("videoHistory"); + expect(stripped[0].data).not.toHaveProperty("audioHistory"); + expect(stripped[0].data).toHaveProperty("prompt", "test prompt"); + }); + + it("strips thumbnail fields from node data", () => { + const nodes: WorkflowNode[] = [ + { + id: "node-1", + type: "videoStitch", + position: { x: 0, y: 0 }, + data: { + thumbnail: "data:image/png;base64,THUMB123", + thumbnails: ["data:image/png;base64,THUMB1", "data:image/png;base64,THUMB2"], + clips: [], + }, + }, + ]; + + const stripped = stripBinaryData(nodes); + + expect(stripped[0].data).not.toHaveProperty("thumbnail"); + expect(stripped[0].data).not.toHaveProperty("thumbnails"); + expect(stripped[0].data).toHaveProperty("clips"); + }); + + it("preserves non-binary fields", () => { + const nodes: WorkflowNode[] = [ + { + id: "node-1", + type: "nanoBanana", + position: { x: 100, y: 200 }, + data: { + image: "data:image/png;base64,ABC123", + prompt: "test prompt", + model: "gemini-2.5-flash-image", + aspectRatio: "1:1", + selectedModel: { provider: "gemini", modelId: "test" }, + seed: 12345, + steps: 20, + guidanceScale: 7.5, + }, + }, + ]; + + const stripped = stripBinaryData(nodes); + + expect(stripped[0].data).not.toHaveProperty("image"); + expect(stripped[0].data).toHaveProperty("prompt", "test prompt"); + expect(stripped[0].data).toHaveProperty("model", "gemini-2.5-flash-image"); + expect(stripped[0].data).toHaveProperty("aspectRatio", "1:1"); + expect(stripped[0].data).toHaveProperty("selectedModel"); + expect(stripped[0].data).toHaveProperty("seed", 12345); + expect(stripped[0].data).toHaveProperty("steps", 20); + expect(stripped[0].data).toHaveProperty("guidanceScale", 7.5); + }); + + it("preserves ref fields", () => { + const nodes: WorkflowNode[] = [ + { + id: "node-1", + type: "imageInput", + position: { x: 0, y: 0 }, + data: { + image: "data:image/png;base64,ABC123", + imageRef: "img_001", + outputImageRef: "img_002", + sourceImageRef: "img_003", + inputImageRefs: ["img_004", "img_005"], + outputVideoRef: "vid_001", + outputAudioRef: "aud_001", + }, + }, + ]; + + const stripped = stripBinaryData(nodes); + + expect(stripped[0].data).not.toHaveProperty("image"); + expect(stripped[0].data).toHaveProperty("imageRef", "img_001"); + expect(stripped[0].data).toHaveProperty("outputImageRef", "img_002"); + expect(stripped[0].data).toHaveProperty("sourceImageRef", "img_003"); + expect(stripped[0].data).toHaveProperty("inputImageRefs"); + expect(stripped[0].data).toHaveProperty("outputVideoRef", "vid_001"); + expect(stripped[0].data).toHaveProperty("outputAudioRef", "aud_001"); + }); + + it("does not mutate original nodes", () => { + const nodes: WorkflowNode[] = [ + { + id: "node-1", + type: "imageInput", + position: { x: 0, y: 0 }, + data: { + image: "data:image/png;base64,ABC123", + prompt: "test", + }, + }, + ]; + + const originalImage = nodes[0].data.image; + stripBinaryData(nodes); + + expect(nodes[0].data.image).toBe(originalImage); + expect(nodes[0].data).toHaveProperty("image", "data:image/png;base64,ABC123"); + }); + + it("handles empty nodes array", () => { + const nodes: WorkflowNode[] = []; + const stripped = stripBinaryData(nodes); + + expect(stripped).toEqual([]); + }); + + it("handles nodes with no binary data", () => { + const nodes: WorkflowNode[] = [ + { + id: "node-1", + type: "prompt", + position: { x: 0, y: 0 }, + data: { + prompt: "test prompt", + variableName: "myPrompt", + }, + }, + ]; + + const stripped = stripBinaryData(nodes); + + expect(stripped[0].data).toEqual({ + prompt: "test prompt", + variableName: "myPrompt", + }); + }); + }); + + describe("undoStateEquality", () => { + const createMockState = () => ({ + nodes: [ + { + id: "node-1", + type: "prompt" as const, + position: { x: 0, y: 0 }, + data: { prompt: "test" }, + }, + ], + edges: [ + { + id: "edge-1", + source: "node-1", + target: "node-2", + data: {}, + }, + ] as WorkflowEdge[], + edgeStyle: "curved" as EdgeStyle, + groups: {} as Record, + }); + + it("returns true when same object refs (no change)", () => { + const state = createMockState(); + const result = undoStateEquality(state, state); + + expect(result).toBe(true); + }); + + it("returns false when edges array differs", () => { + const state1 = createMockState(); + const state2 = createMockState(); + state2.edges = [...state2.edges]; // New array ref + + const result = undoStateEquality(state1, state2); + + expect(result).toBe(false); + }); + + it("returns false when edgeStyle differs", () => { + const state1 = createMockState(); + const state2 = createMockState(); + state2.edgeStyle = "angular"; + + const result = undoStateEquality(state1, state2); + + expect(result).toBe(false); + }); + + it("returns false when groups object differs", () => { + const state1 = createMockState(); + const state2 = createMockState(); + state2.groups = { ...state2.groups }; // New object ref + + const result = undoStateEquality(state1, state2); + + expect(result).toBe(false); + }); + + it("returns false when node count differs", () => { + const state1 = createMockState(); + const state2 = createMockState(); + state2.nodes = [ + ...state2.nodes, + { + id: "node-2", + type: "prompt" as const, + position: { x: 100, y: 100 }, + data: { prompt: "test2" }, + }, + ]; + + const result = undoStateEquality(state1, state2); + + expect(result).toBe(false); + }); + + it("returns false when any node ref differs (position change)", () => { + const state1 = createMockState(); + const state2 = createMockState(); + state2.nodes = [ + { + ...state2.nodes[0], + position: { x: 100, y: 100 }, + }, + ]; + + const result = undoStateEquality(state1, state2); + + expect(result).toBe(false); + }); + + it("returns true when all node refs are identical even with different wrapper array", () => { + const state1 = createMockState(); + const state2 = { + ...state1, + nodes: [...state1.nodes], // New array ref but same node refs + }; + + const result = undoStateEquality(state1, state2); + + expect(result).toBe(true); + }); + }); + + describe("partializeForUndo", () => { + it("returns object with only nodes, edges, edgeStyle, groups", () => { + const mockState = { + nodes: [], + edges: [], + edgeStyle: "curved" as EdgeStyle, + groups: {}, + isRunning: false, + clipboard: null, + openModalCount: 0, + workflowId: "test-id", + hasUnsavedChanges: true, + }; + + const result = partializeForUndo(mockState); + + expect(Object.keys(result)).toEqual(["nodes", "edges", "edgeStyle", "groups"]); + expect(result).not.toHaveProperty("isRunning"); + expect(result).not.toHaveProperty("clipboard"); + expect(result).not.toHaveProperty("openModalCount"); + expect(result).not.toHaveProperty("workflowId"); + expect(result).not.toHaveProperty("hasUnsavedChanges"); + }); + + it("returned nodes have binary data stripped", () => { + const mockState = { + nodes: [ + { + id: "node-1", + type: "imageInput" as const, + position: { x: 0, y: 0 }, + data: { + image: "data:image/png;base64,ABC123", + imageRef: "img_001", + prompt: "test", + }, + }, + ], + edges: [], + edgeStyle: "curved" as EdgeStyle, + groups: {}, + isRunning: false, + }; + + const result = partializeForUndo(mockState); + + expect(result.nodes[0].data).not.toHaveProperty("image"); + expect(result.nodes[0].data).toHaveProperty("imageRef", "img_001"); + expect(result.nodes[0].data).toHaveProperty("prompt", "test"); + }); + }); + + describe("BINARY_DATA_KEYS", () => { + it("contains all expected binary field names", () => { + const expectedKeys = [ + // Image fields + "image", + "outputImage", + "sourceImage", + "inputImages", + "images", + "imageA", + "imageB", + "capturedImage", + // Video fields + "outputVideo", + "video", + // Audio fields + "audioFile", + "outputAudio", + "audio", + // 3D fields + "glbUrl", + "output3dUrl", + // History arrays + "imageHistory", + "videoHistory", + "audioHistory", + "globalImageHistory", + // Thumbnail fields + "thumbnail", + "thumbnails", + ]; + + expectedKeys.forEach((key) => { + expect(BINARY_DATA_KEYS.has(key)).toBe(true); + }); + }); + }); +}); From 2a4b87ac789987cb6fd13c9ac77f922116838ae8 Mon Sep 17 00:00:00 2001 From: Shrimbly Date: Mon, 23 Feb 2026 19:41:36 +1300 Subject: [PATCH 06/23] fix: add missing node and handle types to quickstart validation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit VALID_NODE_TYPES was missing audioInput, promptConstructor, outputGallery, imageCompare, videoStitch, easeCurve, videoTrim, videoFrameGrab, and glbViewer — all of which are defined in NodeType and have entries in DEFAULT_DIMENSIONS and createDefaultNodeData. VALID_HANDLE_TYPES was missing video, easeCurve, and 3d handle types used by EaseCurveNode, GenerateVideoNode, Generate3DNode, GLBViewerNode, VideoStitchNode, VideoTrimNode, and VideoFrameGrabNode. Co-Authored-By: Claude Sonnet 4.6 --- src/lib/quickstart/validation.ts | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/src/lib/quickstart/validation.ts b/src/lib/quickstart/validation.ts index 64a5683f..4afce636 100644 --- a/src/lib/quickstart/validation.ts +++ b/src/lib/quickstart/validation.ts @@ -13,9 +13,11 @@ interface ValidationResult { const VALID_NODE_TYPES: NodeType[] = [ "imageInput", + "audioInput", "annotation", "prompt", "array", + "promptConstructor", "nanoBanana", "generateVideo", "generate3d", @@ -23,9 +25,16 @@ const VALID_NODE_TYPES: NodeType[] = [ "llmGenerate", "splitGrid", "output", + "outputGallery", + "imageCompare", + "videoStitch", + "easeCurve", + "videoTrim", + "videoFrameGrab", + "glbViewer", ]; -const VALID_HANDLE_TYPES = ["image", "text", "audio", "reference"]; +const VALID_HANDLE_TYPES = ["image", "text", "audio", "video", "easeCurve", "3d", "reference"]; // Default node dimensions const DEFAULT_DIMENSIONS: Record = { From 293ed20dc6ec1a945e355aaa6d8a2819a2d90c30 Mon Sep 17 00:00:00 2001 From: shrimbly Date: Mon, 23 Feb 2026 19:53:25 +1300 Subject: [PATCH 07/23] fix: preserve media during undo/redo with smart ref-based stripping Replace BINARY_DATA_KEYS (strips all binary) with BINARY_TO_REF mapping that only strips binary data when a corresponding ref exists (recoverable from disk). Fields without refs (imageA, imageB, capturedImage, etc.) are kept in snapshots since they're unrecoverable. After undo/redo, hydrate missing binary data from refs using existing hydrateWorkflowImages(). Co-Authored-By: Claude Opus 4.6 --- src/components/WorkflowCanvas.tsx | 19 +- src/store/__tests__/undoUtils.test.ts | 374 ++++++++++++++++++-------- src/store/undoUtils.ts | 117 +++++--- 3 files changed, 345 insertions(+), 165 deletions(-) diff --git a/src/components/WorkflowCanvas.tsx b/src/components/WorkflowCanvas.tsx index ca3693fa..e268ce88 100644 --- a/src/components/WorkflowCanvas.tsx +++ b/src/components/WorkflowCanvas.tsx @@ -18,6 +18,7 @@ import { import "@xyflow/react/dist/style.css"; import { useWorkflowStore, WorkflowFile } from "@/store/workflowStore"; +import { undoWithMedia, redoWithMedia } from "@/store/undoUtils"; import { useToast } from "@/components/Toast"; import dynamic from "next/dynamic"; import { @@ -1093,33 +1094,21 @@ export function WorkflowCanvas() { // Handle undo (Ctrl/Cmd + Z, but NOT Ctrl/Cmd + Shift + Z) if ((event.ctrlKey || event.metaKey) && (event.key === "z" || event.key === "Z") && !event.shiftKey) { event.preventDefault(); - const { undo, pastStates } = useWorkflowStore.temporal.getState(); - if (pastStates.length > 0) { - undo(); - useWorkflowStore.setState({ hasUnsavedChanges: true }); - } + undoWithMedia(useWorkflowStore); return; } // Handle redo (Ctrl/Cmd + Shift + Z) if ((event.ctrlKey || event.metaKey) && (event.key === "z" || event.key === "Z") && event.shiftKey) { event.preventDefault(); - const { redo, futureStates } = useWorkflowStore.temporal.getState(); - if (futureStates.length > 0) { - redo(); - useWorkflowStore.setState({ hasUnsavedChanges: true }); - } + redoWithMedia(useWorkflowStore); return; } // Handle redo alt (Ctrl + Y — Windows convention) if ((event.ctrlKey || event.metaKey) && event.key === "y") { event.preventDefault(); - const { redo, futureStates } = useWorkflowStore.temporal.getState(); - if (futureStates.length > 0) { - redo(); - useWorkflowStore.setState({ hasUnsavedChanges: true }); - } + redoWithMedia(useWorkflowStore); return; } diff --git a/src/store/__tests__/undoUtils.test.ts b/src/store/__tests__/undoUtils.test.ts index b9c8b2be..d3bc0758 100644 --- a/src/store/__tests__/undoUtils.test.ts +++ b/src/store/__tests__/undoUtils.test.ts @@ -1,11 +1,36 @@ -import { describe, it, expect } from "vitest"; -import { stripBinaryData, undoStateEquality, partializeForUndo, BINARY_DATA_KEYS } from "../undoUtils"; +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { stripBinaryData, undoStateEquality, partializeForUndo, BINARY_TO_REF, undoWithMedia, redoWithMedia } from "../undoUtils"; import type { WorkflowNode, WorkflowEdge, NodeGroup } from "@/types"; import type { EdgeStyle } from "../workflowStore"; +// Mock imageStorage to avoid real file I/O +vi.mock("@/utils/imageStorage", () => ({ + hydrateWorkflowImages: vi.fn(async (workflow: any) => workflow), +})); + describe("undoUtils", () => { + describe("BINARY_TO_REF", () => { + it("maps all ref-backed binary fields", () => { + expect(BINARY_TO_REF).toEqual({ + image: "imageRef", + outputImage: "outputImageRef", + sourceImage: "sourceImageRef", + inputImages: "inputImageRefs", + outputVideo: "outputVideoRef", + outputAudio: "outputAudioRef", + }); + }); + + it("does not include fields without refs (imageA, imageB, etc.)", () => { + const noRefFields = ["imageA", "imageB", "capturedImage", "video", "images", "audioFile", "glbUrl", "output3dUrl"]; + for (const field of noRefFields) { + expect(BINARY_TO_REF).not.toHaveProperty(field); + } + }); + }); + describe("stripBinaryData", () => { - it("strips image fields from node data", () => { + it("strips binary fields that have corresponding refs", () => { const nodes: WorkflowNode[] = [ { id: "node-1", @@ -13,8 +38,6 @@ describe("undoUtils", () => { position: { x: 0, y: 0 }, data: { image: "data:image/png;base64,ABC123", - outputImage: "data:image/png;base64,DEF456", - sourceImage: "data:image/png;base64,GHI789", imageRef: "img_001", prompt: "test prompt", }, @@ -24,80 +47,128 @@ describe("undoUtils", () => { const stripped = stripBinaryData(nodes); expect(stripped[0].data).not.toHaveProperty("image"); - expect(stripped[0].data).not.toHaveProperty("outputImage"); - expect(stripped[0].data).not.toHaveProperty("sourceImage"); expect(stripped[0].data).toHaveProperty("imageRef", "img_001"); expect(stripped[0].data).toHaveProperty("prompt", "test prompt"); }); - it("strips video fields from node data", () => { + it("keeps binary fields that have NO refs", () => { + const nodes: WorkflowNode[] = [ + { + id: "node-1", + type: "imageInput", + position: { x: 0, y: 0 }, + data: { + image: "data:image/png;base64,ABC123", + // No imageRef → keep image in snapshot + prompt: "test prompt", + }, + }, + ]; + + const stripped = stripBinaryData(nodes); + + expect(stripped[0].data).toHaveProperty("image", "data:image/png;base64,ABC123"); + expect(stripped[0].data).toHaveProperty("prompt", "test prompt"); + }); + + it("strips outputImage and sourceImage when refs exist", () => { const nodes: WorkflowNode[] = [ + { + id: "node-1", + type: "annotation", + position: { x: 0, y: 0 }, + data: { + outputImage: "data:image/png;base64,OUT123", + outputImageRef: "img_out_001", + sourceImage: "data:image/png;base64,SRC123", + sourceImageRef: "img_src_001", + prompt: "test", + }, + }, + ]; + + const stripped = stripBinaryData(nodes); + + expect(stripped[0].data).not.toHaveProperty("outputImage"); + expect(stripped[0].data).not.toHaveProperty("sourceImage"); + expect(stripped[0].data).toHaveProperty("outputImageRef", "img_out_001"); + expect(stripped[0].data).toHaveProperty("sourceImageRef", "img_src_001"); + }); + + it("strips outputVideo when ref exists", () => { + const nodes = [ { id: "node-1", type: "generateVideo", position: { x: 0, y: 0 }, data: { outputVideo: "data:video/mp4;base64,VIDEO123", - video: "data:video/mp4;base64,VIDEO456", outputVideoRef: "vid_001", model: "test-model", }, }, - ]; + ] as unknown as WorkflowNode[]; const stripped = stripBinaryData(nodes); expect(stripped[0].data).not.toHaveProperty("outputVideo"); - expect(stripped[0].data).not.toHaveProperty("video"); expect(stripped[0].data).toHaveProperty("outputVideoRef", "vid_001"); expect(stripped[0].data).toHaveProperty("model", "test-model"); }); - it("strips audio fields from node data", () => { - const nodes: WorkflowNode[] = [ + it("strips outputAudio when ref exists", () => { + const nodes = [ { id: "node-1", - type: "audioInput", + type: "generateAudio", position: { x: 0, y: 0 }, data: { - audioFile: "data:audio/mp3;base64,AUDIO123", outputAudio: "data:audio/mp3;base64,AUDIO456", - audio: "data:audio/mp3;base64,AUDIO789", + outputAudioRef: "aud_001", fileName: "test.mp3", }, }, - ]; + ] as unknown as WorkflowNode[]; const stripped = stripBinaryData(nodes); - expect(stripped[0].data).not.toHaveProperty("audioFile"); expect(stripped[0].data).not.toHaveProperty("outputAudio"); - expect(stripped[0].data).not.toHaveProperty("audio"); + expect(stripped[0].data).toHaveProperty("outputAudioRef", "aud_001"); expect(stripped[0].data).toHaveProperty("fileName", "test.mp3"); }); - it("strips 3D model fields from node data", () => { + it("keeps fields without refs: imageA, imageB, capturedImage, video, glbUrl, etc.", () => { const nodes: WorkflowNode[] = [ { id: "node-1", - type: "glbViewer", + type: "imageCompare", position: { x: 0, y: 0 }, data: { - glbUrl: "data:model/gltf-binary;base64,GLB123", - output3dUrl: "data:model/gltf-binary;base64,GLB456", - modelName: "test.glb", + imageA: "data:image/png;base64,IMGA", + imageB: "data:image/png;base64,IMGB", + capturedImage: "data:image/png;base64,CAP", + video: "data:video/mp4;base64,VID", + glbUrl: "data:model/gltf-binary;base64,GLB", + output3dUrl: "data:model/gltf-binary;base64,GLB2", + audioFile: "data:audio/mp3;base64,AUD", + images: ["data:image/png;base64,IMG1"], }, }, ]; const stripped = stripBinaryData(nodes); - expect(stripped[0].data).not.toHaveProperty("glbUrl"); - expect(stripped[0].data).not.toHaveProperty("output3dUrl"); - expect(stripped[0].data).toHaveProperty("modelName", "test.glb"); + expect(stripped[0].data).toHaveProperty("imageA", "data:image/png;base64,IMGA"); + expect(stripped[0].data).toHaveProperty("imageB", "data:image/png;base64,IMGB"); + expect(stripped[0].data).toHaveProperty("capturedImage", "data:image/png;base64,CAP"); + expect(stripped[0].data).toHaveProperty("video", "data:video/mp4;base64,VID"); + expect(stripped[0].data).toHaveProperty("glbUrl", "data:model/gltf-binary;base64,GLB"); + expect(stripped[0].data).toHaveProperty("output3dUrl", "data:model/gltf-binary;base64,GLB2"); + expect(stripped[0].data).toHaveProperty("audioFile", "data:audio/mp3;base64,AUD"); + expect(stripped[0].data).toHaveProperty("images"); }); - it("strips history arrays from node data", () => { + it("preserves carousel history arrays (metadata only)", () => { const nodes: WorkflowNode[] = [ { id: "node-1", @@ -105,14 +176,14 @@ describe("undoUtils", () => { position: { x: 0, y: 0 }, data: { imageHistory: [ - { timestamp: "2024-01-01", image: "data:image/png;base64,IMG1" }, - { timestamp: "2024-01-02", image: "data:image/png;base64,IMG2" }, + { timestamp: "2024-01-01", imageRef: "img_001" }, + { timestamp: "2024-01-02", imageRef: "img_002" }, ], videoHistory: [ - { timestamp: "2024-01-01", video: "data:video/mp4;base64,VID1" }, + { timestamp: "2024-01-01", videoRef: "vid_001" }, ], audioHistory: [ - { timestamp: "2024-01-01", audio: "data:audio/mp3;base64,AUD1" }, + { timestamp: "2024-01-01", audioRef: "aud_001" }, ], prompt: "test prompt", }, @@ -121,31 +192,39 @@ describe("undoUtils", () => { const stripped = stripBinaryData(nodes); - expect(stripped[0].data).not.toHaveProperty("imageHistory"); - expect(stripped[0].data).not.toHaveProperty("videoHistory"); - expect(stripped[0].data).not.toHaveProperty("audioHistory"); + expect(stripped[0].data).toHaveProperty("imageHistory"); + expect(stripped[0].data).toHaveProperty("videoHistory"); + expect(stripped[0].data).toHaveProperty("audioHistory"); expect(stripped[0].data).toHaveProperty("prompt", "test prompt"); }); - it("strips thumbnail fields from node data", () => { + it("preserves ref fields", () => { const nodes: WorkflowNode[] = [ { id: "node-1", - type: "videoStitch", + type: "imageInput", position: { x: 0, y: 0 }, data: { - thumbnail: "data:image/png;base64,THUMB123", - thumbnails: ["data:image/png;base64,THUMB1", "data:image/png;base64,THUMB2"], - clips: [], + image: "data:image/png;base64,ABC123", + imageRef: "img_001", + outputImageRef: "img_002", + sourceImageRef: "img_003", + inputImageRefs: ["img_004", "img_005"], + outputVideoRef: "vid_001", + outputAudioRef: "aud_001", }, }, ]; const stripped = stripBinaryData(nodes); - expect(stripped[0].data).not.toHaveProperty("thumbnail"); - expect(stripped[0].data).not.toHaveProperty("thumbnails"); - expect(stripped[0].data).toHaveProperty("clips"); + expect(stripped[0].data).not.toHaveProperty("image"); + expect(stripped[0].data).toHaveProperty("imageRef", "img_001"); + expect(stripped[0].data).toHaveProperty("outputImageRef", "img_002"); + expect(stripped[0].data).toHaveProperty("sourceImageRef", "img_003"); + expect(stripped[0].data).toHaveProperty("inputImageRefs"); + expect(stripped[0].data).toHaveProperty("outputVideoRef", "vid_001"); + expect(stripped[0].data).toHaveProperty("outputAudioRef", "aud_001"); }); it("preserves non-binary fields", () => { @@ -156,6 +235,7 @@ describe("undoUtils", () => { position: { x: 100, y: 200 }, data: { image: "data:image/png;base64,ABC123", + imageRef: "img_001", prompt: "test prompt", model: "gemini-2.5-flash-image", aspectRatio: "1:1", @@ -179,35 +259,6 @@ describe("undoUtils", () => { expect(stripped[0].data).toHaveProperty("guidanceScale", 7.5); }); - it("preserves ref fields", () => { - const nodes: WorkflowNode[] = [ - { - id: "node-1", - type: "imageInput", - position: { x: 0, y: 0 }, - data: { - image: "data:image/png;base64,ABC123", - imageRef: "img_001", - outputImageRef: "img_002", - sourceImageRef: "img_003", - inputImageRefs: ["img_004", "img_005"], - outputVideoRef: "vid_001", - outputAudioRef: "aud_001", - }, - }, - ]; - - const stripped = stripBinaryData(nodes); - - expect(stripped[0].data).not.toHaveProperty("image"); - expect(stripped[0].data).toHaveProperty("imageRef", "img_001"); - expect(stripped[0].data).toHaveProperty("outputImageRef", "img_002"); - expect(stripped[0].data).toHaveProperty("sourceImageRef", "img_003"); - expect(stripped[0].data).toHaveProperty("inputImageRefs"); - expect(stripped[0].data).toHaveProperty("outputVideoRef", "vid_001"); - expect(stripped[0].data).toHaveProperty("outputAudioRef", "aud_001"); - }); - it("does not mutate original nodes", () => { const nodes: WorkflowNode[] = [ { @@ -216,6 +267,7 @@ describe("undoUtils", () => { position: { x: 0, y: 0 }, data: { image: "data:image/png;base64,ABC123", + imageRef: "img_001", prompt: "test", }, }, @@ -255,6 +307,38 @@ describe("undoUtils", () => { variableName: "myPrompt", }); }); + + it("handles mixed nodes: some with refs, some without", () => { + const nodes: WorkflowNode[] = [ + { + id: "node-1", + type: "imageInput", + position: { x: 0, y: 0 }, + data: { + image: "data:image/png;base64,SAVED", + imageRef: "img_001", // Has ref → strip + }, + }, + { + id: "node-2", + type: "imageInput", + position: { x: 100, y: 0 }, + data: { + image: "data:image/png;base64,UNSAVED", + // No imageRef → keep + }, + }, + ]; + + const stripped = stripBinaryData(nodes); + + // Node 1: stripped (has ref) + expect(stripped[0].data).not.toHaveProperty("image"); + expect(stripped[0].data).toHaveProperty("imageRef", "img_001"); + + // Node 2: kept (no ref) + expect(stripped[1].data).toHaveProperty("image", "data:image/png;base64,UNSAVED"); + }); }); describe("undoStateEquality", () => { @@ -386,7 +470,7 @@ describe("undoUtils", () => { expect(result).not.toHaveProperty("hasUnsavedChanges"); }); - it("returned nodes have binary data stripped", () => { + it("returned nodes have recoverable binary data stripped", () => { const mockState = { nodes: [ { @@ -412,43 +496,119 @@ describe("undoUtils", () => { expect(result.nodes[0].data).toHaveProperty("imageRef", "img_001"); expect(result.nodes[0].data).toHaveProperty("prompt", "test"); }); + + it("returned nodes keep unrecoverable binary data", () => { + const mockState = { + nodes: [ + { + id: "node-1", + type: "imageInput" as const, + position: { x: 0, y: 0 }, + data: { + image: "data:image/png;base64,UNSAVED", + // No imageRef + prompt: "test", + }, + }, + ], + edges: [], + edgeStyle: "curved" as EdgeStyle, + groups: {}, + isRunning: false, + }; + + const result = partializeForUndo(mockState); + + expect(result.nodes[0].data).toHaveProperty("image", "data:image/png;base64,UNSAVED"); + expect(result.nodes[0].data).toHaveProperty("prompt", "test"); + }); }); - describe("BINARY_DATA_KEYS", () => { - it("contains all expected binary field names", () => { - const expectedKeys = [ - // Image fields - "image", - "outputImage", - "sourceImage", - "inputImages", - "images", - "imageA", - "imageB", - "capturedImage", - // Video fields - "outputVideo", - "video", - // Audio fields - "audioFile", - "outputAudio", - "audio", - // 3D fields - "glbUrl", - "output3dUrl", - // History arrays - "imageHistory", - "videoHistory", - "audioHistory", - "globalImageHistory", - // Thumbnail fields - "thumbnail", - "thumbnails", - ]; + describe("undoWithMedia", () => { + it("calls undo when pastStates is non-empty", () => { + const undo = vi.fn(); + const pause = vi.fn(); + const resume = vi.fn(); + const mockStore = { + getState: vi.fn(() => ({ nodes: [], edges: [], saveDirectoryPath: null })), + setState: vi.fn(), + temporal: { + getState: vi.fn(() => ({ + pastStates: [{}], + undo, + pause, + resume, + })), + }, + } as any; - expectedKeys.forEach((key) => { - expect(BINARY_DATA_KEYS.has(key)).toBe(true); - }); + undoWithMedia(mockStore); + + expect(undo).toHaveBeenCalled(); + expect(mockStore.setState).toHaveBeenCalledWith({ hasUnsavedChanges: true }); + }); + + it("does nothing when pastStates is empty", () => { + const undo = vi.fn(); + const mockStore = { + getState: vi.fn(), + setState: vi.fn(), + temporal: { + getState: vi.fn(() => ({ + pastStates: [], + undo, + })), + }, + } as any; + + undoWithMedia(mockStore); + + expect(undo).not.toHaveBeenCalled(); + expect(mockStore.setState).not.toHaveBeenCalled(); + }); + }); + + describe("redoWithMedia", () => { + it("calls redo when futureStates is non-empty", () => { + const redo = vi.fn(); + const pause = vi.fn(); + const resume = vi.fn(); + const mockStore = { + getState: vi.fn(() => ({ nodes: [], edges: [], saveDirectoryPath: null })), + setState: vi.fn(), + temporal: { + getState: vi.fn(() => ({ + futureStates: [{}], + redo, + pause, + resume, + })), + }, + } as any; + + redoWithMedia(mockStore); + + expect(redo).toHaveBeenCalled(); + expect(mockStore.setState).toHaveBeenCalledWith({ hasUnsavedChanges: true }); + }); + + it("does nothing when futureStates is empty", () => { + const redo = vi.fn(); + const mockStore = { + getState: vi.fn(), + setState: vi.fn(), + temporal: { + getState: vi.fn(() => ({ + futureStates: [], + redo, + })), + }, + } as any; + + redoWithMedia(mockStore); + + expect(redo).not.toHaveBeenCalled(); + expect(mockStore.setState).not.toHaveBeenCalled(); }); }); }); diff --git a/src/store/undoUtils.ts b/src/store/undoUtils.ts index c576221c..95fcfc88 100644 --- a/src/store/undoUtils.ts +++ b/src/store/undoUtils.ts @@ -1,62 +1,45 @@ import type { WorkflowNode, WorkflowEdge, NodeGroup } from "@/types"; import type { EdgeStyle } from "./workflowStore"; +import { hydrateWorkflowImages } from "@/utils/imageStorage"; /** - * Binary data field names that must be excluded from undo snapshots. - * These fields contain base64 data URLs that are large and unnecessary for undo/redo. + * Mapping of binary data fields to their corresponding ref fields. + * Only fields with a ref can be recovered from disk after undo/redo, + * so only these are stripped from snapshots. + * + * Fields WITHOUT refs (imageA, imageB, capturedImage, video, images, + * audioFile, glbUrl, output3dUrl, etc.) are kept in snapshots since + * they're unrecoverable otherwise. */ -export const BINARY_DATA_KEYS = new Set([ - // Image fields - "image", - "outputImage", - "sourceImage", - "inputImages", - "images", - "imageA", - "imageB", - "capturedImage", - - // Video fields - "outputVideo", - "video", - - // Audio fields - "audioFile", - "outputAudio", - "audio", - - // 3D fields - "glbUrl", - "output3dUrl", - - // History arrays (can be large) - "imageHistory", - "videoHistory", - "audioHistory", - "globalImageHistory", - - // Thumbnail fields - "thumbnail", - "thumbnails", -]); +export const BINARY_TO_REF: Record = { + image: "imageRef", + outputImage: "outputImageRef", + sourceImage: "sourceImageRef", + inputImages: "inputImageRefs", + outputVideo: "outputVideoRef", + outputAudio: "outputAudioRef", +}; /** * Strips binary data from node data objects to reduce undo snapshot size. - * Creates new node objects without mutating the originals. + * Only strips fields that have a corresponding ref (recoverable from disk). + * Fields without refs are kept in the snapshot since they can't be recovered. * * @param nodes - Array of workflow nodes - * @returns New array of nodes with binary data fields removed from node.data + * @returns New array of nodes with recoverable binary data stripped */ export function stripBinaryData(nodes: WorkflowNode[]): WorkflowNode[] { return nodes.map(node => { + const data = node.data as Record; const strippedData: Record = {}; - // Copy all non-binary fields - for (const key of Object.keys(node.data)) { - if (!BINARY_DATA_KEYS.has(key)) { - strippedData[key] = node.data[key]; + for (const key of Object.keys(data)) { + const refField = BINARY_TO_REF[key]; + if (refField && data[refField]) { + // Has a ref → strip binary (recoverable from disk) + continue; } - // Binary fields are simply omitted (will be undefined in the result) + strippedData[key] = data[key]; } return { @@ -119,3 +102,51 @@ export function undoStateEquality(past: UndoState, current: UndoState): boolean // All checks passed - states are equal return true; } + +/** + * Hydrate missing binary data from refs after undo/redo. + * Uses the existing hydrateWorkflowImages() which checks `if (ref && !binary)` + * before loading, so it only loads what's actually missing. + */ +async function hydrateAfterUndoRedo(store: typeof import("./workflowStore").useWorkflowStore): Promise { + const state = store.getState(); + const workflowPath = state.saveDirectoryPath; + if (!workflowPath) return; // Unsaved workflow, no refs to hydrate + + try { + const workflow = { version: 1 as const, name: "", nodes: state.nodes, edges: state.edges, edgeStyle: state.edgeStyle }; + const hydrated = await hydrateWorkflowImages(workflow, workflowPath); + + // Pause temporal so hydration doesn't create undo snapshots + const temporal = store.temporal.getState(); + temporal.pause(); + store.setState({ nodes: hydrated.nodes }); + temporal.resume(); + } catch (err) { + console.warn("Failed to hydrate after undo/redo:", err); + } +} + +/** + * Undo with media hydration. Calls undo(), marks unsaved, then + * asynchronously hydrates any binary data that was stripped from the snapshot. + */ +export function undoWithMedia(store: typeof import("./workflowStore").useWorkflowStore): void { + const temporal = store.temporal.getState(); + if (temporal.pastStates.length === 0) return; + temporal.undo(); + store.setState({ hasUnsavedChanges: true }); + hydrateAfterUndoRedo(store); +} + +/** + * Redo with media hydration. Calls redo(), marks unsaved, then + * asynchronously hydrates any binary data that was stripped from the snapshot. + */ +export function redoWithMedia(store: typeof import("./workflowStore").useWorkflowStore): void { + const temporal = store.temporal.getState(); + if (temporal.futureStates.length === 0) return; + temporal.redo(); + store.setState({ hasUnsavedChanges: true }); + hydrateAfterUndoRedo(store); +} From 8baa4e38d016b972bb015fbd9cb187a0aef1940a Mon Sep 17 00:00:00 2001 From: shrimbly Date: Mon, 23 Feb 2026 21:21:00 +1300 Subject: [PATCH 08/23] fix: remove broken zundo undo/redo system Zundo-based undo/redo was causing image flickering, failing to restore deleted nodes, and creating memory issues from large state snapshots. Removes the entire workflow undo/redo system while keeping the independent annotation canvas undo/redo intact. Co-Authored-By: Claude Opus 4.6 --- package-lock.json | 19 - package.json | 1 - src/components/WorkflowCanvas.tsx | 32 -- src/store/__tests__/undoUtils.test.ts | 614 -------------------------- src/store/undoUtils.ts | 152 ------- src/store/workflowStore.ts | 25 +- 6 files changed, 2 insertions(+), 841 deletions(-) delete mode 100644 src/store/__tests__/undoUtils.test.ts delete mode 100644 src/store/undoUtils.ts diff --git a/package-lock.json b/package-lock.json index d51dc97a..59a9e162 100644 --- a/package-lock.json +++ b/package-lock.json @@ -29,7 +29,6 @@ "react-markdown": "^10.1.0", "tailwindcss": "^4.1.17", "three": "^0.182.0", - "zundo": "^2.3.0", "zustand": "^5.0.9" }, "devDependencies": { @@ -7811,24 +7810,6 @@ "url": "https://github.com/sponsors/colinhacks" } }, - "node_modules/zundo": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/zundo/-/zundo-2.3.0.tgz", - "integrity": "sha512-4GXYxXA17SIKYhVbWHdSEU04P697IMyVGXrC2TnzoyohEAWytFNOKqOp5gTGvaW93F/PM5Y0evbGtOPF0PWQwQ==", - "license": "MIT", - "funding": { - "type": "individual", - "url": "https://github.com/sponsors/charkour" - }, - "peerDependencies": { - "zustand": "^4.3.0 || ^5.0.0" - }, - "peerDependenciesMeta": { - "zustand": { - "optional": false - } - } - }, "node_modules/zustand": { "version": "5.0.9", "resolved": "https://registry.npmjs.org/zustand/-/zustand-5.0.9.tgz", diff --git a/package.json b/package.json index e3765c2e..2b9898b1 100644 --- a/package.json +++ b/package.json @@ -33,7 +33,6 @@ "react-markdown": "^10.1.0", "tailwindcss": "^4.1.17", "three": "^0.182.0", - "zundo": "^2.3.0", "zustand": "^5.0.9" }, "devDependencies": { diff --git a/src/components/WorkflowCanvas.tsx b/src/components/WorkflowCanvas.tsx index e268ce88..2ae6d4fd 100644 --- a/src/components/WorkflowCanvas.tsx +++ b/src/components/WorkflowCanvas.tsx @@ -18,7 +18,6 @@ import { import "@xyflow/react/dist/style.css"; import { useWorkflowStore, WorkflowFile } from "@/store/workflowStore"; -import { undoWithMedia, redoWithMedia } from "@/store/undoUtils"; import { useToast } from "@/components/Toast"; import dynamic from "next/dynamic"; import { @@ -282,10 +281,6 @@ export function WorkflowCanvas() { // Check if a node was dropped into a group and add it to that group - const handleNodeDragStart = useCallback(() => { - useWorkflowStore.temporal.getState().pause(); - }, []); - const handleNodeDragStop = useCallback( (_event: React.MouseEvent, node: Node) => { // Skip if it's a group node @@ -318,11 +313,6 @@ export function WorkflowCanvas() { if (targetGroupId !== currentGroupId) { setNodeGroupId(node.id, targetGroupId); } - - // Resume undo tracking after drag and capture final position - const temporal = useWorkflowStore.temporal.getState(); - temporal.resume(); - useWorkflowStore.getState()._nudgeForSnapshot(); }, [groups, nodes, setNodeGroupId] ); @@ -1091,27 +1081,6 @@ export function WorkflowCanvas() { return; } - // Handle undo (Ctrl/Cmd + Z, but NOT Ctrl/Cmd + Shift + Z) - if ((event.ctrlKey || event.metaKey) && (event.key === "z" || event.key === "Z") && !event.shiftKey) { - event.preventDefault(); - undoWithMedia(useWorkflowStore); - return; - } - - // Handle redo (Ctrl/Cmd + Shift + Z) - if ((event.ctrlKey || event.metaKey) && (event.key === "z" || event.key === "Z") && event.shiftKey) { - event.preventDefault(); - redoWithMedia(useWorkflowStore); - return; - } - - // Handle redo alt (Ctrl + Y — Windows convention) - if ((event.ctrlKey || event.metaKey) && event.key === "y") { - event.preventDefault(); - redoWithMedia(useWorkflowStore); - return; - } - // Handle copy (Ctrl/Cmd + C) if ((event.ctrlKey || event.metaKey) && event.key === "c") { event.preventDefault(); @@ -1674,7 +1643,6 @@ export function WorkflowCanvas() { onEdgesChange={onEdgesChange} onConnect={handleConnect} onConnectEnd={handleConnectEnd} - onNodeDragStart={handleNodeDragStart} onNodeDragStop={handleNodeDragStop} onSelectionChange={handleSelectionChange} nodeTypes={nodeTypes} diff --git a/src/store/__tests__/undoUtils.test.ts b/src/store/__tests__/undoUtils.test.ts deleted file mode 100644 index d3bc0758..00000000 --- a/src/store/__tests__/undoUtils.test.ts +++ /dev/null @@ -1,614 +0,0 @@ -import { describe, it, expect, vi, beforeEach } from "vitest"; -import { stripBinaryData, undoStateEquality, partializeForUndo, BINARY_TO_REF, undoWithMedia, redoWithMedia } from "../undoUtils"; -import type { WorkflowNode, WorkflowEdge, NodeGroup } from "@/types"; -import type { EdgeStyle } from "../workflowStore"; - -// Mock imageStorage to avoid real file I/O -vi.mock("@/utils/imageStorage", () => ({ - hydrateWorkflowImages: vi.fn(async (workflow: any) => workflow), -})); - -describe("undoUtils", () => { - describe("BINARY_TO_REF", () => { - it("maps all ref-backed binary fields", () => { - expect(BINARY_TO_REF).toEqual({ - image: "imageRef", - outputImage: "outputImageRef", - sourceImage: "sourceImageRef", - inputImages: "inputImageRefs", - outputVideo: "outputVideoRef", - outputAudio: "outputAudioRef", - }); - }); - - it("does not include fields without refs (imageA, imageB, etc.)", () => { - const noRefFields = ["imageA", "imageB", "capturedImage", "video", "images", "audioFile", "glbUrl", "output3dUrl"]; - for (const field of noRefFields) { - expect(BINARY_TO_REF).not.toHaveProperty(field); - } - }); - }); - - describe("stripBinaryData", () => { - it("strips binary fields that have corresponding refs", () => { - const nodes: WorkflowNode[] = [ - { - id: "node-1", - type: "imageInput", - position: { x: 0, y: 0 }, - data: { - image: "data:image/png;base64,ABC123", - imageRef: "img_001", - prompt: "test prompt", - }, - }, - ]; - - const stripped = stripBinaryData(nodes); - - expect(stripped[0].data).not.toHaveProperty("image"); - expect(stripped[0].data).toHaveProperty("imageRef", "img_001"); - expect(stripped[0].data).toHaveProperty("prompt", "test prompt"); - }); - - it("keeps binary fields that have NO refs", () => { - const nodes: WorkflowNode[] = [ - { - id: "node-1", - type: "imageInput", - position: { x: 0, y: 0 }, - data: { - image: "data:image/png;base64,ABC123", - // No imageRef → keep image in snapshot - prompt: "test prompt", - }, - }, - ]; - - const stripped = stripBinaryData(nodes); - - expect(stripped[0].data).toHaveProperty("image", "data:image/png;base64,ABC123"); - expect(stripped[0].data).toHaveProperty("prompt", "test prompt"); - }); - - it("strips outputImage and sourceImage when refs exist", () => { - const nodes: WorkflowNode[] = [ - { - id: "node-1", - type: "annotation", - position: { x: 0, y: 0 }, - data: { - outputImage: "data:image/png;base64,OUT123", - outputImageRef: "img_out_001", - sourceImage: "data:image/png;base64,SRC123", - sourceImageRef: "img_src_001", - prompt: "test", - }, - }, - ]; - - const stripped = stripBinaryData(nodes); - - expect(stripped[0].data).not.toHaveProperty("outputImage"); - expect(stripped[0].data).not.toHaveProperty("sourceImage"); - expect(stripped[0].data).toHaveProperty("outputImageRef", "img_out_001"); - expect(stripped[0].data).toHaveProperty("sourceImageRef", "img_src_001"); - }); - - it("strips outputVideo when ref exists", () => { - const nodes = [ - { - id: "node-1", - type: "generateVideo", - position: { x: 0, y: 0 }, - data: { - outputVideo: "data:video/mp4;base64,VIDEO123", - outputVideoRef: "vid_001", - model: "test-model", - }, - }, - ] as unknown as WorkflowNode[]; - - const stripped = stripBinaryData(nodes); - - expect(stripped[0].data).not.toHaveProperty("outputVideo"); - expect(stripped[0].data).toHaveProperty("outputVideoRef", "vid_001"); - expect(stripped[0].data).toHaveProperty("model", "test-model"); - }); - - it("strips outputAudio when ref exists", () => { - const nodes = [ - { - id: "node-1", - type: "generateAudio", - position: { x: 0, y: 0 }, - data: { - outputAudio: "data:audio/mp3;base64,AUDIO456", - outputAudioRef: "aud_001", - fileName: "test.mp3", - }, - }, - ] as unknown as WorkflowNode[]; - - const stripped = stripBinaryData(nodes); - - expect(stripped[0].data).not.toHaveProperty("outputAudio"); - expect(stripped[0].data).toHaveProperty("outputAudioRef", "aud_001"); - expect(stripped[0].data).toHaveProperty("fileName", "test.mp3"); - }); - - it("keeps fields without refs: imageA, imageB, capturedImage, video, glbUrl, etc.", () => { - const nodes: WorkflowNode[] = [ - { - id: "node-1", - type: "imageCompare", - position: { x: 0, y: 0 }, - data: { - imageA: "data:image/png;base64,IMGA", - imageB: "data:image/png;base64,IMGB", - capturedImage: "data:image/png;base64,CAP", - video: "data:video/mp4;base64,VID", - glbUrl: "data:model/gltf-binary;base64,GLB", - output3dUrl: "data:model/gltf-binary;base64,GLB2", - audioFile: "data:audio/mp3;base64,AUD", - images: ["data:image/png;base64,IMG1"], - }, - }, - ]; - - const stripped = stripBinaryData(nodes); - - expect(stripped[0].data).toHaveProperty("imageA", "data:image/png;base64,IMGA"); - expect(stripped[0].data).toHaveProperty("imageB", "data:image/png;base64,IMGB"); - expect(stripped[0].data).toHaveProperty("capturedImage", "data:image/png;base64,CAP"); - expect(stripped[0].data).toHaveProperty("video", "data:video/mp4;base64,VID"); - expect(stripped[0].data).toHaveProperty("glbUrl", "data:model/gltf-binary;base64,GLB"); - expect(stripped[0].data).toHaveProperty("output3dUrl", "data:model/gltf-binary;base64,GLB2"); - expect(stripped[0].data).toHaveProperty("audioFile", "data:audio/mp3;base64,AUD"); - expect(stripped[0].data).toHaveProperty("images"); - }); - - it("preserves carousel history arrays (metadata only)", () => { - const nodes: WorkflowNode[] = [ - { - id: "node-1", - type: "nanoBanana", - position: { x: 0, y: 0 }, - data: { - imageHistory: [ - { timestamp: "2024-01-01", imageRef: "img_001" }, - { timestamp: "2024-01-02", imageRef: "img_002" }, - ], - videoHistory: [ - { timestamp: "2024-01-01", videoRef: "vid_001" }, - ], - audioHistory: [ - { timestamp: "2024-01-01", audioRef: "aud_001" }, - ], - prompt: "test prompt", - }, - }, - ]; - - const stripped = stripBinaryData(nodes); - - expect(stripped[0].data).toHaveProperty("imageHistory"); - expect(stripped[0].data).toHaveProperty("videoHistory"); - expect(stripped[0].data).toHaveProperty("audioHistory"); - expect(stripped[0].data).toHaveProperty("prompt", "test prompt"); - }); - - it("preserves ref fields", () => { - const nodes: WorkflowNode[] = [ - { - id: "node-1", - type: "imageInput", - position: { x: 0, y: 0 }, - data: { - image: "data:image/png;base64,ABC123", - imageRef: "img_001", - outputImageRef: "img_002", - sourceImageRef: "img_003", - inputImageRefs: ["img_004", "img_005"], - outputVideoRef: "vid_001", - outputAudioRef: "aud_001", - }, - }, - ]; - - const stripped = stripBinaryData(nodes); - - expect(stripped[0].data).not.toHaveProperty("image"); - expect(stripped[0].data).toHaveProperty("imageRef", "img_001"); - expect(stripped[0].data).toHaveProperty("outputImageRef", "img_002"); - expect(stripped[0].data).toHaveProperty("sourceImageRef", "img_003"); - expect(stripped[0].data).toHaveProperty("inputImageRefs"); - expect(stripped[0].data).toHaveProperty("outputVideoRef", "vid_001"); - expect(stripped[0].data).toHaveProperty("outputAudioRef", "aud_001"); - }); - - it("preserves non-binary fields", () => { - const nodes: WorkflowNode[] = [ - { - id: "node-1", - type: "nanoBanana", - position: { x: 100, y: 200 }, - data: { - image: "data:image/png;base64,ABC123", - imageRef: "img_001", - prompt: "test prompt", - model: "gemini-2.5-flash-image", - aspectRatio: "1:1", - selectedModel: { provider: "gemini", modelId: "test" }, - seed: 12345, - steps: 20, - guidanceScale: 7.5, - }, - }, - ]; - - const stripped = stripBinaryData(nodes); - - expect(stripped[0].data).not.toHaveProperty("image"); - expect(stripped[0].data).toHaveProperty("prompt", "test prompt"); - expect(stripped[0].data).toHaveProperty("model", "gemini-2.5-flash-image"); - expect(stripped[0].data).toHaveProperty("aspectRatio", "1:1"); - expect(stripped[0].data).toHaveProperty("selectedModel"); - expect(stripped[0].data).toHaveProperty("seed", 12345); - expect(stripped[0].data).toHaveProperty("steps", 20); - expect(stripped[0].data).toHaveProperty("guidanceScale", 7.5); - }); - - it("does not mutate original nodes", () => { - const nodes: WorkflowNode[] = [ - { - id: "node-1", - type: "imageInput", - position: { x: 0, y: 0 }, - data: { - image: "data:image/png;base64,ABC123", - imageRef: "img_001", - prompt: "test", - }, - }, - ]; - - const originalImage = nodes[0].data.image; - stripBinaryData(nodes); - - expect(nodes[0].data.image).toBe(originalImage); - expect(nodes[0].data).toHaveProperty("image", "data:image/png;base64,ABC123"); - }); - - it("handles empty nodes array", () => { - const nodes: WorkflowNode[] = []; - const stripped = stripBinaryData(nodes); - - expect(stripped).toEqual([]); - }); - - it("handles nodes with no binary data", () => { - const nodes: WorkflowNode[] = [ - { - id: "node-1", - type: "prompt", - position: { x: 0, y: 0 }, - data: { - prompt: "test prompt", - variableName: "myPrompt", - }, - }, - ]; - - const stripped = stripBinaryData(nodes); - - expect(stripped[0].data).toEqual({ - prompt: "test prompt", - variableName: "myPrompt", - }); - }); - - it("handles mixed nodes: some with refs, some without", () => { - const nodes: WorkflowNode[] = [ - { - id: "node-1", - type: "imageInput", - position: { x: 0, y: 0 }, - data: { - image: "data:image/png;base64,SAVED", - imageRef: "img_001", // Has ref → strip - }, - }, - { - id: "node-2", - type: "imageInput", - position: { x: 100, y: 0 }, - data: { - image: "data:image/png;base64,UNSAVED", - // No imageRef → keep - }, - }, - ]; - - const stripped = stripBinaryData(nodes); - - // Node 1: stripped (has ref) - expect(stripped[0].data).not.toHaveProperty("image"); - expect(stripped[0].data).toHaveProperty("imageRef", "img_001"); - - // Node 2: kept (no ref) - expect(stripped[1].data).toHaveProperty("image", "data:image/png;base64,UNSAVED"); - }); - }); - - describe("undoStateEquality", () => { - const createMockState = () => ({ - nodes: [ - { - id: "node-1", - type: "prompt" as const, - position: { x: 0, y: 0 }, - data: { prompt: "test" }, - }, - ], - edges: [ - { - id: "edge-1", - source: "node-1", - target: "node-2", - data: {}, - }, - ] as WorkflowEdge[], - edgeStyle: "curved" as EdgeStyle, - groups: {} as Record, - }); - - it("returns true when same object refs (no change)", () => { - const state = createMockState(); - const result = undoStateEquality(state, state); - - expect(result).toBe(true); - }); - - it("returns false when edges array differs", () => { - const state1 = createMockState(); - const state2 = createMockState(); - state2.edges = [...state2.edges]; // New array ref - - const result = undoStateEquality(state1, state2); - - expect(result).toBe(false); - }); - - it("returns false when edgeStyle differs", () => { - const state1 = createMockState(); - const state2 = createMockState(); - state2.edgeStyle = "angular"; - - const result = undoStateEquality(state1, state2); - - expect(result).toBe(false); - }); - - it("returns false when groups object differs", () => { - const state1 = createMockState(); - const state2 = createMockState(); - state2.groups = { ...state2.groups }; // New object ref - - const result = undoStateEquality(state1, state2); - - expect(result).toBe(false); - }); - - it("returns false when node count differs", () => { - const state1 = createMockState(); - const state2 = createMockState(); - state2.nodes = [ - ...state2.nodes, - { - id: "node-2", - type: "prompt" as const, - position: { x: 100, y: 100 }, - data: { prompt: "test2" }, - }, - ]; - - const result = undoStateEquality(state1, state2); - - expect(result).toBe(false); - }); - - it("returns false when any node ref differs (position change)", () => { - const state1 = createMockState(); - const state2 = createMockState(); - state2.nodes = [ - { - ...state2.nodes[0], - position: { x: 100, y: 100 }, - }, - ]; - - const result = undoStateEquality(state1, state2); - - expect(result).toBe(false); - }); - - it("returns true when all node refs are identical even with different wrapper array", () => { - const state1 = createMockState(); - const state2 = { - ...state1, - nodes: [...state1.nodes], // New array ref but same node refs - }; - - const result = undoStateEquality(state1, state2); - - expect(result).toBe(true); - }); - }); - - describe("partializeForUndo", () => { - it("returns object with only nodes, edges, edgeStyle, groups", () => { - const mockState = { - nodes: [], - edges: [], - edgeStyle: "curved" as EdgeStyle, - groups: {}, - isRunning: false, - clipboard: null, - openModalCount: 0, - workflowId: "test-id", - hasUnsavedChanges: true, - }; - - const result = partializeForUndo(mockState); - - expect(Object.keys(result)).toEqual(["nodes", "edges", "edgeStyle", "groups"]); - expect(result).not.toHaveProperty("isRunning"); - expect(result).not.toHaveProperty("clipboard"); - expect(result).not.toHaveProperty("openModalCount"); - expect(result).not.toHaveProperty("workflowId"); - expect(result).not.toHaveProperty("hasUnsavedChanges"); - }); - - it("returned nodes have recoverable binary data stripped", () => { - const mockState = { - nodes: [ - { - id: "node-1", - type: "imageInput" as const, - position: { x: 0, y: 0 }, - data: { - image: "data:image/png;base64,ABC123", - imageRef: "img_001", - prompt: "test", - }, - }, - ], - edges: [], - edgeStyle: "curved" as EdgeStyle, - groups: {}, - isRunning: false, - }; - - const result = partializeForUndo(mockState); - - expect(result.nodes[0].data).not.toHaveProperty("image"); - expect(result.nodes[0].data).toHaveProperty("imageRef", "img_001"); - expect(result.nodes[0].data).toHaveProperty("prompt", "test"); - }); - - it("returned nodes keep unrecoverable binary data", () => { - const mockState = { - nodes: [ - { - id: "node-1", - type: "imageInput" as const, - position: { x: 0, y: 0 }, - data: { - image: "data:image/png;base64,UNSAVED", - // No imageRef - prompt: "test", - }, - }, - ], - edges: [], - edgeStyle: "curved" as EdgeStyle, - groups: {}, - isRunning: false, - }; - - const result = partializeForUndo(mockState); - - expect(result.nodes[0].data).toHaveProperty("image", "data:image/png;base64,UNSAVED"); - expect(result.nodes[0].data).toHaveProperty("prompt", "test"); - }); - }); - - describe("undoWithMedia", () => { - it("calls undo when pastStates is non-empty", () => { - const undo = vi.fn(); - const pause = vi.fn(); - const resume = vi.fn(); - const mockStore = { - getState: vi.fn(() => ({ nodes: [], edges: [], saveDirectoryPath: null })), - setState: vi.fn(), - temporal: { - getState: vi.fn(() => ({ - pastStates: [{}], - undo, - pause, - resume, - })), - }, - } as any; - - undoWithMedia(mockStore); - - expect(undo).toHaveBeenCalled(); - expect(mockStore.setState).toHaveBeenCalledWith({ hasUnsavedChanges: true }); - }); - - it("does nothing when pastStates is empty", () => { - const undo = vi.fn(); - const mockStore = { - getState: vi.fn(), - setState: vi.fn(), - temporal: { - getState: vi.fn(() => ({ - pastStates: [], - undo, - })), - }, - } as any; - - undoWithMedia(mockStore); - - expect(undo).not.toHaveBeenCalled(); - expect(mockStore.setState).not.toHaveBeenCalled(); - }); - }); - - describe("redoWithMedia", () => { - it("calls redo when futureStates is non-empty", () => { - const redo = vi.fn(); - const pause = vi.fn(); - const resume = vi.fn(); - const mockStore = { - getState: vi.fn(() => ({ nodes: [], edges: [], saveDirectoryPath: null })), - setState: vi.fn(), - temporal: { - getState: vi.fn(() => ({ - futureStates: [{}], - redo, - pause, - resume, - })), - }, - } as any; - - redoWithMedia(mockStore); - - expect(redo).toHaveBeenCalled(); - expect(mockStore.setState).toHaveBeenCalledWith({ hasUnsavedChanges: true }); - }); - - it("does nothing when futureStates is empty", () => { - const redo = vi.fn(); - const mockStore = { - getState: vi.fn(), - setState: vi.fn(), - temporal: { - getState: vi.fn(() => ({ - futureStates: [], - redo, - })), - }, - } as any; - - redoWithMedia(mockStore); - - expect(redo).not.toHaveBeenCalled(); - expect(mockStore.setState).not.toHaveBeenCalled(); - }); - }); -}); diff --git a/src/store/undoUtils.ts b/src/store/undoUtils.ts deleted file mode 100644 index 95fcfc88..00000000 --- a/src/store/undoUtils.ts +++ /dev/null @@ -1,152 +0,0 @@ -import type { WorkflowNode, WorkflowEdge, NodeGroup } from "@/types"; -import type { EdgeStyle } from "./workflowStore"; -import { hydrateWorkflowImages } from "@/utils/imageStorage"; - -/** - * Mapping of binary data fields to their corresponding ref fields. - * Only fields with a ref can be recovered from disk after undo/redo, - * so only these are stripped from snapshots. - * - * Fields WITHOUT refs (imageA, imageB, capturedImage, video, images, - * audioFile, glbUrl, output3dUrl, etc.) are kept in snapshots since - * they're unrecoverable otherwise. - */ -export const BINARY_TO_REF: Record = { - image: "imageRef", - outputImage: "outputImageRef", - sourceImage: "sourceImageRef", - inputImages: "inputImageRefs", - outputVideo: "outputVideoRef", - outputAudio: "outputAudioRef", -}; - -/** - * Strips binary data from node data objects to reduce undo snapshot size. - * Only strips fields that have a corresponding ref (recoverable from disk). - * Fields without refs are kept in the snapshot since they can't be recovered. - * - * @param nodes - Array of workflow nodes - * @returns New array of nodes with recoverable binary data stripped - */ -export function stripBinaryData(nodes: WorkflowNode[]): WorkflowNode[] { - return nodes.map(node => { - const data = node.data as Record; - const strippedData: Record = {}; - - for (const key of Object.keys(data)) { - const refField = BINARY_TO_REF[key]; - if (refField && data[refField]) { - // Has a ref → strip binary (recoverable from disk) - continue; - } - strippedData[key] = data[key]; - } - - return { - ...node, - data: strippedData as typeof node.data, - }; - }); -} - -/** - * State shape tracked by undo/redo. - * Only includes fields that affect the workflow graph structure. - */ -export type UndoState = { - nodes: WorkflowNode[]; - edges: WorkflowEdge[]; - edgeStyle: EdgeStyle; - groups: Record; -}; - -/** - * Partializes the store state for undo tracking. - * Returns only the fields we want to track in undo history. - * - * @param state - Full workflow store state - * @returns Partialized state with binary data stripped from nodes - */ -export function partializeForUndo(state: any): UndoState { - return { - nodes: stripBinaryData(state.nodes), - edges: state.edges, - edgeStyle: state.edgeStyle, - groups: state.groups, - }; -} - -/** - * Fast equality check for undo states using referential comparison. - * Zustand creates new object/array references on every mutation, - * so we can use === checks instead of deep equality. - * - * @param past - Previous undo state - * @param current - Current undo state - * @returns true if states are equal (skip snapshot), false otherwise - */ -export function undoStateEquality(past: UndoState, current: UndoState): boolean { - // Fast checks for primitive/reference changes - if (past.edges !== current.edges) return false; - if (past.edgeStyle !== current.edgeStyle) return false; - if (past.groups !== current.groups) return false; - - // Check nodes array - if (past.nodes.length !== current.nodes.length) return false; - - // Check each node reference (Zustand creates new refs on change) - for (let i = 0; i < past.nodes.length; i++) { - if (past.nodes[i] !== current.nodes[i]) return false; - } - - // All checks passed - states are equal - return true; -} - -/** - * Hydrate missing binary data from refs after undo/redo. - * Uses the existing hydrateWorkflowImages() which checks `if (ref && !binary)` - * before loading, so it only loads what's actually missing. - */ -async function hydrateAfterUndoRedo(store: typeof import("./workflowStore").useWorkflowStore): Promise { - const state = store.getState(); - const workflowPath = state.saveDirectoryPath; - if (!workflowPath) return; // Unsaved workflow, no refs to hydrate - - try { - const workflow = { version: 1 as const, name: "", nodes: state.nodes, edges: state.edges, edgeStyle: state.edgeStyle }; - const hydrated = await hydrateWorkflowImages(workflow, workflowPath); - - // Pause temporal so hydration doesn't create undo snapshots - const temporal = store.temporal.getState(); - temporal.pause(); - store.setState({ nodes: hydrated.nodes }); - temporal.resume(); - } catch (err) { - console.warn("Failed to hydrate after undo/redo:", err); - } -} - -/** - * Undo with media hydration. Calls undo(), marks unsaved, then - * asynchronously hydrates any binary data that was stripped from the snapshot. - */ -export function undoWithMedia(store: typeof import("./workflowStore").useWorkflowStore): void { - const temporal = store.temporal.getState(); - if (temporal.pastStates.length === 0) return; - temporal.undo(); - store.setState({ hasUnsavedChanges: true }); - hydrateAfterUndoRedo(store); -} - -/** - * Redo with media hydration. Calls redo(), marks unsaved, then - * asynchronously hydrates any binary data that was stripped from the snapshot. - */ -export function redoWithMedia(store: typeof import("./workflowStore").useWorkflowStore): void { - const temporal = store.temporal.getState(); - if (temporal.futureStates.length === 0) return; - temporal.redo(); - store.setState({ hasUnsavedChanges: true }); - hydrateAfterUndoRedo(store); -} diff --git a/src/store/workflowStore.ts b/src/store/workflowStore.ts index 705479c9..6a4f69b1 100644 --- a/src/store/workflowStore.ts +++ b/src/store/workflowStore.ts @@ -1,6 +1,5 @@ import { create, StateCreator } from "zustand"; import { useShallow } from "zustand/shallow"; -import { temporal, TemporalState } from "zundo"; import { Connection, EdgeChange, @@ -44,7 +43,6 @@ import { getCanvasNavigationSettings, saveCanvasNavigationSettings, } from "./utils/localStorage"; -import { partializeForUndo, undoStateEquality } from "./undoUtils"; import { createDefaultNodeData, defaultNodeDimensions, @@ -336,13 +334,8 @@ interface WorkflowStore { // Canvas navigation settings actions updateCanvasNavigationSettings: (settings: CanvasNavigationSettings) => void; - // Undo/Redo helper actions - _nudgeForSnapshot: () => void; } -// Extend with TemporalState for undo/redo functionality -export type WorkflowStoreWithTemporal = WorkflowStore & TemporalState; - let nodeIdCounter = 0; let groupIdCounter = 0; let autoSaveIntervalId: ReturnType | null = null; @@ -377,8 +370,7 @@ async function waitForPendingImageSyncs(timeout: number = 60000): Promise export { generateWorkflowId, saveGenerateImageDefaults, saveNanoBananaDefaults } from "./utils/localStorage"; export { GROUP_COLORS } from "./utils/nodeDefaults"; -// Store implementation with temporal middleware for undo/redo -const workflowStoreImpl: StateCreator = (set, get) => ({ +const workflowStoreImpl: StateCreator = (set, get) => ({ nodes: [], edges: [], edgeStyle: "curved" as EdgeStyle, @@ -2121,22 +2113,9 @@ const workflowStoreImpl: StateCreator { - set((state) => ({ nodes: state.nodes })); - }, }); -export const useWorkflowStore = create()( - temporal( - workflowStoreImpl, - { - partialize: partializeForUndo as any, - limit: 20, - equality: undoStateEquality as any, - } - ) -); +export const useWorkflowStore = create()(workflowStoreImpl); /** * Stable hook for provider API keys. From 9cadb88c841088119d93ca9bf0560aa5e7a420b6 Mon Sep 17 00:00:00 2001 From: shrimbly Date: Tue, 24 Feb 2026 12:03:14 +1300 Subject: [PATCH 09/23] fix(quick-012): add prompt passthrough in replicate and fal.ai dynamicInputs branches - Include input.prompt in API request when dynamicInputs are present but don't contain a prompt key - Use schema paramMap to resolve model-specific prompt parameter name - Skip if dynamicInputs already provides a prompt value (no duplication) --- src/app/api/generate/providers/fal.ts | 7 +++++++ src/app/api/generate/providers/replicate.ts | 9 ++++++++- 2 files changed, 15 insertions(+), 1 deletion(-) diff --git a/src/app/api/generate/providers/fal.ts b/src/app/api/generate/providers/fal.ts index 95f43852..30a10a2b 100644 --- a/src/app/api/generate/providers/fal.ts +++ b/src/app/api/generate/providers/fal.ts @@ -288,6 +288,13 @@ export async function generateWithFalQueue( } } Object.assign(requestBody, filteredInputs); + + // Ensure prompt is included even when dynamicInputs are present + // (executor sends prompt as top-level field, not in dynamicInputs) + const promptParam = paramMap.prompt || "prompt"; + if (input.prompt && !requestBody[promptParam]) { + requestBody[promptParam] = input.prompt; + } } else { // Fallback: use schema to map generic input names to model-specific parameter names if (input.prompt) { diff --git a/src/app/api/generate/providers/replicate.ts b/src/app/api/generate/providers/replicate.ts index d292c5bd..7daeaae5 100644 --- a/src/app/api/generate/providers/replicate.ts +++ b/src/app/api/generate/providers/replicate.ts @@ -76,7 +76,7 @@ export async function generateWithReplicate( if (hasDynamicInputs) { // Apply coerced parameters first, then dynamic inputs override Object.assign(predictionInput, coerceParameterTypes(input.parameters, parameterTypes)); - const { schemaArrayParams } = getInputMappingFromSchema(schema); + const { paramMap, schemaArrayParams } = getInputMappingFromSchema(schema); // Apply array wrapping based on schema type for (const [key, value] of Object.entries(input.dynamicInputs!)) { @@ -90,6 +90,13 @@ export async function generateWithReplicate( } } } + + // Ensure prompt is included even when dynamicInputs are present + // (executor sends prompt as top-level field, not in dynamicInputs) + const promptParam = paramMap.prompt || "prompt"; + if (input.prompt && !predictionInput[promptParam]) { + predictionInput[promptParam] = input.prompt; + } } else { // Fallback: use schema to map generic input names to model-specific parameter names const { paramMap, arrayParams } = getInputMappingFromSchema(schema); From 9985c634ff6e2c96d8e95c13ecf26f22394ce47f Mon Sep 17 00:00:00 2001 From: shrimbly Date: Tue, 24 Feb 2026 12:04:20 +1300 Subject: [PATCH 10/23] test(quick-012): add unit tests for prompt passthrough with dynamicInputs - 3 tests per provider (Replicate, fal.ai): prompt included with dynamicInputs, no duplication when dynamicInputs already has prompt, existing non-dynamicInputs path works - Mock fetch intercepts provider API calls and captures request bodies - Verifies the fix from the previous commit prevents "prompt is required" errors --- .../__tests__/fal-prompt-passthrough.test.ts | 177 ++++++++++++++++++ .../replicate-prompt-passthrough.test.ts | 147 +++++++++++++++ 2 files changed, 324 insertions(+) create mode 100644 src/app/api/generate/providers/__tests__/fal-prompt-passthrough.test.ts create mode 100644 src/app/api/generate/providers/__tests__/replicate-prompt-passthrough.test.ts diff --git a/src/app/api/generate/providers/__tests__/fal-prompt-passthrough.test.ts b/src/app/api/generate/providers/__tests__/fal-prompt-passthrough.test.ts new file mode 100644 index 00000000..c87eaf4f --- /dev/null +++ b/src/app/api/generate/providers/__tests__/fal-prompt-passthrough.test.ts @@ -0,0 +1,177 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { generateWithFalQueue, clearFalInputMappingCache } from "../fal"; +import type { GenerationInput } from "@/lib/providers/types"; + +/** + * Tests for prompt passthrough when dynamicInputs are present. + * + * Bug: When a generate node has both a prompt AND image input connected, + * the dynamicInputs code path processes images but never reads input.prompt, + * causing "prompt is required" errors from the fal.ai API. + */ + +// Captured request bodies from fetch calls +let capturedQueueBody: Record | null = null; + +function makeInput(overrides: Partial = {}): GenerationInput { + return { + model: { + id: "fal-ai/test-model", + name: "Test Model", + description: null, + provider: "fal", + capabilities: ["text-to-image"], + }, + prompt: "a photo of a cat", + images: [], + parameters: {}, + ...overrides, + }; +} + +/** + * Create a mock fetch that intercepts fal.ai API calls. + * - Schema request: returns OpenAPI spec with prompt + image_url properties + * - Queue submission: captures body, returns request_id + * - Status poll: returns COMPLETED + * - Result fetch: returns images array + * - Media fetch: returns fake image + */ +function createMockFetch() { + return vi.fn(async (url: string | URL | Request, init?: RequestInit) => { + const urlStr = typeof url === "string" ? url : url instanceof URL ? url.toString() : url.url; + + // Schema request (fal.ai model search API with OpenAPI expansion) + if (urlStr.includes("api.fal.ai/v1/models")) { + return new Response( + JSON.stringify({ + models: [ + { + endpoint_id: "fal-ai/test-model", + openapi: { + paths: { + "/": { + post: { + requestBody: { + content: { + "application/json": { + schema: { + properties: { + prompt: { type: "string", description: "Text prompt" }, + image_url: { type: "string", description: "Input image URL" }, + }, + }, + }, + }, + }, + }, + }, + }, + }, + }, + ], + }), + { status: 200 } + ); + } + + // Queue submission + if (urlStr.includes("queue.fal.run/fal-ai/test-model") && init?.method === "POST") { + capturedQueueBody = JSON.parse(init.body as string); + return new Response( + JSON.stringify({ + request_id: "test-123", + status_url: "https://queue.fal.run/fal-ai/test-model/requests/test-123/status", + response_url: "https://queue.fal.run/fal-ai/test-model/requests/test-123", + }), + { status: 200 } + ); + } + + // Status poll + if (urlStr.includes("/requests/test-123/status")) { + return new Response( + JSON.stringify({ status: "COMPLETED" }), + { status: 200 } + ); + } + + // Result fetch + if (urlStr.includes("/requests/test-123") && !urlStr.includes("/status")) { + return new Response( + JSON.stringify({ + images: [{ url: "https://cdn.fal.ai/test/image.png" }], + }), + { status: 200 } + ); + } + + // Media fetch (output image) + if (urlStr.includes("cdn.fal.ai")) { + return new Response(Buffer.from([0x89, 0x50, 0x4e, 0x47]), { + status: 200, + headers: { "content-type": "image/png" }, + }); + } + + return new Response("Not Found", { status: 404 }); + }); +} + +describe("fal.ai prompt passthrough with dynamicInputs", () => { + let mockFetch: ReturnType; + + beforeEach(() => { + capturedQueueBody = null; + clearFalInputMappingCache(); + mockFetch = createMockFetch(); + vi.stubGlobal("fetch", mockFetch); + }); + + afterEach(() => { + vi.unstubAllGlobals(); + }); + + it("includes prompt when dynamicInputs has image_url but no prompt", async () => { + const input = makeInput({ + prompt: "a photo of a cat", + dynamicInputs: { + image_url: "https://cdn.example.com/img.png", + }, + }); + + await generateWithFalQueue("test-req", "test-api-key", input); + + expect(capturedQueueBody).not.toBeNull(); + expect(capturedQueueBody!.prompt).toBe("a photo of a cat"); + expect(capturedQueueBody!.image_url).toBe("https://cdn.example.com/img.png"); + }); + + it("does not duplicate prompt when dynamicInputs already contains prompt", async () => { + const input = makeInput({ + prompt: "a cat", + dynamicInputs: { + prompt: "a dog", + image_url: "https://cdn.example.com/img.png", + }, + }); + + await generateWithFalQueue("test-req", "test-api-key", input); + + expect(capturedQueueBody).not.toBeNull(); + // dynamicInputs value wins - not overwritten by input.prompt + expect(capturedQueueBody!.prompt).toBe("a dog"); + }); + + it("works without dynamicInputs (existing behavior)", async () => { + const input = makeInput({ + prompt: "a cat", + dynamicInputs: undefined, + }); + + await generateWithFalQueue("test-req", "test-api-key", input); + + expect(capturedQueueBody).not.toBeNull(); + expect(capturedQueueBody!.prompt).toBe("a cat"); + }); +}); diff --git a/src/app/api/generate/providers/__tests__/replicate-prompt-passthrough.test.ts b/src/app/api/generate/providers/__tests__/replicate-prompt-passthrough.test.ts new file mode 100644 index 00000000..bad9e722 --- /dev/null +++ b/src/app/api/generate/providers/__tests__/replicate-prompt-passthrough.test.ts @@ -0,0 +1,147 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { generateWithReplicate } from "../replicate"; +import type { GenerationInput } from "@/lib/providers/types"; + +/** + * Tests for prompt passthrough when dynamicInputs are present. + * + * Bug: When a generate node has both a prompt AND image input connected, + * the dynamicInputs code path processes images but never reads input.prompt, + * causing "prompt is required" errors from the Replicate API. + */ + +// Captured request bodies from fetch calls +let capturedPredictionBody: Record | null = null; + +function makeInput(overrides: Partial = {}): GenerationInput { + return { + model: { + id: "owner/test-model", + name: "Test Model", + description: null, + provider: "replicate", + capabilities: ["text-to-image"], + }, + prompt: "a photo of a cat", + images: [], + parameters: {}, + ...overrides, + }; +} + +/** + * Create a mock fetch that intercepts Replicate API calls. + * - Model info: returns schema with prompt + image_input properties + * - Prediction creation: captures body, returns succeeded + * - Media fetch: returns fake image + */ +function createMockFetch() { + return vi.fn(async (url: string | URL | Request, init?: RequestInit) => { + const urlStr = typeof url === "string" ? url : url instanceof URL ? url.toString() : url.url; + + // Model info request + if (urlStr.includes("/models/owner/test-model") && !urlStr.includes("/predictions")) { + return new Response( + JSON.stringify({ + latest_version: { + id: "abc123", + openapi_schema: { + components: { + schemas: { + Input: { + properties: { + prompt: { type: "string", description: "Text prompt" }, + image_input: { type: "string", description: "Input image URL" }, + }, + }, + }, + }, + }, + }, + }), + { status: 200 } + ); + } + + // Prediction creation + if (urlStr.includes("/predictions") && init?.method === "POST") { + const body = JSON.parse(init.body as string); + capturedPredictionBody = body.input; + return new Response( + JSON.stringify({ + id: "pred-123", + status: "succeeded", + output: ["https://replicate.delivery/test/image.png"], + }), + { status: 201 } + ); + } + + // Media fetch (output image) + if (urlStr.includes("replicate.delivery")) { + return new Response(Buffer.from([0x89, 0x50, 0x4e, 0x47]), { + status: 200, + headers: { "content-type": "image/png" }, + }); + } + + return new Response("Not Found", { status: 404 }); + }); +} + +describe("Replicate prompt passthrough with dynamicInputs", () => { + let mockFetch: ReturnType; + + beforeEach(() => { + capturedPredictionBody = null; + mockFetch = createMockFetch(); + vi.stubGlobal("fetch", mockFetch); + }); + + afterEach(() => { + vi.unstubAllGlobals(); + }); + + it("includes prompt when dynamicInputs has image_input but no prompt", async () => { + const input = makeInput({ + prompt: "a photo of a cat", + dynamicInputs: { + image_input: "https://cdn.example.com/img.png", + }, + }); + + await generateWithReplicate("test-req", "test-api-key", input); + + expect(capturedPredictionBody).not.toBeNull(); + expect(capturedPredictionBody!.prompt).toBe("a photo of a cat"); + expect(capturedPredictionBody!.image_input).toBe("https://cdn.example.com/img.png"); + }); + + it("does not duplicate prompt when dynamicInputs already contains prompt", async () => { + const input = makeInput({ + prompt: "a cat", + dynamicInputs: { + prompt: "a dog", + image_input: "https://cdn.example.com/img.png", + }, + }); + + await generateWithReplicate("test-req", "test-api-key", input); + + expect(capturedPredictionBody).not.toBeNull(); + // dynamicInputs value wins - not overwritten by input.prompt + expect(capturedPredictionBody!.prompt).toBe("a dog"); + }); + + it("works without dynamicInputs (existing behavior)", async () => { + const input = makeInput({ + prompt: "a cat", + dynamicInputs: undefined, + }); + + await generateWithReplicate("test-req", "test-api-key", input); + + expect(capturedPredictionBody).not.toBeNull(); + expect(capturedPredictionBody!.prompt).toBe("a cat"); + }); +}); From 5fd7ff9e57eeed428c1c3e019ac9fe618b1f6907 Mon Sep 17 00:00:00 2001 From: shrimbly Date: Tue, 24 Feb 2026 13:04:35 +1300 Subject: [PATCH 11/23] fix(security): add path traversal validation to workflow routes - Create validateWorkflowPath() utility to prevent path traversal attacks - Reject non-absolute paths, traversal sequences, and dangerous system directories - Apply validation to workflow and workflow-images POST/GET handlers - Fix workflow-images ENOENT-specific catch block to match workflow route pattern - Fix workflow mkdir failure status code from 400 to 500 (server error) - Add comprehensive path traversal tests for both routes - All malicious path attempts now return 400 Bad Request --- .../workflow-images/__tests__/route.test.ts | 48 ++++++++++++++ src/app/api/workflow-images/route.ts | 44 +++++++++++++ src/app/api/workflow/__tests__/route.test.ts | 65 +++++++++++++++++++ src/app/api/workflow/route.ts | 29 ++++++++- src/utils/pathValidation.ts | 59 +++++++++++++++++ 5 files changed, 244 insertions(+), 1 deletion(-) create mode 100644 src/utils/pathValidation.ts diff --git a/src/app/api/workflow-images/__tests__/route.test.ts b/src/app/api/workflow-images/__tests__/route.test.ts index a5e34b37..2adba681 100644 --- a/src/app/api/workflow-images/__tests__/route.test.ts +++ b/src/app/api/workflow-images/__tests__/route.test.ts @@ -80,5 +80,53 @@ describe("/api/workflow-images route", () => { expect(mockMkdir).toHaveBeenCalledWith("/test/new-workflow", { recursive: true }); expect(mockMkdir).toHaveBeenCalledWith("/test/new-workflow/inputs", { recursive: true }); }); + + it("should reject path traversal attempts", async () => { + const request = createMockPostRequest({ + workflowPath: "/test/../etc/passwd", + imageId: "img_123", + folder: "inputs", + imageData: "data:image/png;base64,aGVsbG8=", + }); + + const response = await POST(request); + const data = await response.json(); + + expect(response.status).toBe(400); + expect(data.success).toBe(false); + expect(data.error).toBe("Path contains traversal sequences"); + }); + + it("should reject non-absolute paths", async () => { + const request = createMockPostRequest({ + workflowPath: "relative/path", + imageId: "img_123", + folder: "inputs", + imageData: "data:image/png;base64,aGVsbG8=", + }); + + const response = await POST(request); + const data = await response.json(); + + expect(response.status).toBe(400); + expect(data.success).toBe(false); + expect(data.error).toBe("Path must be absolute"); + }); + + it("should reject dangerous system paths", async () => { + const request = createMockPostRequest({ + workflowPath: "/etc/workflows", + imageId: "img_123", + folder: "inputs", + imageData: "data:image/png;base64,aGVsbG8=", + }); + + const response = await POST(request); + const data = await response.json(); + + expect(response.status).toBe(400); + expect(data.success).toBe(false); + expect(data.error).toBe("Access to /etc is not allowed"); + }); }); }); diff --git a/src/app/api/workflow-images/route.ts b/src/app/api/workflow-images/route.ts index 81902831..8b3bda19 100644 --- a/src/app/api/workflow-images/route.ts +++ b/src/app/api/workflow-images/route.ts @@ -2,6 +2,7 @@ import { NextRequest, NextResponse } from "next/server"; import * as fs from "fs/promises"; import * as path from "path"; import { logger } from "@/utils/logger"; +import { validateWorkflowPath } from "@/utils/pathValidation"; export const maxDuration = 300; // 5 minute timeout for large image operations @@ -62,6 +63,19 @@ export async function POST(request: NextRequest) { ); } + // Validate path to prevent traversal attacks + const pathValidation = validateWorkflowPath(workflowPath); + if (!pathValidation.valid) { + logger.warn('file.error', 'Workflow image save failed: invalid path', { + workflowPath, + error: pathValidation.error, + }); + return NextResponse.json( + { success: false, error: pathValidation.error }, + { status: 400 } + ); + } + // Validate workflow directory exists, or create it if missing try { const stats = await fs.stat(workflowPath); @@ -75,6 +89,23 @@ export async function POST(request: NextRequest) { ); } } catch (dirError) { + const err = dirError as NodeJS.ErrnoException; + const isNotFound = + err?.code === "ENOENT" || + (typeof err?.message === "string" && + (err.message.includes("ENOENT") || err.message.includes("no such file or directory"))); + + if (!isNotFound) { + logger.warn('file.error', 'Workflow image save failed: directory validation error', { + workflowPath, + error: dirError instanceof Error ? dirError.message : 'Unknown error', + }); + return NextResponse.json( + { success: false, error: "Directory validation failed" }, + { status: 400 } + ); + } + try { await fs.mkdir(workflowPath, { recursive: true }); logger.info('file.save', 'Created workflow directory for image save', { @@ -176,6 +207,19 @@ export async function GET(request: NextRequest) { } try { + // Validate path to prevent traversal attacks + const pathValidation = validateWorkflowPath(workflowPath); + if (!pathValidation.valid) { + logger.warn('file.error', 'Workflow image load failed: invalid path', { + workflowPath, + error: pathValidation.error, + }); + return NextResponse.json( + { success: false, error: pathValidation.error }, + { status: 400 } + ); + } + // Sanitize imageId to prevent path traversal const safeImageId = path.basename(imageId); if (safeImageId !== imageId || imageId.includes('..')) { diff --git a/src/app/api/workflow/__tests__/route.test.ts b/src/app/api/workflow/__tests__/route.test.ts index d61d18ea..28427fad 100644 --- a/src/app/api/workflow/__tests__/route.test.ts +++ b/src/app/api/workflow/__tests__/route.test.ts @@ -249,6 +249,51 @@ describe("/api/workflow route", () => { expect(data.success).toBe(false); expect(data.error).toBe("Disk full"); }); + + it("should reject path traversal attempts", async () => { + const request = createMockPostRequest({ + directoryPath: "/test/../etc/passwd", + filename: "workflow", + workflow: { nodes: [], edges: [] }, + }); + + const response = await POST(request); + const data = await response.json(); + + expect(response.status).toBe(400); + expect(data.success).toBe(false); + expect(data.error).toBe("Path contains traversal sequences"); + }); + + it("should reject non-absolute paths", async () => { + const request = createMockPostRequest({ + directoryPath: "relative/path", + filename: "workflow", + workflow: { nodes: [], edges: [] }, + }); + + const response = await POST(request); + const data = await response.json(); + + expect(response.status).toBe(400); + expect(data.success).toBe(false); + expect(data.error).toBe("Path must be absolute"); + }); + + it("should reject dangerous system paths", async () => { + const request = createMockPostRequest({ + directoryPath: "/etc/workflows", + filename: "workflow", + workflow: { nodes: [], edges: [] }, + }); + + const response = await POST(request); + const data = await response.json(); + + expect(response.status).toBe(400); + expect(data.success).toBe(false); + expect(data.error).toBe("Access to /etc is not allowed"); + }); }); describe("GET - Validate directory", () => { @@ -301,5 +346,25 @@ describe("/api/workflow route", () => { expect(data.success).toBe(false); expect(data.error).toBe("Path parameter required"); }); + + it("should reject path traversal attempts in GET", async () => { + const request = createMockGetRequest({ path: "/test/../etc" }); + const response = await GET(request); + const data = await response.json(); + + expect(response.status).toBe(400); + expect(data.success).toBe(false); + expect(data.error).toBe("Path contains traversal sequences"); + }); + + it("should reject non-absolute paths in GET", async () => { + const request = createMockGetRequest({ path: "relative/path" }); + const response = await GET(request); + const data = await response.json(); + + expect(response.status).toBe(400); + expect(data.success).toBe(false); + expect(data.error).toBe("Path must be absolute"); + }); }); }); diff --git a/src/app/api/workflow/route.ts b/src/app/api/workflow/route.ts index b95b303e..ce2e83c8 100644 --- a/src/app/api/workflow/route.ts +++ b/src/app/api/workflow/route.ts @@ -2,6 +2,7 @@ import { NextRequest, NextResponse } from "next/server"; import * as fs from "fs/promises"; import * as path from "path"; import { logger } from "@/utils/logger"; +import { validateWorkflowPath } from "@/utils/pathValidation"; export const maxDuration = 300; // 5 minute timeout for large workflow files @@ -35,6 +36,19 @@ export async function POST(request: NextRequest) { ); } + // Validate path to prevent traversal attacks + const pathValidation = validateWorkflowPath(directoryPath); + if (!pathValidation.valid) { + logger.warn('file.error', 'Workflow save failed: invalid path', { + directoryPath, + error: pathValidation.error, + }); + return NextResponse.json( + { success: false, error: pathValidation.error }, + { status: 400 } + ); + } + // Ensure project directory exists (supports saving into new subfolders) try { const stats = await fs.stat(directoryPath); @@ -74,7 +88,7 @@ export async function POST(request: NextRequest) { }); return NextResponse.json( { success: false, error: "Could not create directory" }, - { status: 400 } + { status: 500 } ); } } @@ -143,6 +157,19 @@ export async function GET(request: NextRequest) { ); } + // Validate path to prevent traversal attacks + const pathValidation = validateWorkflowPath(directoryPath); + if (!pathValidation.valid) { + logger.warn('file.error', 'Directory validation failed: invalid path', { + directoryPath, + error: pathValidation.error, + }); + return NextResponse.json( + { success: false, error: pathValidation.error }, + { status: 400 } + ); + } + try { const stats = await fs.stat(directoryPath); const isDirectory = stats.isDirectory(); diff --git a/src/utils/pathValidation.ts b/src/utils/pathValidation.ts new file mode 100644 index 00000000..42155778 --- /dev/null +++ b/src/utils/pathValidation.ts @@ -0,0 +1,59 @@ +import * as path from "path"; + +/** + * Validates a workflow directory path to prevent path traversal attacks. + * Ensures the path is absolute, doesn't contain traversal sequences, + * and doesn't point to dangerous system directories. + */ +export function validateWorkflowPath(inputPath: string): { + valid: boolean; + resolved: string; + error?: string; +} { + // Must be an absolute path + if (!path.isAbsolute(inputPath)) { + return { + valid: false, + resolved: inputPath, + error: "Path must be absolute", + }; + } + + // Resolve the path and ensure it equals the input (catches .. traversal) + const resolved = path.resolve(inputPath); + if (resolved !== inputPath) { + return { + valid: false, + resolved, + error: "Path contains traversal sequences", + }; + } + + // Block known dangerous system directories + const dangerousPrefixes = [ + "/etc", + "/usr", + "/bin", + "/sbin", + "/sys", + "/proc", + "/var/run", + "/System", + "/Library", + ]; + + for (const prefix of dangerousPrefixes) { + if (resolved.startsWith(prefix + "/") || resolved === prefix) { + return { + valid: false, + resolved, + error: `Access to ${prefix} is not allowed`, + }; + } + } + + return { + valid: true, + resolved, + }; +} From 6fd112e5518199c25d489b44a68ecbe829448e9f Mon Sep 17 00:00:00 2001 From: shrimbly Date: Tue, 24 Feb 2026 13:05:13 +1300 Subject: [PATCH 12/23] fix(security): detect nested-group ReDoS patterns in arrayParser - Replace regex-based isUnsafePattern with character-by-character parser - Track parenthesis nesting depth to detect deeply nested quantifiers - Detect patterns like ((a+))+ that bypass simple regex checks - Add tests for deeply nested patterns: ((a+))+, ((a*)+), (((x+))+)+ - Parser now catches all levels of nested quantifier groups --- src/utils/__tests__/arrayParser.test.ts | 15 +++++++++++ src/utils/arrayParser.ts | 33 ++++++++++++++++++++++--- 2 files changed, 44 insertions(+), 4 deletions(-) diff --git a/src/utils/__tests__/arrayParser.test.ts b/src/utils/__tests__/arrayParser.test.ts index a19be8d9..27fac5a6 100644 --- a/src/utils/__tests__/arrayParser.test.ts +++ b/src/utils/__tests__/arrayParser.test.ts @@ -80,6 +80,21 @@ describe("parseTextToArray", () => { } }); + it("rejects deeply nested quantifier patterns that bypass simple regex checks", () => { + const deeplyNestedPatterns = ["((a+))+", "((a*)+)", "(((x+))+)+"]; + for (const pattern of deeplyNestedPatterns) { + const result = parseTextToArray("test", { + splitMode: "regex", + delimiter: "*", + regexPattern: pattern, + trimItems: true, + removeEmpty: true, + }); + expect(result.items).toEqual([]); + expect(result.error).toContain("nested quantifiers"); + } + }); + it("allows safe regex patterns", () => { const safePatterns = ["\\d+", "[,;]+", "\\s+", "(a|b)"]; for (const pattern of safePatterns) { diff --git a/src/utils/arrayParser.ts b/src/utils/arrayParser.ts index 8e16ef8c..00b544c2 100644 --- a/src/utils/arrayParser.ts +++ b/src/utils/arrayParser.ts @@ -18,14 +18,39 @@ const MAX_REGEX_INPUT_LENGTH = 100_000; /** * Detect regex patterns prone to catastrophic backtracking (ReDoS). - * Rejects nested quantifiers like (a+)+, (a*)+, (a+)*, etc. + * Rejects nested quantifiers like (a+)+, (a*)+, ((a+))+, etc. + * Uses character-by-character parsing to track nesting depth. */ function isUnsafePattern(pattern: string): boolean { - // Strip /pattern/flags wrapper to inspect the body const slashFormat = pattern.match(/^\/(.+)\/[a-z]*$/i); const body = slashFormat ? slashFormat[1] : pattern; - // Nested quantifiers: a group containing a quantifier, followed by another quantifier - return /\([^)]*[+*]\)[+*{]/.test(body); + + // Track groups: when we see ')' followed by a quantifier, + // check if anything inside that group also had a quantifier. + let depth = 0; + const quantifierAtDepth: boolean[] = []; + + for (let i = 0; i < body.length; i++) { + const ch = body[i]; + if (ch === '\\') { i++; continue; } // skip escaped chars + if (ch === '(') { + depth++; + quantifierAtDepth[depth] = false; + } else if (ch === ')') { + const hadQuantifier = quantifierAtDepth[depth] || false; + depth = Math.max(0, depth - 1); + // Check if this closing paren is followed by a quantifier + const next = body[i + 1]; + if (next === '+' || next === '*' || next === '{') { + if (hadQuantifier) return true; // nested quantifier! + // Mark parent depth as having a quantifier + quantifierAtDepth[depth] = true; + } + } else if ((ch === '+' || ch === '*') && depth > 0) { + quantifierAtDepth[depth] = true; + } + } + return false; } function parseRegexPattern(pattern: string): RegExp { From 58edb52552308b2856418b73ef7f2f95e2267353 Mon Sep 17 00:00:00 2001 From: shrimbly Date: Tue, 24 Feb 2026 13:05:51 +1300 Subject: [PATCH 13/23] fix: clamp stale arrayItemIndex via modulo in getSourceOutput - Apply modulo clamping when reading arrayItemIndex from edge data - Out-of-bounds indices wrap (e.g., index 3 on 2-item array becomes index 1) - Empty arrays return null instead of crashing - No edge mutation needed - clamping happens at read time - Add tests for wrapping behavior and empty array handling --- .../utils/__tests__/connectedInputs.test.ts | 54 +++++++++++++++++++ src/store/utils/connectedInputs.ts | 5 +- 2 files changed, 58 insertions(+), 1 deletion(-) diff --git a/src/store/utils/__tests__/connectedInputs.test.ts b/src/store/utils/__tests__/connectedInputs.test.ts index 85425187..85c30c98 100644 --- a/src/store/utils/__tests__/connectedInputs.test.ts +++ b/src/store/utils/__tests__/connectedInputs.test.ts @@ -140,6 +140,60 @@ describe("getConnectedInputsPure", () => { expect(result.text).toBe("three"); }); + it("should wrap out-of-bounds arrayItemIndex via modulo", () => { + const nodes = [ + makeNode("arr", "array", { outputText: "[\"one\",\"two\"]", outputItems: ["one", "two"] }), + makeNode("gen", "nanoBanana"), + ]; + const edges = [{ + id: "arr-gen-stale", + source: "arr", + target: "gen", + sourceHandle: "text", + targetHandle: "text", + data: { arrayItemIndex: 3 }, // index 3 on 2-item array wraps to index 1 + }] as WorkflowEdge[]; + + const result = getConnectedInputsPure("gen", nodes, edges); + expect(result.text).toBe("two"); + }); + + it("should return null for out-of-bounds arrayItemIndex on empty array", () => { + const nodes = [ + makeNode("arr", "array", { outputText: "[]", outputItems: [] }), + makeNode("gen", "nanoBanana"), + ]; + const edges = [{ + id: "arr-gen-empty", + source: "arr", + target: "gen", + sourceHandle: "text", + targetHandle: "text", + data: { arrayItemIndex: 0 }, + }] as WorkflowEdge[]; + + const result = getConnectedInputsPure("gen", nodes, edges); + expect(result.text).toBeNull(); + }); + + it("should wrap large arrayItemIndex correctly", () => { + const nodes = [ + makeNode("arr", "array", { outputText: "[\"a\",\"b\",\"c\"]", outputItems: ["a", "b", "c"] }), + makeNode("gen", "nanoBanana"), + ]; + const edges = [{ + id: "arr-gen-large", + source: "arr", + target: "gen", + sourceHandle: "text", + targetHandle: "text", + data: { arrayItemIndex: 7 }, // 7 % 3 = 1 + }] as WorkflowEdge[]; + + const result = getConnectedInputsPure("gen", nodes, edges); + expect(result.text).toBe("b"); + }); + it("should extract text from promptConstructor outputText", () => { const nodes = [ makeNode("pc", "promptConstructor", { outputText: "constructed", template: "tmpl" }), diff --git a/src/store/utils/connectedInputs.ts b/src/store/utils/connectedInputs.ts index bd7c0917..959a3051 100644 --- a/src/store/utils/connectedInputs.ts +++ b/src/store/utils/connectedInputs.ts @@ -91,7 +91,10 @@ function getSourceOutput( const arrayData = sourceNode.data as ArrayNodeData; const dataIndex = edgeData?.arrayItemIndex; if (typeof dataIndex === "number" && Number.isInteger(dataIndex) && dataIndex >= 0) { - return { type: "text", value: arrayData.outputItems[dataIndex] ?? null }; + 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-", "")); From 4cbebec31ca450e58d998516f183dec9ad999a85 Mon Sep 17 00:00:00 2001 From: shrimbly Date: Tue, 24 Feb 2026 13:06:40 +1300 Subject: [PATCH 14/23] fix(privacy): stop leaking directoryPath in saved workflow JSON - Remove directoryPath field from WorkflowFile literal in saveToFile - Change loadWorkflow fallback chain to exclude workflow.directoryPath - directoryPath already stored in localStorage via saveSaveConfig() - Keep optional directoryPath field in WorkflowFile type for backward compatibility - Old files with directoryPath will still load, new files won't contain it --- src/store/workflowStore.ts | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/src/store/workflowStore.ts b/src/store/workflowStore.ts index e5f2c299..171a378d 100644 --- a/src/store/workflowStore.ts +++ b/src/store/workflowStore.ts @@ -1541,8 +1541,8 @@ const workflowStoreImpl: StateCreator = (set, get) => ({ const configs = loadSaveConfigs(); const savedConfig = workflow.id ? configs[workflow.id] : null; - // Determine the workflow directory path (passed in, from saved config, or embedded in workflow) - const directoryPath = workflowPath || savedConfig?.directoryPath || workflow.directoryPath; + // Determine the workflow directory path (passed in or from saved config) + const directoryPath = workflowPath || savedConfig?.directoryPath || null; // Hydrate images if we have a directory path and the workflow has image refs let hydratedWorkflow = workflow; @@ -1737,7 +1737,6 @@ const workflowStoreImpl: StateCreator = (set, get) => ({ version: 1, id: workflowId, name: workflowName, - directoryPath: saveDirectoryPath, nodes: currentNodes, edges, edgeStyle, From 0bca6e6969a4a9a81551edb13bc5efd60a9da176 Mon Sep 17 00:00:00 2001 From: shrimbly Date: Tue, 24 Feb 2026 13:07:06 +1300 Subject: [PATCH 15/23] fix(memory): close blob URL leak race in useVideoBlobUrl - Track created blob URL in async chain with createdUrl variable - Cleanup function revokes orphaned URLs created after cancel flag set - Remove unnecessary prevInputRef deduplication (React handles this) - Fixes race where rapid videoUrl changes orphan blob URLs in memory --- src/hooks/useVideoBlobUrl.ts | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/src/hooks/useVideoBlobUrl.ts b/src/hooks/useVideoBlobUrl.ts index 7ca8de87..c9e45109 100644 --- a/src/hooks/useVideoBlobUrl.ts +++ b/src/hooks/useVideoBlobUrl.ts @@ -17,13 +17,8 @@ import { useEffect, useRef, useState } from "react"; export function useVideoBlobUrl(videoUrl: string | null): string | null { const [blobUrl, setBlobUrl] = useState(null); const prevBlobUrlRef = useRef(null); - const prevInputRef = useRef(null); useEffect(() => { - // Input unchanged — skip - if (videoUrl === prevInputRef.current) return; - prevInputRef.current = videoUrl; - // Revoke previous blob URL if (prevBlobUrlRef.current) { URL.revokeObjectURL(prevBlobUrlRef.current); @@ -47,11 +42,13 @@ export function useVideoBlobUrl(videoUrl: string | null): string | null { setBlobUrl(videoUrl); let cancelled = false; + let createdUrl: string | null = null; fetch(videoUrl) .then((r) => r.blob()) .then((blob) => { if (cancelled) return; const url = URL.createObjectURL(blob); + createdUrl = url; prevBlobUrlRef.current = url; setBlobUrl(url); }) @@ -61,6 +58,10 @@ export function useVideoBlobUrl(videoUrl: string | null): string | null { return () => { cancelled = true; + // If a blob URL was created after we decided to cancel, revoke it + if (createdUrl && createdUrl !== prevBlobUrlRef.current) { + URL.revokeObjectURL(createdUrl); + } }; } From cfbf377126591f11f2187705079ed3f416a13e81 Mon Sep 17 00:00:00 2001 From: shrimbly Date: Tue, 24 Feb 2026 13:07:34 +1300 Subject: [PATCH 16/23] fix: restore hasUnsavedChanges on saveAsFile failure - Capture prevUnsaved from hasUnsavedChanges before set() call - Add hasUnsavedChanges to rollback set() call on save failure - All three fields now properly restored: workflowId, workflowName, hasUnsavedChanges - Prevents dirty flag from remaining stuck at true after failed save --- src/store/workflowStore.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/store/workflowStore.ts b/src/store/workflowStore.ts index 171a378d..78e9ba36 100644 --- a/src/store/workflowStore.ts +++ b/src/store/workflowStore.ts @@ -1845,7 +1845,7 @@ const workflowStoreImpl: StateCreator = (set, get) => ({ return false; } - const { saveDirectoryPath, workflowId: prevId, workflowName: prevName } = get(); + const { saveDirectoryPath, workflowId: prevId, workflowName: prevName, hasUnsavedChanges: prevUnsaved } = get(); if (!saveDirectoryPath) { return false; } @@ -1861,7 +1861,7 @@ const workflowStoreImpl: StateCreator = (set, get) => ({ const success = await get().saveToFile(); if (!success) { // Rollback to previous identity on failure - set({ workflowId: prevId, workflowName: prevName }); + set({ workflowId: prevId, workflowName: prevName, hasUnsavedChanges: prevUnsaved }); } return success; }, From 90724644909da734e4727a45c810db0e147eba64 Mon Sep 17 00:00:00 2001 From: shrimbly Date: Tue, 24 Feb 2026 13:08:07 +1300 Subject: [PATCH 17/23] chore: remove debug console.log from VideoStitch executor - Remove 4 debug console.log/console.warn statements - Audio pipeline already validated, verbose logging no longer needed - Keeps production code clean of debug noise --- src/store/execution/videoProcessingExecutors.ts | 9 --------- 1 file changed, 9 deletions(-) diff --git a/src/store/execution/videoProcessingExecutors.ts b/src/store/execution/videoProcessingExecutors.ts index c6af158d..edb20225 100644 --- a/src/store/execution/videoProcessingExecutors.ts +++ b/src/store/execution/videoProcessingExecutors.ts @@ -55,7 +55,6 @@ export async function executeVideoStitch(ctx: NodeExecutionContext): Promise 0 && inputs.audio[0]) { - console.log('[VideoStitch] Audio input detected, preparing audio...'); const { prepareAudioAsync } = await import("@/hooks/useAudioMixing"); const audioUrl = inputs.audio[0]; const audioResponse = await fetch(audioUrl); @@ -69,14 +68,6 @@ export async function executeVideoStitch(ctx: NodeExecutionContext): Promise Date: Tue, 24 Feb 2026 13:08:30 +1300 Subject: [PATCH 18/23] fix(perf): guard ArrayNode height check and fix variable shadowing - Add early return in setNodes when height unchanged to prevent unnecessary updates - Rename shadowed 'selected' variable to 'currentSelection' in validation useEffect - Reduces redundant React Flow re-renders when array items don't change height --- src/components/nodes/ArrayNode.tsx | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/src/components/nodes/ArrayNode.tsx b/src/components/nodes/ArrayNode.tsx index 8f591fa2..a01052b3 100644 --- a/src/components/nodes/ArrayNode.tsx +++ b/src/components/nodes/ArrayNode.tsx @@ -150,8 +150,8 @@ export function ArrayNode({ id, data, selected }: NodeProps) { // Reset selection if it no longer points to a valid parsed item. useEffect(() => { - const selected = nodeData.selectedOutputIndex; - if (selected !== null && (selected < 0 || selected >= previewItems.length)) { + const currentSelection = nodeData.selectedOutputIndex; + if (currentSelection !== null && (currentSelection < 0 || currentSelection >= previewItems.length)) { updateNodeData(id, { selectedOutputIndex: null }); } }, [id, nodeData.selectedOutputIndex, previewItems.length, updateNodeData]); @@ -163,9 +163,11 @@ export function ArrayNode({ id, data, selected }: NodeProps) { const newHeight = Math.max(baseHeight, 270 + previewItems.length * perItemHeight); setNodes((nodes) => - nodes.map((node) => - node.id === id ? { ...node, style: { ...node.style, height: newHeight } } : node - ) + nodes.map((node) => { + if (node.id !== id) return node; + if ((node.style?.height as number) === newHeight) return node; + return { ...node, style: { ...node.style, height: newHeight } }; + }) ); }, [id, previewItems.length, setNodes]); From 010aebab4844a1032b82dbd5d018bcb83c238132 Mon Sep 17 00:00:00 2001 From: shrimbly Date: Tue, 24 Feb 2026 13:08:56 +1300 Subject: [PATCH 19/23] fix(ui): add Shift+R and Shift+T to keyboard shortcuts dialog - Add Shift+T for Audio node to shortcuts list - Add Shift+R for Array node to shortcuts list - Both shortcuts already wired in WorkflowCanvas, now visible in dialog --- src/components/KeyboardShortcutsDialog.tsx | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/components/KeyboardShortcutsDialog.tsx b/src/components/KeyboardShortcutsDialog.tsx index 9fea1570..4333219a 100644 --- a/src/components/KeyboardShortcutsDialog.tsx +++ b/src/components/KeyboardShortcutsDialog.tsx @@ -34,6 +34,8 @@ const shortcutGroups: ShortcutGroup[] = [ { keys: ["Shift", "V"], description: "Add Generate Video node" }, { keys: ["Shift", "L"], description: "Add LLM Text node" }, { keys: ["Shift", "A"], description: "Add Annotation node" }, + { keys: ["Shift", "T"], description: "Add Audio node" }, + { keys: ["Shift", "R"], description: "Add Array node" }, ], }, { From 23db4d1cf1da6f62c30c0b11df306b3e21ede7ff Mon Sep 17 00:00:00 2001 From: shrimbly Date: Tue, 24 Feb 2026 13:11:51 +1300 Subject: [PATCH 20/23] fix: add 10s timeout to getImageDimensions to prevent promise hang - Add resolved flag and safeResolve guard pattern from getVideoDimensions - Add cleanup() function to null event handlers and clear src - Add 10-second timeout that resolves with null if neither onload nor onerror fires - Prevents promise from hanging indefinitely on corrupt/malformed images --- src/utils/nodeDimensions.ts | 19 ++++++++++++++----- 1 file changed, 14 insertions(+), 5 deletions(-) diff --git a/src/utils/nodeDimensions.ts b/src/utils/nodeDimensions.ts index c28a355e..89f6b3aa 100644 --- a/src/utils/nodeDimensions.ts +++ b/src/utils/nodeDimensions.ts @@ -16,20 +16,29 @@ export function getImageDimensions( return; } + let resolved = false; const img = new Image(); const cleanup = () => { img.onload = null; img.onerror = null; img.src = ""; }; - img.onload = () => { - const dims = { width: img.naturalWidth, height: img.naturalHeight }; + const safeResolve = (value: { width: number; height: number } | null) => { + if (resolved) return; + resolved = true; cleanup(); - resolve(dims); + resolve(value); + }; + + const timeout = setTimeout(() => safeResolve(null), 10_000); + + img.onload = () => { + clearTimeout(timeout); + safeResolve({ width: img.naturalWidth, height: img.naturalHeight }); }; img.onerror = () => { - cleanup(); - resolve(null); + clearTimeout(timeout); + safeResolve(null); }; img.src = base64DataUrl; }); From be08d190dca19c4548d661a06f44f97b7198bd5f Mon Sep 17 00:00:00 2001 From: shrimbly Date: Tue, 24 Feb 2026 13:11:59 +1300 Subject: [PATCH 21/23] fix(security): propagate quantifier state to parent groups in ReDoS detection - Propagate hadQuantifier to parent depth when closing groups - Fixes detection of deeply nested patterns like ((a+))+ and (((x+))+)+ - Inner groups with quantifiers now properly marked at parent level --- src/utils/arrayParser.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/utils/arrayParser.ts b/src/utils/arrayParser.ts index 00b544c2..66685a7e 100644 --- a/src/utils/arrayParser.ts +++ b/src/utils/arrayParser.ts @@ -45,6 +45,9 @@ function isUnsafePattern(pattern: string): boolean { if (hadQuantifier) return true; // nested quantifier! // Mark parent depth as having a quantifier quantifierAtDepth[depth] = true; + } else if (hadQuantifier) { + // Group contained quantifier but isn't followed by one - propagate to parent + quantifierAtDepth[depth] = true; } } else if ((ch === '+' || ch === '*') && depth > 0) { quantifierAtDepth[depth] = true; From 94ab09f7ebbd050901e9228832166165803e5841 Mon Sep 17 00:00:00 2001 From: shrimbly Date: Tue, 24 Feb 2026 14:12:17 +1300 Subject: [PATCH 22/23] fix: restore legacy workflow.directoryPath fallback for image hydration Co-Authored-By: Claude Opus 4.6 --- src/store/workflowStore.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/store/workflowStore.ts b/src/store/workflowStore.ts index 78e9ba36..9d1c9590 100644 --- a/src/store/workflowStore.ts +++ b/src/store/workflowStore.ts @@ -1541,8 +1541,8 @@ const workflowStoreImpl: StateCreator = (set, get) => ({ const configs = loadSaveConfigs(); const savedConfig = workflow.id ? configs[workflow.id] : null; - // Determine the workflow directory path (passed in or from saved config) - const directoryPath = workflowPath || savedConfig?.directoryPath || null; + // Determine the workflow directory path (passed in, from saved config, or embedded in legacy workflow JSON) + const directoryPath = workflowPath || savedConfig?.directoryPath || workflow.directoryPath || null; // Hydrate images if we have a directory path and the workflow has image refs let hydratedWorkflow = workflow; From fa199148a1b3ae1d2f48260bd311f0b30ef8fe70 Mon Sep 17 00:00:00 2001 From: shrimbly Date: Tue, 24 Feb 2026 20:36:16 +1300 Subject: [PATCH 23/23] fix: resolve anyOf/oneOf property types for dynamic image input detection Add resolvePropertyType() helper to extract effective type/format from OpenAPI wrapper patterns (anyOf, oneOf, allOf, $ref) used by Pydantic and other code generators. Update isImageInput() and convertSchemaProperty() to use resolved types instead of reading prop.type directly. This ensures nullable fields like `{anyOf: [{type: "string"}, {type: "null"}]}` are correctly identified as image inputs when their name matches image patterns. Co-Authored-By: Claude Opus 4.6 --- .../models/[modelId]/__tests__/route.test.ts | 323 ++++++++++++++++++ src/app/api/models/[modelId]/route.ts | 153 +++++++-- 2 files changed, 444 insertions(+), 32 deletions(-) diff --git a/src/app/api/models/[modelId]/__tests__/route.test.ts b/src/app/api/models/[modelId]/__tests__/route.test.ts index 42a3ca09..cb4e4daf 100644 --- a/src/app/api/models/[modelId]/__tests__/route.test.ts +++ b/src/app/api/models/[modelId]/__tests__/route.test.ts @@ -451,6 +451,329 @@ describe("/api/models/[modelId] schema endpoint", () => { }); }); + describe("real Fal Kling v2.6 pro image-to-video schema", () => { + it("should detect both start_image_url and end_image_url as image inputs", async () => { + // Exact schema structure from Fal API for fal-ai/kling-video/v2.6/pro/image-to-video + mockFetch.mockResolvedValueOnce({ + ok: true, + json: () => Promise.resolve({ + models: [{ + openapi: { + openapi: "3.0.4", + components: { + schemas: { + KlingVideoV26ProImageToVideoInput: { + title: "ImageToVideoV26ProRequest", + type: "object", + properties: { + prompt: { + title: "Prompt", + type: "string", + maxLength: 2500, + }, + duration: { + enum: ["5", "10"], + title: "Duration", + type: "string", + description: "The duration of the generated video in seconds", + default: "5", + }, + generate_audio: { + title: "Generate Audio", + type: "boolean", + description: "Whether to generate native audio for the video.", + default: true, + }, + start_image_url: { + description: "URL of the image to be used for the video", + type: "string", + title: "Start Image Url", + }, + end_image_url: { + title: "End Image Url", + type: "string", + description: "URL of the image to be used for the end of the video", + }, + negative_prompt: { + title: "Negative Prompt", + type: "string", + maxLength: 2500, + default: "blur, distort, and low quality", + }, + }, + required: ["prompt", "start_image_url"], + }, + }, + }, + paths: { + "/fal-ai/kling-video/v2.6/pro/image-to-video": { + post: { + requestBody: { + required: true, + content: { + "application/json": { + schema: { + $ref: "#/components/schemas/KlingVideoV26ProImageToVideoInput", + }, + }, + }, + }, + }, + }, + }, + }, + }], + }), + }); + + const modelId = `fal-ai/kling-video-real-${testCounter}`; + const request = createMockSchemaRequest(modelId, "fal"); + const response = await GET(request, { params: Promise.resolve({ modelId }) }); + const data = await response.json(); + + expect(response.status).toBe(200); + expect(data.success).toBe(true); + + const imageInputNames = data.inputs + .filter((i: { type: string }) => i.type === "image") + .map((i: { name: string }) => i.name); + const textInputNames = data.inputs + .filter((i: { type: string }) => i.type === "text") + .map((i: { name: string }) => i.name); + const paramNames = data.parameters.map((p: { name: string }) => p.name); + + // Both image URL fields should be detected as image inputs + expect(imageInputNames).toContain("start_image_url"); + expect(imageInputNames).toContain("end_image_url"); + + // Text inputs + expect(textInputNames).toContain("prompt"); + expect(textInputNames).toContain("negative_prompt"); + + // Parameters (not image or text inputs) + expect(paramNames).toContain("duration"); + expect(paramNames).toContain("generate_audio"); + + // Image inputs should NOT appear as parameters + expect(paramNames).not.toContain("start_image_url"); + expect(paramNames).not.toContain("end_image_url"); + }); + }); + + describe("anyOf/oneOf nullable schema patterns", () => { + // Helper to create fal.ai response with components.schemas for $ref resolution + function createFalModelResponseWithComponents( + inputProperties: Record, + required: string[] = [], + components?: Record + ) { + return { + ok: true, + json: () => Promise.resolve({ + models: [{ + openapi: { + paths: { + "/": { + post: { + requestBody: { + content: { + "application/json": { + schema: { + $ref: "#/components/schemas/Input", + }, + }, + }, + }, + }, + }, + }, + components: { + schemas: { + Input: { + type: "object", + properties: inputProperties, + required, + }, + ...components, + }, + }, + }, + }], + }), + }; + } + + it("should detect anyOf nullable string as image input (Kling pattern)", async () => { + // Exact pattern from Kling v2.6 image-to-video on Fal + mockFetch.mockResolvedValueOnce( + createFalModelResponseWithComponents({ + prompt: { + type: "string", + description: "Text prompt", + }, + image_url: { + type: "string", + description: "The URL of the image", + }, + end_image_url: { + anyOf: [{ type: "string" }, { type: "null" }], + description: "The URL of the end image", + }, + }, ["prompt", "image_url"]) + ); + + const modelId = `fal-ai/kling-anyof-${testCounter}`; + const request = createMockSchemaRequest(modelId, "fal"); + const response = await GET(request, { params: Promise.resolve({ modelId }) }); + const data = await response.json(); + + expect(response.status).toBe(200); + + const imageInputNames = data.inputs + .filter((i: { type: string }) => i.type === "image") + .map((i: { name: string }) => i.name); + + // Both image_url and end_image_url should be image inputs + expect(imageInputNames).toContain("image_url"); + expect(imageInputNames).toContain("end_image_url"); + }); + + it("should detect anyOf with format: uri as image input", async () => { + mockFetch.mockResolvedValueOnce( + createFalModelResponseWithComponents({ + reference_image: { + anyOf: [ + { type: "string", format: "uri" }, + { type: "null" }, + ], + description: "Reference image URL", + }, + }) + ); + + const modelId = `fal-ai/anyof-uri-${testCounter}`; + const request = createMockSchemaRequest(modelId, "fal"); + const response = await GET(request, { params: Promise.resolve({ modelId }) }); + const data = await response.json(); + + expect(response.status).toBe(200); + + const imageInputNames = data.inputs + .filter((i: { type: string }) => i.type === "image") + .map((i: { name: string }) => i.name); + + expect(imageInputNames).toContain("reference_image"); + }); + + it("should NOT detect anyOf with non-string types as image input", async () => { + mockFetch.mockResolvedValueOnce( + createFalModelResponseWithComponents({ + image_guidance_scale: { + anyOf: [{ type: "number" }, { type: "null" }], + description: "Image guidance scale", + }, + image_count: { + anyOf: [{ type: "integer" }, { type: "null" }], + description: "Number of images", + }, + }) + ); + + const modelId = `fal-ai/anyof-nonstring-${testCounter}`; + const request = createMockSchemaRequest(modelId, "fal"); + const response = await GET(request, { params: Promise.resolve({ modelId }) }); + const data = await response.json(); + + expect(response.status).toBe(200); + + const paramNames = data.parameters.map((p: { name: string }) => p.name); + const inputNames = data.inputs.map((i: { name: string }) => i.name); + + expect(paramNames).toContain("image_guidance_scale"); + expect(paramNames).toContain("image_count"); + expect(inputNames).not.toContain("image_guidance_scale"); + expect(inputNames).not.toContain("image_count"); + }); + + it("should handle oneOf pattern same as anyOf", async () => { + mockFetch.mockResolvedValueOnce( + createFalModelResponseWithComponents({ + start_image: { + oneOf: [{ type: "string" }, { type: "null" }], + description: "The start image URL", + }, + }) + ); + + const modelId = `fal-ai/oneof-${testCounter}`; + const request = createMockSchemaRequest(modelId, "fal"); + const response = await GET(request, { params: Promise.resolve({ modelId }) }); + const data = await response.json(); + + expect(response.status).toBe(200); + + const imageInputNames = data.inputs + .filter((i: { type: string }) => i.type === "image") + .map((i: { name: string }) => i.name); + + expect(imageInputNames).toContain("start_image"); + }); + + it("should resolve anyOf parameter types correctly (not default to string)", async () => { + mockFetch.mockResolvedValueOnce( + createFalModelResponseWithComponents({ + seed: { + anyOf: [{ type: "integer" }, { type: "null" }], + description: "Random seed", + }, + guidance_scale: { + anyOf: [{ type: "number" }, { type: "null" }], + description: "Guidance scale", + default: 7.5, + }, + }) + ); + + const modelId = `fal-ai/anyof-types-${testCounter}`; + const request = createMockSchemaRequest(modelId, "fal"); + const response = await GET(request, { params: Promise.resolve({ modelId }) }); + const data = await response.json(); + + expect(response.status).toBe(200); + + const seedParam = data.parameters.find((p: { name: string }) => p.name === "seed"); + const guidanceParam = data.parameters.find((p: { name: string }) => p.name === "guidance_scale"); + + expect(seedParam?.type).toBe("integer"); + expect(guidanceParam?.type).toBe("number"); + }); + + it("should NOT classify anyOf boolean with image in name as image input", async () => { + mockFetch.mockResolvedValueOnce( + createFalModelResponseWithComponents({ + enable_image_enhancement: { + anyOf: [{ type: "boolean" }, { type: "null" }], + description: "Enable image enhancement", + default: false, + }, + }) + ); + + const modelId = `fal-ai/anyof-bool-${testCounter}`; + const request = createMockSchemaRequest(modelId, "fal"); + const response = await GET(request, { params: Promise.resolve({ modelId }) }); + const data = await response.json(); + + expect(response.status).toBe(200); + + const paramNames = data.parameters.map((p: { name: string }) => p.name); + const inputNames = data.inputs.map((i: { name: string }) => i.name); + + expect(paramNames).toContain("enable_image_enhancement"); + expect(inputNames).not.toContain("enable_image_enhancement"); + }); + }); + describe("error handling", () => { it("should return 400 for invalid provider", async () => { const request = createMockSchemaRequest("test/model", "invalid"); diff --git a/src/app/api/models/[modelId]/route.ts b/src/app/api/models/[modelId]/route.ts index 1300fc5b..3c6ac278 100644 --- a/src/app/api/models/[modelId]/route.ts +++ b/src/app/api/models/[modelId]/route.ts @@ -123,10 +123,11 @@ function toLabel(name: string): string { * Image inputs must be strings (URLs or base64) or arrays of strings. * Integers, booleans, numbers with "image" in the name are NOT image inputs. */ -function isImageInput(name: string, prop: Record): boolean { +function isImageInput(name: string, prop: Record, schemaComponents?: Record): boolean { // First check: must be a string type (images are URLs or base64 strings) // Integers, booleans, numbers are NEVER image inputs regardless of name - const propType = prop.type as string | undefined; + const resolved = resolvePropertyType(prop, schemaComponents); + const propType = resolved.type; if (propType !== "string" && propType !== "array") { return false; } @@ -146,8 +147,8 @@ function isImageInput(name: string, prop: Record): boolean { return false; } - // Check format hints (OpenAPI format field) - strong signal for image URLs - const format = prop.format as string | undefined; + // Check format hints (OpenAPI format field or resolved format) - strong signal for image URLs + const format = (prop.format ?? resolved.format) as string | undefined; if (format === "uri" || format === "data-uri" || format === "binary") { // Only treat as image if name also suggests it's an image if (IMAGE_INPUT_PATTERNS.includes(name) || @@ -215,6 +216,68 @@ function resolveRef( return resolved || null; } +/** + * Resolve the effective type and format from an OpenAPI property. + * + * Handles wrapper patterns used by code generators (e.g. Pydantic → OpenAPI): + * - anyOf / oneOf: picks the first non-null type (nullable pattern) + * - allOf: merges referenced schemas + * - $ref: resolves from schemaComponents + * - Direct type: returns immediately (fast path — no behavior change) + */ +function resolvePropertyType( + prop: Record, + schemaComponents?: Record +): { type?: string; format?: string } { + // Fast path: direct type is defined — existing behaviour, no change + if (prop.type !== undefined) { + return { type: prop.type as string, format: prop.format as string | undefined }; + } + + // anyOf / oneOf — pick the first non-null variant + const variants = (prop.anyOf ?? prop.oneOf) as Array> | undefined; + if (variants && Array.isArray(variants)) { + for (const variant of variants) { + // Resolve $ref inside variant + if (variant.$ref && typeof variant.$ref === "string" && schemaComponents) { + const resolved = resolveRef(variant.$ref as string, schemaComponents); + if (resolved && resolved.type && resolved.type !== "null") { + return { type: resolved.type as string, format: (resolved.format ?? prop.format) as string | undefined }; + } + } + if (variant.type && variant.type !== "null") { + return { type: variant.type as string, format: (variant.format ?? prop.format) as string | undefined }; + } + } + } + + // allOf — merge referenced schemas + const allOf = prop.allOf as Array> | undefined; + if (allOf && Array.isArray(allOf) && schemaComponents) { + for (const item of allOf) { + if (item.$ref && typeof item.$ref === "string") { + const resolved = resolveRef(item.$ref as string, schemaComponents); + if (resolved && resolved.type) { + return { type: resolved.type as string, format: (resolved.format ?? prop.format) as string | undefined }; + } + } + if (item.type) { + return { type: item.type as string, format: (item.format ?? prop.format) as string | undefined }; + } + } + } + + // $ref at top level + if (prop.$ref && typeof prop.$ref === "string" && schemaComponents) { + const resolved = resolveRef(prop.$ref as string, schemaComponents); + if (resolved && resolved.type) { + return { type: resolved.type as string, format: (resolved.format ?? prop.format) as string | undefined }; + } + } + + return {}; +} + /** * Convert OpenAPI schema property to ModelParameter */ @@ -229,55 +292,80 @@ function convertSchemaProperty( return null; } - // Determine type and extract enum from allOf/$ref if present + // Determine type and extract enum from allOf/$ref/anyOf/oneOf if present let type: ModelParameter["type"] = "string"; let enumValues: unknown[] | undefined; let resolvedDefault: unknown; let resolvedDescription: string | undefined; - const schemaType = prop.type as string | undefined; - const allOf = prop.allOf as Array> | undefined; + // Use resolvePropertyType() to handle anyOf/oneOf/allOf/$ref patterns + const resolved = resolvePropertyType(prop, schemaComponents); + const effectiveType = resolved.type; - if (schemaType === "integer") { + if (effectiveType === "integer") { type = "integer"; - } else if (schemaType === "number") { + } else if (effectiveType === "number") { type = "number"; - } else if (schemaType === "boolean") { + } else if (effectiveType === "boolean") { type = "boolean"; - } else if (schemaType === "array") { + } else if (effectiveType === "array") { type = "array"; - } else if (allOf && allOf.length > 0 && schemaComponents) { - // Handle allOf with $ref - resolve references and extract enum/type + } + + // Extract enum/default/description from allOf with $ref + const allOf = prop.allOf as Array> | undefined; + if (allOf && allOf.length > 0 && schemaComponents) { for (const item of allOf) { const itemRef = item.$ref as string | undefined; if (itemRef) { - const resolved = resolveRef(itemRef, schemaComponents); - if (resolved) { - // Extract type from resolved schema - if (resolved.type === "integer") type = "integer"; - else if (resolved.type === "number") type = "number"; - else if (resolved.type === "boolean") type = "boolean"; - - // Extract enum from resolved schema - if (Array.isArray(resolved.enum)) { - enumValues = resolved.enum; + const refResolved = resolveRef(itemRef, schemaComponents); + if (refResolved) { + if (Array.isArray(refResolved.enum)) { + enumValues = refResolved.enum; } - // Extract default from resolved schema - if (resolved.default !== undefined && resolvedDefault === undefined) { - resolvedDefault = resolved.default; + if (refResolved.default !== undefined && resolvedDefault === undefined) { + resolvedDefault = refResolved.default; } - // Extract description from resolved schema - if (resolved.description && !resolvedDescription) { - resolvedDescription = resolved.description as string; + if (refResolved.description && !resolvedDescription) { + resolvedDescription = refResolved.description as string; } } } else if (Array.isArray(item.enum)) { - // Direct enum in allOf item enumValues = item.enum; } } } + // Extract enum/default/description from anyOf/oneOf variants + const variants = (prop.anyOf ?? prop.oneOf) as Array> | undefined; + if (variants && Array.isArray(variants)) { + for (const variant of variants) { + if (variant.type === "null") continue; + // Resolve $ref inside variant + if (variant.$ref && typeof variant.$ref === "string" && schemaComponents) { + const refResolved = resolveRef(variant.$ref as string, schemaComponents); + if (refResolved) { + if (Array.isArray(refResolved.enum) && !enumValues) { + enumValues = refResolved.enum; + } + if (refResolved.default !== undefined && resolvedDefault === undefined) { + resolvedDefault = refResolved.default; + } + if (refResolved.description && !resolvedDescription) { + resolvedDescription = refResolved.description as string; + } + } + } else { + if (Array.isArray(variant.enum) && !enumValues) { + enumValues = variant.enum; + } + if (variant.default !== undefined && resolvedDefault === undefined) { + resolvedDefault = variant.default; + } + } + } + } + const parameter: ModelParameter = { name, type, @@ -439,14 +527,15 @@ function extractParametersFromSchema( for (const [name, prop] of Object.entries(properties)) { // Check if this is a connectable input (image or text) // Pass both name AND prop to check schema type, not just name - if (isImageInput(name, prop)) { + if (isImageInput(name, prop, schemaComponents)) { + const resolvedType = resolvePropertyType(prop, schemaComponents).type; inputs.push({ name, type: "image", required: required.includes(name), label: toLabel(name), description: prop.description as string | undefined, - isArray: prop.type === "array", + isArray: resolvedType === "array", }); continue; }