From 35d4961d353292c53f9927acf8c0a261e579fb78 Mon Sep 17 00:00:00 2001 From: TianYun Date: Sat, 6 Jun 2026 13:36:21 +0800 Subject: [PATCH] =?UTF-8?q?=E4=B8=8A=E4=BC=A0=E5=A2=9E=E5=8A=A0loading?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/components/FloatingActionBar.tsx | 245 +++++++++++++++++- .../composer/GenerationComposer.tsx | 25 +- src/components/nodes/AudioInputNode.tsx | 20 +- src/components/nodes/ImageInputNode.tsx | 50 ++-- src/components/nodes/SmartAudioNode.tsx | 27 +- src/components/nodes/SmartImageNode.tsx | 61 +++-- src/components/nodes/SmartVideoNode.tsx | 31 ++- src/components/nodes/VideoInputNode.tsx | 19 +- .../workflowStore.integration.test.ts | 31 +++ .../utils/__tests__/connectedInputs.test.ts | 6 + src/store/utils/connectedInputs.ts | 5 +- src/store/utils/nodeDefaults.ts | 12 + src/store/workflowStore.ts | 21 +- src/types/nodes.ts | 6 + src/utils/__tests__/generationConfig.test.ts | 2 + src/utils/generationConfig.ts | 3 + 16 files changed, 479 insertions(+), 85 deletions(-) diff --git a/src/components/FloatingActionBar.tsx b/src/components/FloatingActionBar.tsx index 3e371ac7..08b9bc91 100644 --- a/src/components/FloatingActionBar.tsx +++ b/src/components/FloatingActionBar.tsx @@ -1,6 +1,6 @@ "use client"; -import { useEffect, useRef, useState } from "react"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { CloseOutlined, PictureOutlined, PlusOutlined } from "@ant-design/icons"; import { useWorkflowStore } from "@/store/workflowStore"; import { NodeType } from "@/types"; @@ -11,6 +11,7 @@ import { GenerateNodeMenu } from "./GenerateNodeMenu"; import { ModelSearchDialog } from "./modals/ModelSearchDialog"; import { AssetPickerModal, PopiAssetItem } from "./AssetPickerModal"; import { getPopiImageAssetPreview, resolvePopiImageAsset } from "@/lib/popiAssetApi"; +import { useFTUXStore, TutorialStep } from "@/store/ftuxStore"; // Get the center of the React Flow pane in screen coordinates function getPaneCenter() { @@ -195,16 +196,131 @@ function ResourceImageButton() { export function FloatingActionBar() { const { t } = useI18n(); + const nodes = useWorkflowStore((state) => state.nodes) ?? []; + const isRunning = useWorkflowStore((state) => state.isRunning) ?? false; + const currentNodeIds = useWorkflowStore((state) => state.currentNodeIds) ?? []; + const executeWorkflow = useWorkflowStore((state) => state.executeWorkflow) ?? (async () => {}); + const regenerateNode = useWorkflowStore((state) => state.regenerateNode) ?? (async () => {}); + const executeSelectedNodes = useWorkflowStore((state) => state.executeSelectedNodes) ?? (async () => {}); + const stopWorkflow = useWorkflowStore((state) => state.stopWorkflow) ?? (() => {}); + const mockTutorialExecution = useWorkflowStore((state) => state.mockTutorialExecution) ?? (async () => {}); + const validateWorkflow = useWorkflowStore((state) => state.validateWorkflow); const edgeStyle = useWorkflowStore((state) => state.edgeStyle); const setEdgeStyle = useWorkflowStore((state) => state.setEdgeStyle); const setModelSearchOpen = useWorkflowStore((state) => state.setModelSearchOpen); const modelSearchOpen = useWorkflowStore((state) => state.modelSearchOpen); const modelSearchProvider = useWorkflowStore((state) => state.modelSearchProvider); + const [tutorialActive, setTutorialActive] = useState(false); + const [currentTutorialStep, setCurrentTutorialStep] = useState(0); + const [tutorialSteps, setTutorialSteps] = useState([]); + const [runMenuOpen, setRunMenuOpen] = useState(false); + const runMenuRef = useRef(null); + const { valid, errors } = validateWorkflow?.() ?? { valid: true, errors: [] }; + + useEffect(() => { + const unsubscribe = useFTUXStore.subscribe((state) => { + setTutorialActive(state.tutorialActive); + setCurrentTutorialStep(state.currentTutorialStep); + setTutorialSteps(state.tutorialSteps); + }); + + const currentState = useFTUXStore.getState(); + setTutorialActive(currentState.tutorialActive); + setCurrentTutorialStep(currentState.currentTutorialStep); + setTutorialSteps(currentState.tutorialSteps); + + return unsubscribe; + }, []); + + const selectedNodes = useMemo(() => nodes.filter((node) => node.selected), [nodes]); + const selectedNode = useMemo(() => selectedNodes.length === 1 ? selectedNodes[0] : null, [selectedNodes]); + + const isRunOptionsTutorialStep = useMemo(() => { + if (!tutorialActive || tutorialSteps.length === 0) return false; + const currentStep = tutorialSteps[currentTutorialStep]; + return currentStep?.id === "explain-run-options"; + }, [currentTutorialStep, tutorialActive, tutorialSteps]); + + useEffect(() => { + const handleClickOutside = (event: MouseEvent) => { + if (runMenuRef.current && !runMenuRef.current.contains(event.target as Node) && !isRunOptionsTutorialStep) { + setRunMenuOpen(false); + } + }; + + if (runMenuOpen) { + document.addEventListener("mousedown", handleClickOutside); + } + + return () => { + document.removeEventListener("mousedown", handleClickOutside); + }; + }, [isRunOptionsTutorialStep, runMenuOpen]); + + useEffect(() => { + if (isRunOptionsTutorialStep) { + setRunMenuOpen(true); + } + }, [isRunOptionsTutorialStep]); + + useEffect(() => { + if (!tutorialActive || tutorialSteps.length === 0) return; + const currentStep = tutorialSteps[currentTutorialStep]; + if ( + currentStep?.id === "run-workflow" || + currentStep?.id === "demonstrate-downstream" || + currentStep?.id === "demonstrate-complete" + ) { + setRunMenuOpen(false); + } + }, [currentTutorialStep, tutorialActive, tutorialSteps]); + + const runningNodeCount = currentNodeIds.length; + const getRunningLabel = useCallback(() => { + if (runningNodeCount === 0) return t("toolbar.running"); + if (runningNodeCount === 1) { + const node = nodes.find((item) => item.id === currentNodeIds[0]); + const nodeName = node?.data?.customTitle || node?.type || "node"; + return t("toolbar.runningNode", { node: nodeName }); + } + return t("toolbar.runningNodes", { count: runningNodeCount }); + }, [currentNodeIds, nodes, runningNodeCount, t]); const toggleEdgeStyle = () => { setEdgeStyle(edgeStyle === "angular" ? "curved" : "angular"); }; + const handleRunClick = useCallback(() => { + const ftuxState = useFTUXStore.getState(); + const currentStep = ftuxState.tutorialSteps[ftuxState.currentTutorialStep]; + + if (isRunning) { + stopWorkflow(); + } else if (ftuxState.tutorialActive && currentStep?.id === "run-workflow") { + void mockTutorialExecution(); + } else { + void executeWorkflow(); + } + }, [executeWorkflow, isRunning, mockTutorialExecution, stopWorkflow]); + + const handleRunFromSelected = useCallback(() => { + if (!selectedNode) return; + void executeWorkflow(selectedNode.id); + setRunMenuOpen(false); + }, [executeWorkflow, selectedNode]); + + const handleRunSelectedOnly = useCallback(() => { + if (!selectedNode) return; + void regenerateNode(selectedNode.id); + setRunMenuOpen(false); + }, [regenerateNode, selectedNode]); + + const handleRunSelectedNodes = useCallback(() => { + if (selectedNodes.length === 0) return; + void executeSelectedNodes(selectedNodes.map((node) => node.id)); + setRunMenuOpen(false); + }, [executeSelectedNodes, selectedNodes]); + return (
@@ -239,6 +355,133 @@ export function FloatingActionBar() { )} + +
+ + + {!isRunning && valid && ( + + )} + + {runMenuOpen && !isRunning && ( +
+ + + + +
+ )} +
node.id === activeContext.node?.id); const currentModel = (freshNode?.data as { selectedModel?: SelectedModel } | undefined)?.selectedModel; if (isSameSelectedModel(currentModel, nextModel)) { + flushCurrentDraft(); if (activeContext.node) { updateNodeData(activeContext.node.id, { modelSchemaRequestId: Date.now() }); } - clearDirtyFields(); return; } + const modelDirtyFields = new Set(dirtyFieldsRef.current); + modelDirtyFields.add("selectedModel"); + modelDirtyFields.add("resolution"); + modelDirtyFields.add("parameters"); + if (modelSupportsCapability(nextModel, "video")) { + modelDirtyFields.add("durationSeconds"); + } + if (getDefaultVideoSoundEnabled(nextModel) !== undefined) { + modelDirtyFields.add("soundEnabled"); + } const modelPatch = activeAdapter.buildPatch( { ...draftRef.current, @@ -1972,7 +1979,7 @@ export function GenerationComposer() { soundEnabled: getDefaultVideoSoundEnabled(nextModel), parameters: {}, }, - new Set(["selectedModel", "resolution", "parameters"]) + modelDirtyFields ); updateNodeData(activeContext.node.id, modelPatch); setDraft((current) => { @@ -2014,7 +2021,7 @@ export function GenerationComposer() { }); } }, - [clearDirtyFields, updateNodeData] + [clearDirtyFields, flushCurrentDraft, updateNodeData] ); useEffect(() => { diff --git a/src/components/nodes/AudioInputNode.tsx b/src/components/nodes/AudioInputNode.tsx index 7344dcd6..5d63ba59 100644 --- a/src/components/nodes/AudioInputNode.tsx +++ b/src/components/nodes/AudioInputNode.tsx @@ -3,6 +3,7 @@ import { useCallback, useRef, useState, useEffect } from "react"; import { CustomerServiceOutlined } from "@ant-design/icons"; import { Handle, Position, NodeProps, Node } from "@xyflow/react"; +import { Spin } from "antd"; import { BaseNode } from "./BaseNode"; import { useWorkflowStore } from "@/store/workflowStore"; import { AudioInputNodeData } from "@/types"; @@ -53,6 +54,7 @@ export function AudioInputNodeView({ const updateNodeData = useWorkflowStore((state) => state.updateNodeData); const selectSingleNode = useWorkflowStore((state) => state.selectSingleNode); const fileInputRef = useRef(null); + const isUploading = nodeData.uploadStatus === "loading"; const [audioBlob, setAudioBlob] = useState(null); @@ -99,6 +101,8 @@ export function AudioInputNodeView({ return; } + updateNodeData(id, { uploadStatus: "loading", uploadError: null }); + const metadataUrl = URL.createObjectURL(file); const applyUpload = (duration: number | null) => { URL.revokeObjectURL(metadataUrl); @@ -106,13 +110,18 @@ export function AudioInputNodeView({ .then((uploaded) => { updateNodeData(id, { audioFile: uploaded.url, + audioFileRef: undefined, + uploadStatus: "idle", + uploadError: null, filename: uploaded.filename, format: uploaded.contentType, duration, }); }) .catch((error) => { - alert(error instanceof Error ? error.message : "Media upload failed"); + const message = error instanceof Error ? error.message : "Media upload failed"; + updateNodeData(id, { uploadStatus: "error", uploadError: message }); + alert(message); }); }; @@ -167,8 +176,9 @@ export function AudioInputNodeView({ }, [id, updateNodeData, audioRef]); const handleOpenFileDialog = useCallback(() => { + if (isUploading) return; fileInputRef.current?.click(); - }, []); + }, [isUploading]); const title = getAudioNodeTitle(id, nodeData, t("node.audioInput")); @@ -333,6 +343,12 @@ export function AudioInputNodeView({
)} + {isUploading && ( +
+ +
+ )} + (null); const [isPreviewOpen, setIsPreviewOpen] = useState(false); const [isAssetPickerOpen, setIsAssetPickerOpen] = useState(false); + const isUploading = nodeData.uploadStatus === "loading"; useEffect(() => { if (!nodeData.image || !nodeData.dimensions || nodeData.image === prevImageRef.current) { @@ -149,11 +151,9 @@ export function ImageInputNodeView({ id, data, selected }: ImageInputNodeViewPro // return; // } - const objectUrl = URL.createObjectURL(file); - const img = new Image(); - img.onload = () => { - const dimensions = { width: img.width, height: img.height }; - URL.revokeObjectURL(objectUrl); + updateNodeData(id, { uploadStatus: "loading", uploadError: null }); + + const uploadImage = (dimensions: { width: number; height: number } | null) => { uploadFileToGatewayMedia(file, "image") .then((uploaded) => { updateNodeData(id, { @@ -163,32 +163,29 @@ export function ImageInputNodeView({ id, data, selected }: ImageInputNodeViewPro previewImage: undefined, assetDetailLoading: false, assetDetailError: null, + uploadStatus: "idle", + uploadError: null, filename: uploaded.filename, dimensions, }); }) .catch((error) => { - alert(error instanceof Error ? error.message : "Media upload failed"); + const message = error instanceof Error ? error.message : "Media upload failed"; + updateNodeData(id, { uploadStatus: "error", uploadError: message }); + alert(message); }); }; + + const objectUrl = URL.createObjectURL(file); + const img = new Image(); + img.onload = () => { + const dimensions = { width: img.width, height: img.height }; + URL.revokeObjectURL(objectUrl); + uploadImage(dimensions); + }; img.onerror = () => { URL.revokeObjectURL(objectUrl); - uploadFileToGatewayMedia(file, "image") - .then((uploaded) => { - updateNodeData(id, { - image: uploaded.url, - imageRef: undefined, - assetId: undefined, - previewImage: undefined, - assetDetailLoading: false, - assetDetailError: null, - filename: uploaded.filename, - dimensions: null, - }); - }) - .catch((error) => { - alert(error instanceof Error ? error.message : "Media upload failed"); - }); + uploadImage(null); }; img.src = objectUrl; }, @@ -219,8 +216,9 @@ export function ImageInputNodeView({ id, data, selected }: ImageInputNodeViewPro }, []); const handleOpenFileDialog = useCallback(() => { + if (isUploading) return; fileInputRef.current?.click(); - }, []); + }, [isUploading]); const handleSelect = useCallback(() => { selectSingleNode(id); @@ -526,6 +524,12 @@ export function ImageInputNodeView({ id, data, selected }: ImageInputNodeViewPro )} + {isUploading && ( +
+ +
+ )} + {/* Handles rendered after visual content so they paint on top */} state.updateNodeData); @@ -51,15 +54,20 @@ function SmartAudioUploadToolbar({ return; } + updateNodeData(id, { uploadStatus: "loading", uploadError: null }); + const metadataUrl = URL.createObjectURL(file); const applyUpload = (duration: number | null) => { URL.revokeObjectURL(metadataUrl); uploadFileToGatewayMedia(file, "audio") .then((uploaded) => { + updateNodeData(id, { uploadStatus: "idle", uploadError: null }); applyAudio(uploaded.url, uploaded.filename, uploaded.contentType, duration); }) .catch((error) => { - alert(error instanceof Error ? error.message : "Media upload failed"); + const message = error instanceof Error ? error.message : "Media upload failed"; + updateNodeData(id, { uploadStatus: "error", uploadError: message }); + alert(message); }); }; @@ -67,7 +75,7 @@ function SmartAudioUploadToolbar({ audio.onloadedmetadata = () => applyUpload(Number.isFinite(audio.duration) ? audio.duration : null); audio.onerror = () => applyUpload(null); }, - [applyAudio, t] + [applyAudio, id, t, updateNodeData] ); const handleFileChange = useCallback( @@ -79,9 +87,10 @@ function SmartAudioUploadToolbar({ ); const handleOpenFileDialog = useCallback(() => { + if (isUploading) return; selectSingleNode(id); fileInputRef.current?.click(); - }, [id, selectSingleNode]); + }, [id, isUploading, selectSingleNode]); return ( <> @@ -103,7 +112,8 @@ function SmartAudioUploadToolbar({ handleOpenFileDialog(); }} aria-label={t("imageInput.localUpload")} - className="flex h-10 items-center gap-2 whitespace-nowrap rounded-lg border border-neutral-600/70 bg-neutral-800 px-3 text-sm text-neutral-200 shadow-xl shadow-black/30 transition-colors hover:border-neutral-500 hover:bg-neutral-700 hover:text-neutral-100" + disabled={isUploading} + className="flex h-10 items-center gap-2 whitespace-nowrap rounded-lg border border-neutral-600/70 bg-neutral-800 px-3 text-sm text-neutral-200 shadow-xl shadow-black/30 transition-colors hover:border-neutral-500 hover:bg-neutral-700 hover:text-neutral-100 disabled:cursor-wait disabled:opacity-60" > {t("imageInput.localUpload")} @@ -123,10 +133,17 @@ function SmartAudioEmptyView({ data: SmartAudioNodeData; selected?: boolean; }) { + const isUploading = data.uploadStatus === "loading"; + return (
- + + {isUploading && ( +
+ +
+ )}
); } diff --git a/src/components/nodes/SmartImageNode.tsx b/src/components/nodes/SmartImageNode.tsx index 9d792fbb..936bc24f 100644 --- a/src/components/nodes/SmartImageNode.tsx +++ b/src/components/nodes/SmartImageNode.tsx @@ -2,6 +2,7 @@ import { useCallback, useRef, useState } from "react"; import { Handle, Node, NodeProps, Position } from "@xyflow/react"; +import { Spin } from "antd"; import { AssetPickerModal, PopiAssetItem } from "@/components/AssetPickerModal"; import { useI18n } from "@/i18n"; import { useWorkflowStore } from "@/store/workflowStore"; @@ -61,9 +62,11 @@ function SmartImageToolbarIcon({ kind }: { kind: "upload" | "assets" }) { function SmartImageUploadToolbar({ id, selected, + isUploading, }: { id: string; selected?: boolean; + isUploading: boolean; }) { const { t } = useI18n(); const updateNodeData = useWorkflowStore((state) => state.updateNodeData); @@ -81,11 +84,9 @@ function SmartImageUploadToolbar({ return; } - const objectUrl = URL.createObjectURL(file); - const image = new Image(); - image.onload = () => { - const dimensions = { width: image.width, height: image.height }; - URL.revokeObjectURL(objectUrl); + updateNodeData(id, { uploadStatus: "loading", uploadError: null }); + + const uploadImage = (dimensions: { width: number; height: number } | null) => { uploadFileToGatewayMedia(file, "image") .then((uploaded) => { updateNodeData(id, { @@ -95,32 +96,29 @@ function SmartImageUploadToolbar({ previewImage: undefined, assetDetailLoading: false, assetDetailError: null, + uploadStatus: "idle", + uploadError: null, filename: uploaded.filename, dimensions, }); }) .catch((error) => { - alert(error instanceof Error ? error.message : "Media upload failed"); + const message = error instanceof Error ? error.message : "Media upload failed"; + updateNodeData(id, { uploadStatus: "error", uploadError: message }); + alert(message); }); }; + + const objectUrl = URL.createObjectURL(file); + const image = new Image(); + image.onload = () => { + const dimensions = { width: image.width, height: image.height }; + URL.revokeObjectURL(objectUrl); + uploadImage(dimensions); + }; image.onerror = () => { URL.revokeObjectURL(objectUrl); - uploadFileToGatewayMedia(file, "image") - .then((uploaded) => { - updateNodeData(id, { - image: uploaded.url, - imageRef: undefined, - assetId: undefined, - previewImage: undefined, - assetDetailLoading: false, - assetDetailError: null, - filename: uploaded.filename, - dimensions: null, - }); - }) - .catch((error) => { - alert(error instanceof Error ? error.message : "Media upload failed"); - }); + uploadImage(null); }; image.src = objectUrl; }, @@ -128,9 +126,10 @@ function SmartImageUploadToolbar({ ); const handleOpenFileDialog = useCallback(() => { + if (isUploading) return; selectSingleNode(id); fileInputRef.current?.click(); - }, [id, selectSingleNode]); + }, [id, isUploading, selectSingleNode]); const handleSelectAsset = useCallback((asset: PopiAssetItem) => { const preview = getPopiImageAssetPreview(asset); @@ -213,7 +212,8 @@ function SmartImageUploadToolbar({ handleOpenFileDialog(); }} aria-label={t("imageInput.localUpload")} - className="flex h-10 items-center gap-2 whitespace-nowrap rounded-lg border border-neutral-600/70 bg-neutral-800 px-3 text-sm text-neutral-200 shadow-xl shadow-black/30 transition-colors hover:border-neutral-500 hover:bg-neutral-700 hover:text-neutral-100" + disabled={isUploading} + className="flex h-10 items-center gap-2 whitespace-nowrap rounded-lg border border-neutral-600/70 bg-neutral-800 px-3 text-sm text-neutral-200 shadow-xl shadow-black/30 transition-colors hover:border-neutral-500 hover:bg-neutral-700 hover:text-neutral-100 disabled:cursor-wait disabled:opacity-60" > {t("imageInput.localUpload")} @@ -223,11 +223,13 @@ function SmartImageUploadToolbar({ onClick={(event) => { event.preventDefault(); event.stopPropagation(); + if (isUploading) return; selectSingleNode(id); setIsAssetPickerOpen(true); }} aria-label={t("imageInput.fromAssets")} - className="flex h-10 items-center gap-2 whitespace-nowrap rounded-lg border border-neutral-600/70 bg-neutral-800 px-3 text-sm text-neutral-200 shadow-xl shadow-black/30 transition-colors hover:border-neutral-500 hover:bg-neutral-700 hover:text-neutral-100" + disabled={isUploading} + className="flex h-10 items-center gap-2 whitespace-nowrap rounded-lg border border-neutral-600/70 bg-neutral-800 px-3 text-sm text-neutral-200 shadow-xl shadow-black/30 transition-colors hover:border-neutral-500 hover:bg-neutral-700 hover:text-neutral-100 disabled:cursor-wait disabled:opacity-60" > {t("imageInput.fromAssets")} @@ -255,10 +257,17 @@ function SmartImageNeutralView({ data: SmartImageNodeData; selected?: boolean; }) { + const isUploading = data.uploadStatus === "loading"; + return (
- + + {isUploading && ( +
+ +
+ )}
); } diff --git a/src/components/nodes/SmartVideoNode.tsx b/src/components/nodes/SmartVideoNode.tsx index b4828ce0..aee01181 100644 --- a/src/components/nodes/SmartVideoNode.tsx +++ b/src/components/nodes/SmartVideoNode.tsx @@ -2,6 +2,7 @@ import { useCallback, useRef, useState } from "react"; import { Handle, Node, NodeProps, Position } from "@xyflow/react"; +import { Spin } from "antd"; import { AssetPickerModal, PopiAssetItem } from "@/components/AssetPickerModal"; import { useI18n } from "@/i18n"; import { useWorkflowStore } from "@/store/workflowStore"; @@ -65,9 +66,11 @@ function SmartVideoToolbarIcon({ kind }: { kind: "upload" | "assets" }) { function SmartVideoUploadToolbar({ id, selected, + isUploading, }: { id: string; selected?: boolean; + isUploading: boolean; }) { const { t } = useI18n(); const updateNodeData = useWorkflowStore((state) => state.updateNodeData); @@ -108,6 +111,8 @@ function SmartVideoUploadToolbar({ return; } + updateNodeData(id, { uploadStatus: "loading", uploadError: null }); + const metadataUrl = URL.createObjectURL(file); const video = document.createElement("video"); video.preload = "metadata"; @@ -119,6 +124,7 @@ function SmartVideoUploadToolbar({ URL.revokeObjectURL(metadataUrl); uploadFileToGatewayMedia(file, "video") .then((uploaded) => { + updateNodeData(id, { uploadStatus: "idle", uploadError: null }); applyVideo( uploaded.url, uploaded.filename, @@ -128,7 +134,9 @@ function SmartVideoUploadToolbar({ ); }) .catch((error) => { - alert(error instanceof Error ? error.message : "Media upload failed"); + const message = error instanceof Error ? error.message : "Media upload failed"; + updateNodeData(id, { uploadStatus: "error", uploadError: message }); + alert(message); }); }; video.onloadedmetadata = () => { @@ -144,7 +152,7 @@ function SmartVideoUploadToolbar({ }; video.src = metadataUrl; }, - [applyVideo, t] + [applyVideo, id, t, updateNodeData] ); const handleSelectAsset = useCallback((asset: PopiAssetItem) => { @@ -224,9 +232,10 @@ function SmartVideoUploadToolbar({ }, [id, updateNodeData]); const handleOpenFileDialog = useCallback(() => { + if (isUploading) return; selectSingleNode(id); fileInputRef.current?.click(); - }, [id, selectSingleNode]); + }, [id, isUploading, selectSingleNode]); return ( <> @@ -248,7 +257,8 @@ function SmartVideoUploadToolbar({ handleOpenFileDialog(); }} aria-label={t("imageInput.localUpload")} - className="flex h-10 items-center gap-2 whitespace-nowrap rounded-lg border border-neutral-600/70 bg-neutral-800 px-3 text-sm text-neutral-200 shadow-xl shadow-black/30 transition-colors hover:border-neutral-500 hover:bg-neutral-700 hover:text-neutral-100" + disabled={isUploading} + className="flex h-10 items-center gap-2 whitespace-nowrap rounded-lg border border-neutral-600/70 bg-neutral-800 px-3 text-sm text-neutral-200 shadow-xl shadow-black/30 transition-colors hover:border-neutral-500 hover:bg-neutral-700 hover:text-neutral-100 disabled:cursor-wait disabled:opacity-60" > {t("imageInput.localUpload")} @@ -258,11 +268,13 @@ function SmartVideoUploadToolbar({ onClick={(event) => { event.preventDefault(); event.stopPropagation(); + if (isUploading) return; selectSingleNode(id); setIsAssetPickerOpen(true); }} aria-label={t("imageInput.fromAssets")} - className="flex h-10 items-center gap-2 whitespace-nowrap rounded-lg border border-neutral-600/70 bg-neutral-800 px-3 text-sm text-neutral-200 shadow-xl shadow-black/30 transition-colors hover:border-neutral-500 hover:bg-neutral-700 hover:text-neutral-100" + disabled={isUploading} + className="flex h-10 items-center gap-2 whitespace-nowrap rounded-lg border border-neutral-600/70 bg-neutral-800 px-3 text-sm text-neutral-200 shadow-xl shadow-black/30 transition-colors hover:border-neutral-500 hover:bg-neutral-700 hover:text-neutral-100 disabled:cursor-wait disabled:opacity-60" > {t("imageInput.fromAssets")} @@ -290,10 +302,17 @@ function SmartVideoNeutralView({ data: SmartVideoNodeData; selected?: boolean; }) { + const isUploading = data.uploadStatus === "loading"; + return (
- + + {isUploading && ( +
+ +
+ )}
); } diff --git a/src/components/nodes/VideoInputNode.tsx b/src/components/nodes/VideoInputNode.tsx index 620d5b51..c2e92df7 100644 --- a/src/components/nodes/VideoInputNode.tsx +++ b/src/components/nodes/VideoInputNode.tsx @@ -2,6 +2,7 @@ import { useCallback, useEffect, useRef, useState } from "react"; import { Handle, Position, NodeProps, Node, useReactFlow } from "@xyflow/react"; +import { Spin } from "antd"; import { BaseNode } from "./BaseNode"; import { useWorkflowStore } from "@/store/workflowStore"; import { VideoInputNodeData } from "@/types"; @@ -73,6 +74,7 @@ export function VideoInputNodeView({ id, data, selected }: VideoInputNodeViewPro const prevFitKeyRef = useRef(null); const [isPreviewOpen, setIsPreviewOpen] = useState(false); const [isAssetPickerOpen, setIsAssetPickerOpen] = useState(false); + const isUploading = nodeData.uploadStatus === "loading"; // Use blob URL for efficient playback of large base64 videos const playbackUrl = useVideoBlobUrl(nodeData.video ?? null); @@ -133,6 +135,8 @@ export function VideoInputNodeView({ id, data, selected }: VideoInputNodeViewPro return; } + updateNodeData(id, { uploadStatus: "loading", uploadError: null }); + // Extract metadata using a temporary video element pointing at the original file const metadataUrl = URL.createObjectURL(file); const video = document.createElement("video"); @@ -152,6 +156,8 @@ export function VideoInputNodeView({ id, data, selected }: VideoInputNodeViewPro previewVideoPoster: undefined, assetDetailLoading: false, assetDetailError: null, + uploadStatus: "idle", + uploadError: null, filename: uploaded.filename, format: uploaded.contentType, duration: metadata.duration, @@ -159,7 +165,9 @@ export function VideoInputNodeView({ id, data, selected }: VideoInputNodeViewPro }); }) .catch((error) => { - alert(error instanceof Error ? error.message : "Media upload failed"); + const message = error instanceof Error ? error.message : "Media upload failed"; + updateNodeData(id, { uploadStatus: "error", uploadError: message }); + alert(message); }); }; @@ -204,8 +212,9 @@ export function VideoInputNodeView({ id, data, selected }: VideoInputNodeViewPro }, []); const handleOpenFileDialog = useCallback(() => { + if (isUploading) return; fileInputRef.current?.click(); - }, []); + }, [isUploading]); const handleRemove = useCallback(() => { updateNodeData(id, { @@ -457,6 +466,12 @@ export function VideoInputNodeView({ id, data, selected }: VideoInputNodeViewPro )} + {isUploading && ( +
+ +
+ )} + { expect(node.data.outputVideoStorageStatus).toBe("remote-only"); }); + it("should normalize legacy indexed handles for image and video single-input nodes when loading workflow", async () => { + const store = useWorkflowStore.getState(); + await store.loadWorkflow({ + version: 1, + id: "test-workflow", + name: "Legacy Handles", + nodes: [ + createTestNode("prompt-1", "prompt", { prompt: "prompt" }), + createTestNode("image-1", "smartImage", { inputPrompt: "prompt" }), + createTestNode("video-input-1", "videoInput", { video: "https://example.com/input.mp4" }), + createTestNode("video-1", "smartVideo", {}), + ], + edges: [ + createTestEdge("prompt-1", "image-1", "text", "text-0"), + createTestEdge("video-input-1", "video-1", "video", "video-0"), + ], + edgeStyle: "angular", + }); + + expect(useWorkflowStore.getState().edges).toEqual([ + expect.objectContaining({ + id: "edge-prompt-1-image-1-text-text", + targetHandle: "text", + }), + expect.objectContaining({ + id: "edge-video-input-1-video-1-video-video", + targetHandle: "video", + }), + ]); + }); + it("should hydrate model detail and pricing when loading a workflow", async () => { const originalFetch = global.fetch; const mockFetch = vi.fn().mockResolvedValue({ diff --git a/src/store/utils/__tests__/connectedInputs.test.ts b/src/store/utils/__tests__/connectedInputs.test.ts index 9ffd6736..0801a78a 100644 --- a/src/store/utils/__tests__/connectedInputs.test.ts +++ b/src/store/utils/__tests__/connectedInputs.test.ts @@ -439,4 +439,10 @@ describe("validateWorkflowPure", () => { const result = validateWorkflowPure(nodes, edges); expect(result.valid).toBe(true); }); + + it("should accept saved local prompt on nanoBanana without a text edge", () => { + const nodes = [makeNode("gen", "nanoBanana", { inputPrompt: "remote saved prompt" })]; + const result = validateWorkflowPure(nodes, []); + expect(result.valid).toBe(true); + }); }); diff --git a/src/store/utils/connectedInputs.ts b/src/store/utils/connectedInputs.ts index 07642a40..bcbe6d4c 100644 --- a/src/store/utils/connectedInputs.ts +++ b/src/store/utils/connectedInputs.ts @@ -480,9 +480,8 @@ export function validateWorkflowPure( !e.data?.isLoop && (e.targetHandle === "text" || e.targetHandle?.startsWith("text-")) ); - const hasLocalPrompt = - node.type === "smartImage" && - Boolean((node.data as Partial).inputPrompt?.trim()); + const data = node.data as Partial; + const hasLocalPrompt = Boolean(data.inputPrompt?.trim()); if (!textConnected && !hasLocalPrompt) { errors.push(`Generate node "${node.id}" missing text input`); } diff --git a/src/store/utils/nodeDefaults.ts b/src/store/utils/nodeDefaults.ts index 77511998..15bc075a 100644 --- a/src/store/utils/nodeDefaults.ts +++ b/src/store/utils/nodeDefaults.ts @@ -79,6 +79,8 @@ export const createDefaultNodeData = (type: NodeType): WorkflowNodeData => { case "imageInput": return { image: null, + uploadStatus: "idle", + uploadError: null, filename: null, dimensions: null, } as ImageInputNodeData; @@ -102,6 +104,8 @@ export const createDefaultNodeData = (type: NodeType): WorkflowNodeData => { return { image: null, + uploadStatus: "idle", + uploadError: null, filename: null, dimensions: null, inputImages: [], @@ -123,6 +127,8 @@ export const createDefaultNodeData = (type: NodeType): WorkflowNodeData => { case "audioInput": return { audioFile: null, + uploadStatus: "idle", + uploadError: null, filename: null, duration: null, format: null, @@ -131,6 +137,8 @@ export const createDefaultNodeData = (type: NodeType): WorkflowNodeData => { const nodeDefaults = loadNodeDefaults(); return { audioFile: null, + uploadStatus: "idle", + uploadError: null, filename: null, duration: null, format: null, @@ -148,6 +156,8 @@ export const createDefaultNodeData = (type: NodeType): WorkflowNodeData => { case "videoInput": return { video: null, + uploadStatus: "idle", + uploadError: null, filename: null, duration: null, dimensions: null, @@ -162,6 +172,8 @@ export const createDefaultNodeData = (type: NodeType): WorkflowNodeData => { ); return { video: null, + uploadStatus: "idle", + uploadError: null, filename: null, duration: null, dimensions: null, diff --git a/src/store/workflowStore.ts b/src/store/workflowStore.ts index cb7169df..6867df0e 100644 --- a/src/store/workflowStore.ts +++ b/src/store/workflowStore.ts @@ -2593,17 +2593,22 @@ const workflowStoreImpl: StateCreator = (set, get) => ({ return node; }) as WorkflowNode[]; - // Migrate legacy indexed handle IDs on edges targeting nanoBanana nodes. - // GenerateImageNode always renders "image"/"text" handles (not "image-0"/"text-0"), - // so edges saved with the old indexed format cause React Flow error #008. - const nanoBananaNodeIds = new Set( - workflow.nodes.filter((n) => n.type === "nanoBanana").map((n) => n.id) + const singleInputHandleNodeIds = new Set( + workflow.nodes + .filter((n) => ( + n.type === "nanoBanana" || + n.type === "smartImage" || + n.type === "smartVideo" || + n.type === "videoTrim" || + n.type === "videoFrameGrab" + )) + .map((n) => n.id) ); workflow.edges = workflow.edges.map((edge) => { - if (!nanoBananaNodeIds.has(edge.target)) return edge; + if (!singleInputHandleNodeIds.has(edge.target)) return edge; const th = edge.targetHandle; - if (th === "image-0" || th === "text-0") { - const baseHandle = th === "image-0" ? "image" : "text"; + if (th === "image-0" || th === "text-0" || th === "video-0") { + const baseHandle = th.slice(0, th.lastIndexOf("-")); return { ...edge, targetHandle: baseHandle, diff --git a/src/types/nodes.ts b/src/types/nodes.ts index e52988d0..507b3c0f 100644 --- a/src/types/nodes.ts +++ b/src/types/nodes.ts @@ -69,6 +69,8 @@ export interface ImageInputNodeData extends BaseNodeData { previewImage?: string; assetDetailLoading?: boolean; assetDetailError?: string | null; + uploadStatus?: "idle" | "loading" | "error"; + uploadError?: string | null; filename: string | null; dimensions: { width: number; height: number } | null; isOptional?: boolean; @@ -86,6 +88,8 @@ export interface SmartImageNodeData extends ImageInputNodeData, NanoBananaNodeDa export interface AudioInputNodeData extends BaseNodeData { audioFile: string | null; // Base64 data URL of the audio file audioFileRef?: string; // External audio reference for storage optimization + uploadStatus?: "idle" | "loading" | "error"; + uploadError?: string | null; filename: string | null; // Original filename for display duration: number | null; // Duration in seconds format: string | null; // MIME type (audio/mp3, audio/wav, etc.) @@ -108,6 +112,8 @@ export interface VideoInputNodeData extends BaseNodeData { previewVideoPoster?: string; assetDetailLoading?: boolean; assetDetailError?: string | null; + uploadStatus?: "idle" | "loading" | "error"; + uploadError?: string | null; filename: string | null; duration: number | null; // Duration in seconds dimensions: { width: number; height: number } | null; diff --git a/src/utils/__tests__/generationConfig.test.ts b/src/utils/__tests__/generationConfig.test.ts index 1e3044b0..c0e87767 100644 --- a/src/utils/__tests__/generationConfig.test.ts +++ b/src/utils/__tests__/generationConfig.test.ts @@ -117,6 +117,7 @@ describe("generationConfig", () => { parameters: { resolution: "720p", duration: 4, + videoRatio: 20, }, }, "video", @@ -131,6 +132,7 @@ describe("generationConfig", () => { ratio: "9:16", resolution: "1080p", duration: 8, + videoRatio: 20, width: 720, height: 1280, batchSize: 1, diff --git a/src/utils/generationConfig.ts b/src/utils/generationConfig.ts index 3592dd09..5682f01e 100644 --- a/src/utils/generationConfig.ts +++ b/src/utils/generationConfig.ts @@ -124,6 +124,7 @@ export type PersistedGenerationNodeConfig = Partial; export type GenerationCapability = "all" | "image" | "video" | "3d" | "audio"; export type PopiserverTaskPricePayload = { + [key: string]: unknown; type: number; subType: number; aiModelId: number; @@ -557,8 +558,10 @@ export function buildPopiserverTaskPricePayload( ? normalizeVideoDurationSeconds(config.durationSeconds) : finiteIntegerFromUnknown(config.parameters?.duration); const modelName = getPopiserverModelName(model); + const dynamicParameters = removeFirstClassGenerationParameters(config.parameters); return { + ...dynamicParameters, type, subType, aiModelId,