diff --git a/src/components/__tests__/GenerateAudioNode.test.tsx b/src/components/__tests__/GenerateAudioNode.test.tsx index 63f89a17..f2c30a82 100644 --- a/src/components/__tests__/GenerateAudioNode.test.tsx +++ b/src/components/__tests__/GenerateAudioNode.test.tsx @@ -1,5 +1,5 @@ import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; -import { render, screen, fireEvent } from "@testing-library/react"; +import { render, screen, fireEvent, waitFor } from "@testing-library/react"; import { GenerateAudioNode } from "@/components/nodes/GenerateAudioNode"; import { ReactFlowProvider } from "@xyflow/react"; import { GenerateAudioNodeData, ProviderSettings } from "@/types"; @@ -151,6 +151,7 @@ describe("GenerateAudioNode", () => { generationsPath: "/test/generations", isRunning: false, currentNodeIds: [], + runningNodeIds: new Set(), groups: {}, nodes: [], recentModels: [], @@ -290,11 +291,11 @@ describe("GenerateAudioNode", () => { ); - const clearButton = screen.getByTitle("Clear audio"); + const clearButton = screen.getByTitle("清除音频"); expect(clearButton).toBeInTheDocument(); }); - it("should call updateNodeData to clear audio when clear button is clicked", () => { + it("should call updateNodeData to clear audio when clear button is clicked", async () => { render( { ); - const clearButton = screen.getByTitle("Clear audio"); + const clearButton = screen.getByTitle("清除音频"); fireEvent.click(clearButton); - expect(mockUpdateNodeData).toHaveBeenCalledWith("test-audio-1", { - outputAudio: null, - status: "idle", - error: null, - duration: null, - format: null, + await waitFor(() => { + expect(mockUpdateNodeData).toHaveBeenCalledWith("test-audio-1", expect.objectContaining({ + outputAudio: null, + audioHistory: [], + selectedAudioHistoryIndex: 0, + status: "idle", + error: null, + duration: null, + format: null, + })); + }); + }); + + it("should delete the current audio page and select the next audio", async () => { + mockFetch.mockResolvedValue({ + ok: true, + json: () => Promise.resolve({ success: true, audio: "data:audio/mp3;base64,next" }), + }); + + const firstAudio = { + id: "audio1", + audio: "data:audio/mp3;base64,current", + timestamp: Date.now(), + prompt: "test1", + model: "elevenlabs", + }; + const secondAudio = { + id: "audio2", + audio: "data:audio/mp3;base64,next", + timestamp: Date.now() + 1, + prompt: "test2", + model: "elevenlabs", + }; + + render( + + + + ); + + fireEvent.click(screen.getByTitle("清除音频")); + + await waitFor(() => { + expect(mockUpdateNodeData).toHaveBeenCalledWith("test-audio-1", expect.objectContaining({ + outputAudio: "data:audio/mp3;base64,next", + audioHistory: [secondAudio], + selectedAudioHistoryIndex: 0, + status: "idle", + error: null, + duration: null, + format: null, + })); }); }); }); diff --git a/src/components/__tests__/GenerateImageNode.test.tsx b/src/components/__tests__/GenerateImageNode.test.tsx index 5e590d9c..b1eba815 100644 --- a/src/components/__tests__/GenerateImageNode.test.tsx +++ b/src/components/__tests__/GenerateImageNode.test.tsx @@ -159,6 +159,7 @@ describe("GenerateImageNode", () => { saveDirectoryPath: null, isRunning: false, currentNodeIds: [], + runningNodeIds: new Set(), groups: {}, nodes: [], recentModels: [], @@ -429,7 +430,7 @@ describe("GenerateImageNode", () => { expect(screen.getByAltText("Generated preview")).toHaveAttribute("src", "data:image/png;base64,abc123"); }); - it("should call updateNodeData to clear image when clear button is clicked", () => { + it("should call updateNodeData to clear image when clear button is clicked", async () => { render( { const clearButton = screen.getByTitle("Clear image"); fireEvent.click(clearButton); - expect(mockUpdateNodeData).toHaveBeenCalledWith("test-node-1", { - outputImage: null, - status: "idle", - error: null, + await waitFor(() => { + expect(mockUpdateNodeData).toHaveBeenCalledWith("test-node-1", expect.objectContaining({ + outputImage: null, + imageHistory: [], + selectedHistoryIndex: 0, + status: "idle", + error: null, + })); + }); + }); + + it("should delete the current image page and select the next image", async () => { + const firstImage = { + id: "img1", + image: "data:image/png;base64,current", + timestamp: Date.now(), + prompt: "test1", + aspectRatio: "1:1" as const, + model: "nano-banana", + }; + const secondImage = { + id: "img2", + image: "data:image/png;base64,next", + previewImg: "data:image/png;base64,next-preview", + timestamp: Date.now() + 1, + prompt: "test2", + aspectRatio: "1:1" as const, + model: "nano-banana", + }; + + render( + + + + ); + + fireEvent.click(screen.getByTitle("Clear image")); + + await waitFor(() => { + expect(mockUpdateNodeData).toHaveBeenCalledWith("test-node-1", expect.objectContaining({ + outputImage: secondImage.image, + previewImg: secondImage.previewImg, + imageHistory: [secondImage], + selectedHistoryIndex: 0, + status: "idle", + error: null, + })); }); }); }); @@ -542,6 +590,7 @@ describe("GenerateImageNode", () => { saveDirectoryPath: "browserfs://root/project", isRunning: false, currentNodeIds: [], + runningNodeIds: new Set(), groups: {}, nodes: [], recentModels: [], diff --git a/src/components/__tests__/GenerateVideoNode.test.tsx b/src/components/__tests__/GenerateVideoNode.test.tsx index 89a7f278..7d4397dd 100644 --- a/src/components/__tests__/GenerateVideoNode.test.tsx +++ b/src/components/__tests__/GenerateVideoNode.test.tsx @@ -159,6 +159,7 @@ describe("GenerateVideoNode", () => { generationsPath: "/test/generations", isRunning: false, currentNodeIds: [], + runningNodeIds: new Set(), groups: {}, nodes: [], recentModels: [], @@ -503,7 +504,7 @@ describe("GenerateVideoNode", () => { expect(container.querySelectorAll("video")).toHaveLength(2); }); - it("should call updateNodeData to clear video when clear button is clicked", () => { + it("should call updateNodeData to clear video when clear button is clicked", async () => { render( { const clearButton = screen.getByTitle("Clear video"); fireEvent.click(clearButton); - expect(mockUpdateNodeData).toHaveBeenCalledWith("test-node-1", { - outputVideo: null, - status: "idle", - error: null, + await waitFor(() => { + expect(mockUpdateNodeData).toHaveBeenCalledWith("test-node-1", expect.objectContaining({ + outputVideo: null, + videoHistory: [], + selectedVideoHistoryIndex: 0, + status: "idle", + error: null, + })); + }); + }); + + it("should delete the current video page and select the next video", async () => { + const firstVideo = { + id: "vid1", + video: "data:video/mp4;base64,current", + timestamp: Date.now(), + prompt: "test1", + model: "kling-video/v1", + }; + const secondVideo = { + id: "vid2", + video: "data:video/mp4;base64,next", + timestamp: Date.now() + 1, + prompt: "test2", + model: "kling-video/v1", + }; + + render( + + + + ); + + fireEvent.click(screen.getByTitle("Clear video")); + + await waitFor(() => { + expect(mockUpdateNodeData).toHaveBeenCalledWith("test-node-1", expect.objectContaining({ + outputVideo: secondVideo.video, + videoHistory: [secondVideo], + selectedVideoHistoryIndex: 0, + status: "idle", + error: null, + })); }); }); }); @@ -569,6 +613,7 @@ describe("GenerateVideoNode", () => { generationsPath: null, isRunning: false, currentNodeIds: [], + runningNodeIds: new Set(), groups: {}, nodes: [], recentModels: [], @@ -609,6 +654,7 @@ describe("GenerateVideoNode", () => { generationsPath: null, isRunning: false, currentNodeIds: [], + runningNodeIds: new Set(), groups: {}, nodes: [], recentModels: [], diff --git a/src/components/nodes/GenerateAudioNode.tsx b/src/components/nodes/GenerateAudioNode.tsx index 6a002980..0ca63357 100644 --- a/src/components/nodes/GenerateAudioNode.tsx +++ b/src/components/nodes/GenerateAudioNode.tsx @@ -143,15 +143,6 @@ export function GenerateAudioNodeView({ isLoadingWaveform, }); - const handleClearAudio = useCallback(() => { - if (audioRef.current) { - audioRef.current.pause(); - audioRef.current = null; - } - setAudioBlob(null); - updateNodeData(id, { outputAudio: null, status: "idle", error: null, duration: null, format: null }); - }, [id, updateNodeData, audioRef]); - const handleParametersChange = useCallback( (parameters: Record) => { updateNodeData(id, buildGenerationParametersPatch(nodeConfig, parameters)); @@ -260,6 +251,69 @@ export function GenerateAudioNodeView({ } }, [generationsPath]); + const handleClearAudio = useCallback(async () => { + if (audioRef.current) { + audioRef.current.pause(); + audioRef.current = null; + } + setAudioBlob(null); + + const history = nodeData.audioHistory || []; + if (history.length === 0) { + updateNodeData(id, { + outputAudio: null, + outputAudioRef: undefined, + audioHistory: [], + selectedAudioHistoryIndex: 0, + status: "idle", + error: null, + duration: null, + format: null, + }); + return; + } + + const currentIndex = Math.min(Math.max(nodeData.selectedAudioHistoryIndex || 0, 0), history.length - 1); + const nextHistory = history.filter((_, index) => index !== currentIndex); + if (nextHistory.length === 0) { + updateNodeData(id, { + outputAudio: null, + outputAudioRef: undefined, + audioHistory: [], + selectedAudioHistoryIndex: 0, + status: "idle", + error: null, + duration: null, + format: null, + }); + return; + } + + const nextIndex = Math.min(currentIndex, nextHistory.length - 1); + const nextItem = nextHistory[nextIndex]; + setIsLoadingCarouselAudio(true); + const nextAudio = nextItem.audio ?? await loadAudioById(nextItem.id); + setIsLoadingCarouselAudio(false); + + updateNodeData(id, { + outputAudio: nextAudio, + outputAudioRef: undefined, + audioHistory: nextHistory, + selectedAudioHistoryIndex: nextIndex, + status: "idle", + error: nextAudio ? null : "Selected audio could not be loaded", + duration: null, + format: null, + }); + }, [ + audioRef, + id, + loadAudioById, + nodeData.audioHistory, + nodeData.selectedAudioHistoryIndex, + updateNodeData, + ]); + // Carousel navigation handlers const handleCarouselPrevious = useCallback(async () => { const history = nodeData.audioHistory || []; @@ -270,7 +324,7 @@ export function GenerateAudioNodeView({ const audioItem = history[newIndex]; setIsLoadingCarouselAudio(true); - const audio = await loadAudioById(audioItem.id); + const audio = audioItem.audio ?? await loadAudioById(audioItem.id); setIsLoadingCarouselAudio(false); if (audio) { @@ -292,7 +346,7 @@ export function GenerateAudioNodeView({ const audioItem = history[newIndex]; setIsLoadingCarouselAudio(true); - const audio = await loadAudioById(audioItem.id); + const audio = audioItem.audio ?? await loadAudioById(audioItem.id); setIsLoadingCarouselAudio(false); if (audio) { diff --git a/src/components/nodes/GenerateImageNode.tsx b/src/components/nodes/GenerateImageNode.tsx index 3e4c8c5a..7e90a2c4 100644 --- a/src/components/nodes/GenerateImageNode.tsx +++ b/src/components/nodes/GenerateImageNode.tsx @@ -406,11 +406,7 @@ export function GenerateImageNodeView({ id, data, selected }: GenerateImageNodeV [id, nodeConfig, updateNodeData] ); - const { setNodes } = useReactFlow(); - const handleClearImage = useCallback(() => { - updateNodeData(id, { outputImage: null, previewImg: undefined, status: "idle", error: null }); - }, [id, updateNodeData]); const regenerateNode = useWorkflowStore((state) => state.regenerateNode); const isRunning = useWorkflowStore((state) => state.runningNodeIds.has(id)); @@ -457,6 +453,58 @@ export function GenerateImageNodeView({ id, data, selected }: GenerateImageNodeV } }, [generationsPath, saveDirectoryPath]); + const handleClearImage = useCallback(async () => { + const history = nodeData.imageHistory || []; + if (history.length === 0) { + updateNodeData(id, { + outputImage: null, + previewImg: undefined, + outputImageRef: undefined, + outputImageStorageStatus: undefined, + imageHistory: [], + selectedHistoryIndex: 0, + status: "idle", + error: null, + }); + return; + } + + const currentIndex = Math.min(Math.max(nodeData.selectedHistoryIndex || 0, 0), history.length - 1); + const nextHistory = history.filter((_, index) => index !== currentIndex); + if (nextHistory.length === 0) { + updateNodeData(id, { + outputImage: null, + previewImg: undefined, + outputImageRef: undefined, + outputImageStorageStatus: undefined, + imageHistory: [], + selectedHistoryIndex: 0, + status: "idle", + error: null, + }); + return; + } + + const nextIndex = Math.min(currentIndex, nextHistory.length - 1); + const nextItem = nextHistory[nextIndex]; + setIsLoadingCarouselImage(true); + const nextImage = nextItem.image ?? await loadImageById(nextItem.id); + setIsLoadingCarouselImage(false); + + updateNodeData(id, { + outputImage: nextImage, + previewImg: nextItem.previewImg, + outputImageRef: undefined, + outputImageStorageStatus: nextImage + ? /^https?:\/\//i.test(nextImage) ? "remote-only" : "localized" + : undefined, + imageHistory: nextHistory, + selectedHistoryIndex: nextIndex, + status: "idle", + error: nextImage ? null : "Selected image could not be loaded", + }); + }, [id, loadImageById, nodeData.imageHistory, nodeData.selectedHistoryIndex, updateNodeData]); + const handleCarouselPrevious = useCallback(async () => { const history = nodeData.imageHistory || []; if (history.length === 0 || isLoadingCarouselImage) return; diff --git a/src/components/nodes/GenerateVideoNode.tsx b/src/components/nodes/GenerateVideoNode.tsx index 86069e6c..87db388c 100644 --- a/src/components/nodes/GenerateVideoNode.tsx +++ b/src/components/nodes/GenerateVideoNode.tsx @@ -366,10 +366,6 @@ export function GenerateVideoNodeView({ id, data, selected }: GenerateVideoNodeV [id, modelOptions, nodeData, updateNodeData] ); - const handleClearVideo = useCallback(() => { - updateNodeData(id, { outputVideo: null, status: "idle", error: null }); - }, [id, updateNodeData]); - const handleParametersChange = useCallback( (parameters: Record) => { updateNodeData(id, buildGenerationParametersPatch(nodeConfig, parameters)); @@ -441,6 +437,59 @@ export function GenerateVideoNodeView({ id, data, selected }: GenerateVideoNodeV } }, [generationsPath]); + const handleClearVideo = useCallback(async () => { + const history = nodeData.videoHistory || []; + if (history.length === 0) { + updateNodeData(id, { + outputVideo: null, + outputVideoRef: undefined, + outputVideoRemoteUrl: undefined, + outputVideoRemoteExpiresAt: undefined, + outputVideoStorageStatus: undefined, + videoHistory: [], + selectedVideoHistoryIndex: 0, + status: "idle", + error: null, + }); + return; + } + + const currentIndex = Math.min(Math.max(nodeData.selectedVideoHistoryIndex || 0, 0), history.length - 1); + const nextHistory = history.filter((_, index) => index !== currentIndex); + if (nextHistory.length === 0) { + updateNodeData(id, { + outputVideo: null, + outputVideoRef: undefined, + outputVideoRemoteUrl: undefined, + outputVideoRemoteExpiresAt: undefined, + outputVideoStorageStatus: undefined, + videoHistory: [], + selectedVideoHistoryIndex: 0, + status: "idle", + error: null, + }); + return; + } + + const nextIndex = Math.min(currentIndex, nextHistory.length - 1); + const nextItem = nextHistory[nextIndex]; + setIsLoadingCarouselVideo(true); + const nextVideo = nextItem.video ?? await loadVideoById(nextItem.id); + setIsLoadingCarouselVideo(false); + + updateNodeData(id, { + outputVideo: nextVideo, + outputVideoRef: undefined, + outputVideoRemoteUrl: nextVideo && /^https?:\/\//i.test(nextVideo) ? nextVideo : undefined, + outputVideoRemoteExpiresAt: undefined, + outputVideoStorageStatus: nextVideo && /^https?:\/\//i.test(nextVideo) ? "remote-only" : undefined, + videoHistory: nextHistory, + selectedVideoHistoryIndex: nextIndex, + status: "idle", + error: nextVideo ? null : "Selected video could not be loaded", + }); + }, [id, loadVideoById, nodeData.selectedVideoHistoryIndex, nodeData.videoHistory, updateNodeData]); + // Carousel navigation handlers const handleCarouselPrevious = useCallback(async () => { const history = nodeData.videoHistory || []; diff --git a/src/hooks/__tests__/useCanvasWorkflowLoader.test.ts b/src/hooks/__tests__/useCanvasWorkflowLoader.test.ts index 06d02cef..583fbc6b 100644 --- a/src/hooks/__tests__/useCanvasWorkflowLoader.test.ts +++ b/src/hooks/__tests__/useCanvasWorkflowLoader.test.ts @@ -9,6 +9,7 @@ const setCurrentCanvasWorkflow = vi.fn(); const clearCurrentCanvasWorkflow = vi.fn(); const setIsLoadingCanvasWorkflow = vi.fn(); const messageError = vi.fn(); +const routerReplace = vi.fn(); vi.mock("antd", () => ({ App: { @@ -24,6 +25,12 @@ vi.mock("@/lib/canvasWorkflowApi", () => ({ getCanvasWorkflowDetail: vi.fn(), })); +vi.mock("next/navigation", () => ({ + useRouter: () => ({ + replace: routerReplace, + }), +})); + vi.mock("@/store/workflowStore", () => ({ useWorkflowStore: (selector: (state: { loadWorkflow: typeof loadWorkflow; @@ -85,5 +92,35 @@ describe("useCanvasWorkflowLoader", () => { title: "Remote Flow", description: "Remote description", }); + expect(routerReplace).not.toHaveBeenCalled(); + }); + + it("redirects home when loading a workflow by id fails", async () => { + vi.mocked(getCanvasWorkflowDetail).mockRejectedValueOnce(new Error("Workflow not found")); + + renderHook(() => useCanvasWorkflowLoader("345")); + + await waitFor(() => { + expect(routerReplace).toHaveBeenCalledWith("/"); + }); + + expect(getCanvasWorkflowDetail).toHaveBeenCalledWith(345); + expect(clearCurrentCanvasWorkflow).toHaveBeenCalled(); + expect(clearWorkflow).toHaveBeenCalled(); + expect(messageError).toHaveBeenCalledWith("Workflow not found"); + expect(setCurrentCanvasWorkflow).not.toHaveBeenCalled(); + }); + + it("redirects home when the workflow id is invalid", async () => { + renderHook(() => useCanvasWorkflowLoader("abc")); + + await waitFor(() => { + expect(routerReplace).toHaveBeenCalledWith("/"); + }); + + expect(getCanvasWorkflowDetail).not.toHaveBeenCalled(); + expect(clearCurrentCanvasWorkflow).toHaveBeenCalled(); + expect(clearWorkflow).toHaveBeenCalled(); + expect(messageError).toHaveBeenCalledWith("工作流 ID 无效"); }); }); diff --git a/src/hooks/useCanvasWorkflowLoader.ts b/src/hooks/useCanvasWorkflowLoader.ts index edff1bfc..1993c8c0 100644 --- a/src/hooks/useCanvasWorkflowLoader.ts +++ b/src/hooks/useCanvasWorkflowLoader.ts @@ -2,6 +2,8 @@ import { useEffect, useRef } from "react"; import { App } from "antd"; +import { useRouter } from "next/navigation"; +import { useI18n } from "@/i18n"; import { getCanvasWorkflowDetail } from "@/lib/canvasWorkflowApi"; import { useCanvasWorkflowStore } from "@/store/canvasWorkflowStore"; import { useWorkflowStore } from "@/store/workflowStore"; @@ -9,9 +11,10 @@ import { parseCanvasWorkflowProperties } from "@/utils/canvasWorkflowProperties" export function useCanvasWorkflowLoader(workflowId: string | null) { const { message } = App.useApp(); + const router = useRouter(); + const { t } = useI18n(); const loadWorkflow = useWorkflowStore((state) => state.loadWorkflow); const clearWorkflow = useWorkflowStore((state) => state.clearWorkflow); - const setWorkFlowName = useWorkflowStore((state) => state.setWorkflowName); const setCurrentCanvasWorkflow = useCanvasWorkflowStore((state) => state.setCurrentCanvasWorkflow); const clearCurrentCanvasWorkflow = useCanvasWorkflowStore((state) => state.clearCurrentCanvasWorkflow); const setIsLoadingCanvasWorkflow = useCanvasWorkflowStore((state) => state.setIsLoadingCanvasWorkflow); @@ -24,9 +27,17 @@ export function useCanvasWorkflowLoader(workflowId: string | null) { return; } + const redirectHome = () => { + loadedWorkflowIdRef.current = null; + clearCurrentCanvasWorkflow(); + clearWorkflow(); + router.replace("/"); + }; + const numericWorkflowId = Number(workflowId); if (!Number.isInteger(numericWorkflowId) || numericWorkflowId <= 0) { - void message.error("工作流 ID 无效"); + void message.error(t("canvasWorkflow.invalidId")); + redirectHome(); return; } @@ -55,9 +66,9 @@ export function useCanvasWorkflowLoader(workflowId: string | null) { }) .catch((error) => { if (isCancelled) return; - loadedWorkflowIdRef.current = null; - const errorMessage = error instanceof Error ? error.message : "加载工作流失败"; + const errorMessage = error instanceof Error ? error.message : t("canvasWorkflow.loadFailed"); void message.error(errorMessage); + redirectHome(); }) .finally(() => { if (!isCancelled) { @@ -72,10 +83,11 @@ export function useCanvasWorkflowLoader(workflowId: string | null) { workflowId, loadWorkflow, clearWorkflow, - setWorkFlowName, setCurrentCanvasWorkflow, clearCurrentCanvasWorkflow, setIsLoadingCanvasWorkflow, message, + router, + t, ]); } diff --git a/src/i18n/index.tsx b/src/i18n/index.tsx index 3df8f196..14285a23 100644 --- a/src/i18n/index.tsx +++ b/src/i18n/index.tsx @@ -142,6 +142,8 @@ const en = { "workflowSave.success": "Workflow saved", "workflowSave.updateSuccess": "Workflow updated", "workflowSave.failed": "Failed to save workflow", + "canvasWorkflow.invalidId": "Invalid workflow ID", + "canvasWorkflow.loadFailed": "Failed to load workflow", "workflowSave.nameRequired": "Workflow name is required", "auth.loginNow": "Login", "auth.loggedInUser": "Logged-in user", @@ -1146,6 +1148,8 @@ const zhCN: Dictionary = { "workflowSave.success": "工作流已保存", "workflowSave.updateSuccess": "工作流已更新", "workflowSave.failed": "保存工作流失败", + "canvasWorkflow.invalidId": "工作流 ID 无效", + "canvasWorkflow.loadFailed": "加载工作流失败", "workflowSave.nameRequired": "工作流名称必填", "quickstart.description": "一个面向生成式 AI 流水线的节点式工作流编辑器。连接节点即可构建生成与转换图片、视频、音频和 3D 资产的流程。", "quickstart.docs": "文档", @@ -3202,6 +3206,8 @@ const ja: Dictionary = { }; Object.assign(zhTW, { + "canvasWorkflow.invalidId": "工作流程 ID 無效", + "canvasWorkflow.loadFailed": "載入工作流程失敗", "group.color": "顏色", "group.layout": "佈局", "group.ungroup": "解組", @@ -3226,6 +3232,8 @@ Object.assign(zhTW, { }); Object.assign(ja, { + "canvasWorkflow.invalidId": "ワークフロー ID が無効です", + "canvasWorkflow.loadFailed": "ワークフローの読み込みに失敗しました", "group.color": "色", "group.layout": "レイアウト", "group.ungroup": "グループ解除", diff --git a/src/store/execution/generateAudioExecutor.ts b/src/store/execution/generateAudioExecutor.ts index cb7a484a..3992217d 100644 --- a/src/store/execution/generateAudioExecutor.ts +++ b/src/store/execution/generateAudioExecutor.ts @@ -108,6 +108,7 @@ export async function executeGenerateAudio( // Add to node's audio history const newHistoryItem = { id: audioId, + audio: audioData, timestamp, prompt: text || "", model: modelToUse.modelId || "", diff --git a/src/types/nodes.ts b/src/types/nodes.ts index 3c506715..701d283d 100644 --- a/src/types/nodes.ts +++ b/src/types/nodes.ts @@ -350,6 +350,7 @@ export interface Generate3DNodeData extends BaseNodeData { */ export interface CarouselAudioItem { id: string; + audio?: string; // Current-session fallback; persisted workflows may only keep id timestamp: number; prompt: string; model: string; // Model ID for audio (not ModelType since external providers)