Browse Source

上传增加loading

feature/interaction
TianYun 1 month ago
parent
commit
35d4961d35
  1. 245
      src/components/FloatingActionBar.tsx
  2. 25
      src/components/composer/GenerationComposer.tsx
  3. 20
      src/components/nodes/AudioInputNode.tsx
  4. 50
      src/components/nodes/ImageInputNode.tsx
  5. 27
      src/components/nodes/SmartAudioNode.tsx
  6. 61
      src/components/nodes/SmartImageNode.tsx
  7. 31
      src/components/nodes/SmartVideoNode.tsx
  8. 19
      src/components/nodes/VideoInputNode.tsx
  9. 31
      src/store/__tests__/workflowStore.integration.test.ts
  10. 6
      src/store/utils/__tests__/connectedInputs.test.ts
  11. 5
      src/store/utils/connectedInputs.ts
  12. 12
      src/store/utils/nodeDefaults.ts
  13. 21
      src/store/workflowStore.ts
  14. 6
      src/types/nodes.ts
  15. 2
      src/utils/__tests__/generationConfig.test.ts
  16. 3
      src/utils/generationConfig.ts

245
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<TutorialStep[]>([]);
const [runMenuOpen, setRunMenuOpen] = useState(false);
const runMenuRef = useRef<HTMLDivElement>(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 (
<div className="fixed left-5 top-1/2 z-50 -translate-y-1/2">
<div className="flex flex-col items-center gap-2 bg-neutral-800/95 rounded-2xl shadow-lg border border-neutral-700/80 px-1.5 py-1.5">
@ -239,6 +355,133 @@ export function FloatingActionBar() {
</svg>
)}
</button>
<div className="relative flex items-center" ref={runMenuRef}>
<button
onClick={handleRunClick}
disabled={!valid && !isRunning}
title={!valid ? errors.join("\n") : isRunning ? t("toolbar.stop") : t("toolbar.run")}
data-tutorial="floating-run-button"
className={`flex h-8 w-8 items-center justify-center rounded-lg transition-colors ${
isRunning
? "bg-neutral-100 text-neutral-950 hover:bg-neutral-200"
: valid
? "bg-neutral-100 text-neutral-950 hover:bg-neutral-200"
: "cursor-not-allowed bg-neutral-700 text-neutral-500"
}`}
>
{isRunning ? (
<>
<svg className="h-4 w-4 animate-spin" fill="none" viewBox="0 0 24 24">
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="3" />
<path
className="opacity-75"
fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
/>
</svg>
<span className="sr-only" title={getRunningLabel()}>
{runningNodeCount > 1 ? t("toolbar.nodesCount", { count: runningNodeCount }) : t("toolbar.stop")}
</span>
</>
) : (
<>
<svg className="h-4 w-4" fill="currentColor" viewBox="0 0 24 24">
<path d="M8 5v14l11-7z" />
</svg>
<span className="sr-only">{t("toolbar.run")}</span>
</>
)}
</button>
{!isRunning && valid && (
<button
onClick={() => setRunMenuOpen(!runMenuOpen)}
data-tutorial="floating-run-dropdown"
className="absolute -bottom-1 -right-1 flex h-4 w-4 items-center justify-center rounded-full border border-neutral-600 bg-neutral-700 text-neutral-200 transition-colors hover:bg-neutral-600"
title={t("toolbar.runOptions")}
aria-label={t("toolbar.runOptions")}
>
<svg
className={`h-2.5 w-2.5 transition-transform ${runMenuOpen ? "rotate-180" : ""}`}
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={2.5}
>
<path strokeLinecap="round" strokeLinejoin="round" d="M19 9l-7 7-7-7" />
</svg>
</button>
)}
{runMenuOpen && !isRunning && (
<div
data-tutorial="floating-run-menu"
className="absolute bottom-0 left-full ml-2 min-w-[180px] overflow-hidden rounded-lg border border-neutral-700 bg-neutral-800 shadow-xl"
>
<button
onClick={() => {
void executeWorkflow();
setRunMenuOpen(false);
}}
className="flex w-full items-center gap-2 px-3 py-2 text-left text-[11px] font-medium text-neutral-300 transition-colors hover:bg-neutral-700 hover:text-neutral-100"
>
<svg className="h-3.5 w-3.5" fill="currentColor" viewBox="0 0 24 24">
<path d="M8 5v14l11-7z" />
</svg>
{t("toolbar.runEntire")}
</button>
<button
onClick={handleRunFromSelected}
disabled={!selectedNode}
title={!selectedNode ? t("toolbar.selectSingleNode") : undefined}
className={`flex w-full items-center gap-2 px-3 py-2 text-left text-[11px] font-medium transition-colors ${
selectedNode
? "text-neutral-300 hover:bg-neutral-700 hover:text-neutral-100"
: "cursor-not-allowed text-neutral-500"
}`}
>
<svg className="h-3.5 w-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M13 5l7 7-7 7M5 5l7 7-7 7" />
</svg>
{t("toolbar.runFromSelected")}
</button>
<button
onClick={handleRunSelectedOnly}
disabled={!selectedNode}
title={!selectedNode ? t("toolbar.selectSingleNode") : undefined}
className={`flex w-full items-center gap-2 px-3 py-2 text-left text-[11px] font-medium transition-colors ${
selectedNode
? "text-neutral-300 hover:bg-neutral-700 hover:text-neutral-100"
: "cursor-not-allowed text-neutral-500"
}`}
>
<svg className="h-3.5 w-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M5.25 5.653c0-.856.917-1.398 1.667-.986l11.54 6.347a1.125 1.125 0 010 1.972l-11.54 6.347a1.125 1.125 0 01-1.667-.986V5.653z" />
</svg>
{t("toolbar.runSelectedOnly")}
</button>
<button
onClick={handleRunSelectedNodes}
disabled={selectedNodes.length === 0}
title={selectedNodes.length === 0 ? t("toolbar.selectOneOrMoreNodes") : t("toolbar.runSelectedNodesCount", { count: selectedNodes.length })}
className={`flex w-full items-center gap-2 px-3 py-2 text-left text-[11px] font-medium transition-colors ${
selectedNodes.length > 0
? "text-neutral-300 hover:bg-neutral-700 hover:text-neutral-100"
: "cursor-not-allowed text-neutral-500"
}`}
>
<svg className="h-3.5 w-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M5.25 5.653c0-.856.917-1.398 1.667-.986l11.54 6.347a1.125 1.125 0 010 1.972l-11.54 6.347a1.125 1.125 0 01-1.667-.986V5.653z" />
<path strokeLinecap="round" strokeLinejoin="round" d="M9.75 9.653c0-.856.917-1.398 1.667-.986l11.54 6.347a1.125 1.125 0 010 1.972l-11.54 6.347a1.125 1.125 0 01-1.667-.986V9.653z" />
</svg>
{selectedNodes.length > 0
? t("toolbar.runSelectedNodesCount", { count: selectedNodes.length })
: t("toolbar.runSelectedNodes")}
</button>
</div>
)}
</div>
</div>
<ModelSearchDialog

25
src/components/composer/GenerationComposer.tsx

@ -211,12 +211,9 @@ function hasPersistedSchemaParameterValue(
if (Object.prototype.hasOwnProperty.call(data.config?.parameters ?? {}, name)) return true;
const field = getDraftFieldForSchemaParameter(name);
return Boolean(
field &&
Object.prototype.hasOwnProperty.call(data, field) &&
data.config &&
Object.prototype.hasOwnProperty.call(data.config, field)
);
if (!field) return false;
if (Object.prototype.hasOwnProperty.call(data, field)) return true;
return Boolean(data.config && Object.prototype.hasOwnProperty.call(data.config, field));
}
function isVideoGenerationSubType(value: unknown): value is VideoGenerationSubType {
@ -1955,12 +1952,22 @@ export function GenerationComposer() {
const freshNode = useWorkflowStore.getState().nodes.find((node) => 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<DraftField>(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<DraftField>(["selectedModel", "resolution", "parameters"])
modelDirtyFields
);
updateNodeData(activeContext.node.id, modelPatch);
setDraft((current) => {
@ -2014,7 +2021,7 @@ export function GenerationComposer() {
});
}
},
[clearDirtyFields, updateNodeData]
[clearDirtyFields, flushCurrentDraft, updateNodeData]
);
useEffect(() => {

20
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<HTMLInputElement>(null);
const isUploading = nodeData.uploadStatus === "loading";
const [audioBlob, setAudioBlob] = useState<Blob | null>(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({
</div>
)}
{isUploading && (
<div className="nodrag nopan absolute inset-0 z-[10003] flex items-center justify-center rounded-lg bg-black/45 backdrop-blur-[1px]">
<Spin />
</div>
)}
<Handle
type="source"
position={Position.Right}

50
src/components/nodes/ImageInputNode.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 { ImageInputNodeData } from "@/types";
@ -95,6 +96,7 @@ export function ImageInputNodeView({ id, data, selected }: ImageInputNodeViewPro
const prevImageRef = useRef<string | null>(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
</div>
)}
{isUploading && (
<div className="nodrag nopan absolute inset-0 z-[10003] flex items-center justify-center rounded-lg bg-black/45 backdrop-blur-[1px]">
<Spin />
</div>
)}
{/* Handles rendered after visual content so they paint on top */}
<Handle
type="source"

27
src/components/nodes/SmartAudioNode.tsx

@ -3,6 +3,7 @@
import { useCallback, useRef } from "react";
import { UploadOutlined } from "@ant-design/icons";
import { Node, NodeProps } from "@xyflow/react";
import { Spin } from "antd";
import { useI18n } from "@/i18n";
import { useWorkflowStore } from "@/store/workflowStore";
import { SmartAudioNodeData } from "@/types";
@ -17,9 +18,11 @@ const MAX_FILE_SIZE = 50 * 1024 * 1024;
function SmartAudioUploadToolbar({
id,
selected,
isUploading,
}: {
id: string;
selected?: boolean;
isUploading: boolean;
}) {
const { t } = useI18n();
const updateNodeData = useWorkflowStore((state) => 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"
>
<UploadOutlined className="text-base" />
<span>{t("imageInput.localUpload")}</span>
@ -123,10 +133,17 @@ function SmartAudioEmptyView({
data: SmartAudioNodeData;
selected?: boolean;
}) {
const isUploading = data.uploadStatus === "loading";
return (
<div className="relative h-full w-full">
<GenerateAudioNodeView id={id} data={data} selected={selected} />
<SmartAudioUploadToolbar id={id} selected={selected} />
<SmartAudioUploadToolbar id={id} selected={selected} isUploading={isUploading} />
{isUploading && (
<div className="nodrag nopan absolute inset-0 z-[10003] flex items-center justify-center rounded-lg bg-black/45 backdrop-blur-[1px]">
<Spin />
</div>
)}
</div>
);
}

61
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"
>
<SmartImageToolbarIcon kind="upload" />
<span>{t("imageInput.localUpload")}</span>
@ -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"
>
<SmartImageToolbarIcon kind="assets" />
<span>{t("imageInput.fromAssets")}</span>
@ -255,10 +257,17 @@ function SmartImageNeutralView({
data: SmartImageNodeData;
selected?: boolean;
}) {
const isUploading = data.uploadStatus === "loading";
return (
<div className="relative h-full w-full">
<GenerateImageNodeView id={id} data={data} selected={selected} />
<SmartImageUploadToolbar id={id} selected={selected} />
<SmartImageUploadToolbar id={id} selected={selected} isUploading={isUploading} />
{isUploading && (
<div className="nodrag nopan absolute inset-0 z-[10003] flex items-center justify-center rounded-lg bg-black/45 backdrop-blur-[1px]">
<Spin />
</div>
)}
</div>
);
}

31
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"
>
<SmartVideoToolbarIcon kind="upload" />
<span>{t("imageInput.localUpload")}</span>
@ -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"
>
<SmartVideoToolbarIcon kind="assets" />
<span>{t("imageInput.fromAssets")}</span>
@ -290,10 +302,17 @@ function SmartVideoNeutralView({
data: SmartVideoNodeData;
selected?: boolean;
}) {
const isUploading = data.uploadStatus === "loading";
return (
<div className="relative h-full w-full">
<GenerateVideoNodeView id={id} data={data} selected={selected} />
<SmartVideoUploadToolbar id={id} selected={selected} />
<SmartVideoUploadToolbar id={id} selected={selected} isUploading={isUploading} />
{isUploading && (
<div className="nodrag nopan absolute inset-0 z-[10003] flex items-center justify-center rounded-lg bg-black/45 backdrop-blur-[1px]">
<Spin />
</div>
)}
</div>
);
}

19
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<string | null>(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
</div>
)}
{isUploading && (
<div className="nodrag nopan absolute inset-0 z-[10003] flex items-center justify-center rounded-lg bg-black/45 backdrop-blur-[1px]">
<Spin />
</div>
)}
<Handle
type="source"
position={Position.Right}

31
src/store/__tests__/workflowStore.integration.test.ts

@ -2392,6 +2392,37 @@ describe("workflowStore integration tests", () => {
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({

6
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);
});
});

5
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<SmartImageNodeData>).inputPrompt?.trim());
const data = node.data as Partial<NanoBananaNodeData & SmartImageNodeData>;
const hasLocalPrompt = Boolean(data.inputPrompt?.trim());
if (!textConnected && !hasLocalPrompt) {
errors.push(`Generate node "${node.id}" missing text input`);
}

12
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,

21
src/store/workflowStore.ts

@ -2593,17 +2593,22 @@ const workflowStoreImpl: StateCreator<WorkflowStore> = (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,

6
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;

2
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,

3
src/utils/generationConfig.ts

@ -124,6 +124,7 @@ export type PersistedGenerationNodeConfig = Partial<GenerationNodeConfig>;
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,

Loading…
Cancel
Save