diff --git a/src/components/__tests__/GenerateVideoNode.test.tsx b/src/components/__tests__/GenerateVideoNode.test.tsx index 9be89136..295f2a3d 100644 --- a/src/components/__tests__/GenerateVideoNode.test.tsx +++ b/src/components/__tests__/GenerateVideoNode.test.tsx @@ -819,6 +819,61 @@ describe("GenerateVideoNode", () => { expect(screen.getByLabelText("Sound")).toBeChecked(); }); + it("should not resize repeatedly when a non-sound video model schema is stable", async () => { + window.localStorage.removeItem("node-banana-schema-cache"); + mockFetch.mockImplementation((url) => { + const requestUrl = String(url); + if (requestUrl.startsWith("/api/models?")) { + return Promise.resolve({ + ok: true, + json: () => Promise.resolve({ models: [] }), + }); + } + if (requestUrl.startsWith("/api/models/")) { + return Promise.resolve({ + ok: true, + json: () => Promise.resolve({ + parameters: [{ name: "motion", type: "string", label: "Motion" }], + inputs: [{ name: "prompt", type: "text", required: true, label: "Prompt" }], + }), + }); + } + return Promise.resolve({ + ok: true, + json: () => Promise.resolve({}), + }); + }); + + const props = createNodeProps({ + selectedModel: { + provider: "newapiwg", + modelId: "vidu-q1", + displayName: "Vidu Q1", + capabilities: ["text-to-video"], + }, + }); + + const { rerender } = render( + + + + ); + + await waitFor(() => { + expect(mockSetNodes.mock.calls.length).toBeGreaterThan(0); + }); + const resizeCallsAfterSchemaLoad = mockSetNodes.mock.calls.length; + + rerender( + + + + ); + await new Promise((resolve) => setTimeout(resolve, 0)); + + expect(mockSetNodes.mock.calls.length).toBe(resizeCallsAfterSchemaLoad); + }); + it("should render ModelParameters when model is selected", async () => { render( diff --git a/src/components/nodes/GenerateVideoNode.tsx b/src/components/nodes/GenerateVideoNode.tsx index 3caac153..f70f68bc 100644 --- a/src/components/nodes/GenerateVideoNode.tsx +++ b/src/components/nodes/GenerateVideoNode.tsx @@ -39,6 +39,10 @@ import { // Video generation capabilities const VIDEO_CAPABILITIES: ModelCapability[] = ["text-to-video", "image-to-video", "video-to-video", "audio-to-video"]; +const VIDEO_BASIC_PARAMETER_NAMES = [ + ...VIDEO_DURATION_PARAMETER_NAMES, + ...VIDEO_RESOLUTION_PARAMETER_NAMES, +]; /** Returns true for Gemini-native Veo video models */ function isVeoModel(modelId: string | undefined): boolean { @@ -312,13 +316,16 @@ export function GenerateVideoNode({ id, data, selected }: NodeProps - nodes.map((node) => - node.id === id - ? { ...node, style: { ...node.style, height: newHeight } } - : node - ) - ); + setNodes((nodes) => { + let changed = false; + const nextNodes = nodes.map((node) => { + if (node.id !== id) return node; + if (node.style?.height === newHeight) return node; + changed = true; + return { ...node, style: { ...node.style, height: newHeight } }; + }); + return changed ? nextNodes : nodes; + }); }, [id, setNodes] ); @@ -436,9 +443,10 @@ export function GenerateVideoNode({ id, data, selected }: NodeProps videoSoundSupported ? VIDEO_FIRST_CLASS_PARAMETER_NAMES : VIDEO_BASIC_PARAMETER_NAMES, + [videoSoundSupported] + ); // Provider badge as title prefix const titlePrefix = useMemo(() => ( diff --git a/src/components/nodes/ModelParameters.tsx b/src/components/nodes/ModelParameters.tsx index 97517e61..e232328e 100644 --- a/src/components/nodes/ModelParameters.tsx +++ b/src/components/nodes/ModelParameters.tsx @@ -85,6 +85,7 @@ function ModelParametersInner({ const [error, setError] = useState(null); // Use stable selector for API keys to prevent unnecessary re-fetches const { replicateApiKey, falApiKey, kieApiKey, wavespeedApiKey } = useProviderApiKeys(); + const lastExpandCountRef = useRef(null); // Fetch schema when modelId changes useEffect(() => { @@ -163,6 +164,7 @@ function ModelParametersInner({ () => schema.filter((param) => !hiddenParameterSet.has(param.name)), [hiddenParameterSet, schema] ); + const visibleParameterCount = visibleSchema.length; // Remove parameters controlled by a higher-priority surface, such as the bottom composer. useEffect(() => { @@ -202,10 +204,16 @@ function ModelParametersInner({ // Notify parent to resize node when schema loads useEffect(() => { - if (visibleSchema.length > 0 && onExpandChange) { - onExpandChange(true, visibleSchema.length); + if (visibleParameterCount === 0) { + lastExpandCountRef.current = null; + return; + } + if (lastExpandCountRef.current === visibleParameterCount) return; + lastExpandCountRef.current = visibleParameterCount; + if (visibleParameterCount > 0 && onExpandChange) { + onExpandChange(true, visibleParameterCount); } - }, [visibleSchema, onExpandChange]); + }, [visibleParameterCount, onExpandChange]); const handleParameterChange = useCallback( (name: string, value: unknown) => { diff --git a/src/store/__tests__/skipPropagation.test.ts b/src/store/__tests__/skipPropagation.test.ts index e888f710..236e811f 100644 --- a/src/store/__tests__/skipPropagation.test.ts +++ b/src/store/__tests__/skipPropagation.test.ts @@ -438,6 +438,32 @@ describe("Skip propagation", () => { expect(useWorkflowStore.getState().skippedNodeIds.size).toBe(0); }); + + it("should clear loading status on stopped nodes with existing video output", () => { + useWorkflowStore.setState({ + nodes: [ + createTestNode("video-1", "generateVideo", { + inputImages: [], + inputPrompt: "happy fuji", + outputVideo: "https://example.com/fuji.mp4", + outputVideoStorageStatus: "remote-only", + status: "loading", + error: null, + videoHistory: [], + selectedVideoHistoryIndex: 0, + }), + ], + currentNodeIds: ["video-1"], + isRunning: true, + }); + + const store = useWorkflowStore.getState(); + store.stopWorkflow(); + + const node = useWorkflowStore.getState().nodes[0]; + expect(node.data.status).toBe("complete"); + expect(node.data.outputVideo).toBe("https://example.com/fuji.mp4"); + }); }); describe("Multi-level cascade", () => { diff --git a/src/store/__tests__/workflowStore.integration.test.ts b/src/store/__tests__/workflowStore.integration.test.ts index 4b553545..d688ac23 100644 --- a/src/store/__tests__/workflowStore.integration.test.ts +++ b/src/store/__tests__/workflowStore.integration.test.ts @@ -98,6 +98,43 @@ describe("workflowStore integration tests", () => { resetStore(); }); + describe("onNodesChange no-op guards", () => { + it("should ignore duplicate measured dimensions from React Flow", () => { + const node = { + ...createTestNode("video-1", "generateVideo", {}), + measured: { width: 360, height: 300 }, + } as WorkflowNode; + const nodes = [node]; + useWorkflowStore.setState({ nodes, hasUnsavedChanges: false }); + + useWorkflowStore.getState().onNodesChange([ + { id: "video-1", type: "dimensions", dimensions: { width: 360, height: 300 } }, + ]); + + expect(useWorkflowStore.getState().nodes).toBe(nodes); + expect(useWorkflowStore.getState().hasUnsavedChanges).toBe(false); + }); + + it("should apply dimensions when the measured value changes", () => { + useWorkflowStore.setState({ + nodes: [ + { + ...createTestNode("video-1", "generateVideo", {}), + measured: { width: 360, height: 300 }, + } as WorkflowNode, + ], + hasUnsavedChanges: false, + }); + + useWorkflowStore.getState().onNodesChange([ + { id: "video-1", type: "dimensions", dimensions: { width: 420, height: 300 } }, + ]); + + expect(useWorkflowStore.getState().nodes[0].measured?.width).toBe(420); + expect(useWorkflowStore.getState().hasUnsavedChanges).toBe(false); + }); + }); + describe("getConnectedInputs", () => { describe("Basic data extraction scenarios", () => { it("should extract image from imageInput node", () => { @@ -2298,15 +2335,45 @@ describe("workflowStore integration tests", () => { const store = useWorkflowStore.getState(); await store.loadWorkflow({ + version: 1, id: "test-workflow", name: "Test", nodes: [], edges: [], + edgeStyle: "angular", }); expect(useWorkflowStore.getState().viewedCommentNodeIds.size).toBe(0); }); + it("should clear transient loading status when loading a workflow with an existing video output", async () => { + const store = useWorkflowStore.getState(); + await store.loadWorkflow({ + version: 1, + id: "test-workflow", + name: "Happy Fuji", + nodes: [ + createTestNode("video-1", "generateVideo", { + inputImages: [], + inputPrompt: "happy fuji", + outputVideo: "https://example.com/fuji.mp4", + outputVideoStorageStatus: "remote-only", + status: "loading", + error: null, + videoHistory: [], + selectedVideoHistoryIndex: 0, + }), + ], + edges: [], + edgeStyle: "angular", + }); + + const node = useWorkflowStore.getState().nodes[0]; + expect(node.data.status).toBe("complete"); + expect(node.data.outputVideo).toBe("https://example.com/fuji.mp4"); + expect(node.data.outputVideoStorageStatus).toBe("remote-only"); + }); + it("should promote a NewApiWG fallback when env status is known before workflow load", async () => { useWorkflowStore.setState({ providerSettings: defaultProviderSettings, @@ -2324,6 +2391,7 @@ describe("workflowStore integration tests", () => { const store = useWorkflowStore.getState(); await store.loadWorkflow({ + version: 1, id: "test-workflow", name: "Test", nodes: [ @@ -2347,6 +2415,7 @@ describe("workflowStore integration tests", () => { }), ], edges: [], + edgeStyle: "angular", }); const node = useWorkflowStore.getState().nodes[0]; diff --git a/src/store/workflowStore.ts b/src/store/workflowStore.ts index af574b9f..59d82505 100644 --- a/src/store/workflowStore.ts +++ b/src/store/workflowStore.ts @@ -596,6 +596,134 @@ function promoteUnavailableGeminiFallbacks( return { nodes: nextNodes, changed }; } +const NODE_OUTPUT_KEYS = [ + "outputImage", + "outputImageRef", + "outputVideo", + "outputVideoRef", + "outputVideoRemoteUrl", + "outputAudio", + "outputAudioRef", + "outputText", + "output3dUrl", + "savedFilePath", + "capturedImage", + "capturedImageRef", + "image", + "imageRef", + "video", + "videoRef", + "audio", + "model3d", + "glbUrl", + "images", + "imageRefs", + "videos", + "videoRefs", + "outputItems", +] as const; + +function hasUsableOutputValue(value: unknown): boolean { + if (value === null || value === undefined) return false; + if (typeof value === "string") { + const trimmed = value.trim(); + return trimmed.length > 0 && trimmed !== "[]"; + } + if (Array.isArray(value)) return value.some(hasUsableOutputValue); + return true; +} + +function hasNodeOutput(data: WorkflowNodeData): boolean { + const record = data as Record; + return NODE_OUTPUT_KEYS.some((key) => hasUsableOutputValue(record[key])); +} + +function resetTransientExecutionState(nodes: WorkflowNode[]): { nodes: WorkflowNode[]; changed: boolean } { + let changed = false; + const nextNodes = nodes.map((node) => { + const data = node.data as WorkflowNodeData; + const status = (data as Record).status; + if (status !== "loading" && status !== "skipped") return node; + + changed = true; + return { + ...node, + data: { + ...data, + status: hasNodeOutput(data) ? "complete" : "idle", + error: null, + } as WorkflowNodeData, + } as WorkflowNode; + }); + + return { nodes: nextNodes as WorkflowNode[], changed }; +} + +function numbersMatch(a: unknown, b: unknown): boolean { + return typeof a === "number" && typeof b === "number" && Math.abs(a - b) < 0.5; +} + +function positionMatches(a: XYPosition | undefined, b: XYPosition | undefined): boolean { + return !!a && !!b && numbersMatch(a.x, b.x) && numbersMatch(a.y, b.y); +} + +function isDimensionValueEffective( + node: WorkflowNode, + axis: "width" | "height", + value: number | undefined, + setAttributes: boolean | "width" | "height" | undefined, +): boolean { + if (value === undefined) return false; + + if (!numbersMatch(node.measured?.[axis], value)) { + return true; + } + + if ( + setAttributes === true || + setAttributes === axis + ) { + return !numbersMatch(node[axis], value); + } + + return false; +} + +function isNodeChangeEffective( + change: NodeChange, + nodeById: Map, +): boolean { + switch (change.type) { + case "add": + case "replace": + return true; + case "remove": + return nodeById.has(change.id); + case "select": { + const node = nodeById.get(change.id); + return !!node && node.selected !== change.selected; + } + case "position": { + const node = nodeById.get(change.id); + if (!node) return false; + const positionChanged = change.position !== undefined && !positionMatches(node.position, change.position); + const draggingChanged = typeof change.dragging === "boolean" && node.dragging !== change.dragging; + return positionChanged || draggingChanged; + } + case "dimensions": { + const node = nodeById.get(change.id); + if (!node) return false; + const widthChanged = isDimensionValueEffective(node, "width", change.dimensions?.width, change.setAttributes); + const heightChanged = isDimensionValueEffective(node, "height", change.dimensions?.height, change.setAttributes); + const resizing = (node as WorkflowNode & { resizing?: boolean }).resizing; + const resizingChanged = typeof change.resizing === "boolean" && resizing !== change.resizing; + return widthChanged || heightChanged || resizingChanged; + } + default: + return true; + } +} + const workflowStoreImpl: StateCreator = (set, get) => ({ nodes: [], edges: [], @@ -829,18 +957,22 @@ const workflowStoreImpl: StateCreator = (set, get) => ({ }, onNodesChange: (changes: NodeChange[]) => { + const nodeById = new Map(get().nodes.map((node) => [node.id, node])); + const effectiveChanges = changes.filter((change) => isNodeChangeEffective(change, nodeById)); + if (effectiveChanges.length === 0) return; + // Only mark as unsaved for meaningful changes (not selection changes) - const hasMeaningfulChange = changes.some( + const hasMeaningfulChange = effectiveChanges.some( (c) => c.type !== "select" && c.type !== "dimensions" ); // Track manual changes only for remove operations (not position/selection/dimensions) - const hasRemoveChange = changes.some((c) => c.type === "remove"); + const hasRemoveChange = effectiveChanges.some((c) => c.type === "remove"); // Undo: capture snapshot on drag start - const hasDragStart = changes.some( + const hasDragStart = effectiveChanges.some( (c) => c.type === "position" && (c as { dragging?: boolean }).dragging === true ); - const hasDragEnd = changes.some( + const hasDragEnd = effectiveChanges.some( (c) => c.type === "position" && (c as { dragging?: boolean }).dragging === false ); if (hasDragStart && !isDragging) { @@ -861,7 +993,7 @@ const workflowStoreImpl: StateCreator = (set, get) => ({ } set((state) => ({ - nodes: applyNodeChanges(changes, state.nodes), + nodes: applyNodeChanges(effectiveChanges, state.nodes), ...(hasMeaningfulChange ? { hasUnsavedChanges: true } : {}), })); @@ -1724,7 +1856,17 @@ const workflowStoreImpl: StateCreator = (set, get) => ({ if (controller) { controller.abort("user-cancelled"); } - set({ isRunning: false, currentNodeIds: [], skippedNodeIds: new Set(), _abortController: null }); + set((state) => { + const reset = resetTransientExecutionState(state.nodes); + return { + nodes: reset.nodes, + isRunning: false, + currentNodeIds: [], + skippedNodeIds: new Set(), + _abortController: null, + hasUnsavedChanges: state.hasUnsavedChanges || reset.changed, + }; + }); }, mockTutorialExecution: async () => { @@ -2315,6 +2457,14 @@ const workflowStoreImpl: StateCreator = (set, get) => ({ }; } + const restoredExecutionState = resetTransientExecutionState(hydratedWorkflow.nodes as WorkflowNode[]); + if (restoredExecutionState.changed) { + hydratedWorkflow = { + ...hydratedWorkflow, + nodes: restoredExecutionState.nodes, + }; + } + // Load cost data for this workflow const costData = workflow.id ? loadWorkflowCostData(workflow.id) : null; @@ -2343,7 +2493,6 @@ const workflowStoreImpl: StateCreator = (set, get) => ({ ? joinBrowserFileSystemPath(directoryPath, "generations") : null), lastSavedAt: savedConfig?.lastSavedAt || null, - hasUnsavedChanges: promotedModels.changed, // Restore cost data incurredCost: costData?.incurredCost || 0, // Track where imageRefs are valid from @@ -2354,6 +2503,7 @@ const workflowStoreImpl: StateCreator = (set, get) => ({ viewedCommentNodeIds: new Set(), // Dismiss welcome modal after loading a workflow showQuickstart: false, + hasUnsavedChanges: promotedModels.changed || restoredExecutionState.changed, }); // Clear snapshot unless explicitly preserving (e.g., AI workflow generation)