From 43cc39fcffcb451e589b1e514e6f895624f5fd81 Mon Sep 17 00:00:00 2001 From: TianYun Date: Fri, 12 Jun 2026 12:12:35 +0800 Subject: [PATCH] =?UTF-8?q?=E8=A7=86=E9=A2=91=E4=B8=8D=E8=83=BD=E5=86=8D?= =?UTF-8?q?=E7=BB=84=E7=94=9F=E6=88=90=E7=9A=84=E9=97=AE=E9=A2=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/components/nodes/GenerateImageNode.tsx | 35 +++++++++++- src/components/nodes/ImageInputNode.tsx | 41 ++++++++++++-- src/components/nodes/SmartImageNode.tsx | 2 +- .../workflowStore.integration.test.ts | 56 +++++++++++++++++++ src/store/workflowStore.ts | 7 +++ 5 files changed, 133 insertions(+), 8 deletions(-) diff --git a/src/components/nodes/GenerateImageNode.tsx b/src/components/nodes/GenerateImageNode.tsx index f1868eb7..4c2ceabe 100644 --- a/src/components/nodes/GenerateImageNode.tsx +++ b/src/components/nodes/GenerateImageNode.tsx @@ -73,6 +73,7 @@ const COMPOSER_PRIORITY_IMAGE_PARAMETERS = [ "resolution", "imageSize", ]; +const IMAGE_LOAD_RETRY_DELAYS_MS = [600, 1600]; // Hardcoded Gemini image models (always available) const GEMINI_IMAGE_MODELS: { value: ModelType; label: string }[] = [ @@ -125,8 +126,16 @@ export interface GenerateImageNodeViewProps { export function GenerateImageNodeView({ id, data, selected }: GenerateImageNodeViewProps) { const nodeData = data; const { t } = useI18n(); - const displayImage = data.previewImg || data.outputImage; + const preferredDisplayImage = data.previewImg || data.outputImage; + const [imageRetryNonce, setImageRetryNonce] = useState(0); + const [displayFallbackImage, setDisplayFallbackImage] = useState(null); + const displayImage = displayFallbackImage || preferredDisplayImage; const adaptiveOutputImage = useAdaptiveImageSrc(displayImage, id); + const outputImageSrc = adaptiveOutputImage + ? imageRetryNonce > 0 && /^https?:\/\//i.test(adaptiveOutputImage) && !adaptiveOutputImage.includes("?") + ? `${adaptiveOutputImage}${adaptiveOutputImage.includes("?") ? "&" : "?"}canvasRetry=${imageRetryNonce}` + : adaptiveOutputImage + : null; const updateNodeData = useWorkflowStore((state) => state.updateNodeData); const popiserverImageModels = useModelStore((state) => state.byKind.image); // Use stable selector for API keys to prevent unnecessary re-fetches @@ -141,6 +150,11 @@ export function GenerateImageNodeView({ id, data, selected }: GenerateImageNodeV const [settingsTab, setSettingsTab] = useState<"primary" | "fallback">("primary"); const showFallbackSettings = !popiProviderMode && !!nodeData.fallbackModel; + useEffect(() => { + setDisplayFallbackImage(null); + setImageRetryNonce(0); + }, [preferredDisplayImage]); + useEffect(() => { if (!showFallbackSettings && settingsTab === "fallback") { setSettingsTab("primary"); @@ -873,8 +887,25 @@ export function GenerateImageNodeView({ id, data, selected }: GenerateImageNodeV {displayImage ? ( <> Generated setImageRetryNonce(0)} + onError={() => { + if (nodeData.previewImg && displayImage === nodeData.previewImg && nodeData.outputImage && nodeData.outputImage !== nodeData.previewImg) { + setDisplayFallbackImage(nodeData.outputImage); + setImageRetryNonce(0); + return; + } + + const retryIndex = imageRetryNonce; + const retryDelay = IMAGE_LOAD_RETRY_DELAYS_MS[retryIndex]; + if (retryDelay !== undefined && displayImage && /^https?:\/\//i.test(displayImage)) { + window.setTimeout(() => { + setImageRetryNonce((current) => current === retryIndex ? current + 1 : current); + }, retryDelay); + } + }} onDoubleClick={(e) => { e.preventDefault(); e.stopPropagation(); diff --git a/src/components/nodes/ImageInputNode.tsx b/src/components/nodes/ImageInputNode.tsx index e406a166..c201614b 100644 --- a/src/components/nodes/ImageInputNode.tsx +++ b/src/components/nodes/ImageInputNode.tsx @@ -20,6 +20,7 @@ import { getPopiImageAssetPreview, resolvePopiImageAsset } from "@/lib/popiAsset type ImageInputNodeType = Node; const IMAGE_INPUT_DIMENSIONS = defaultNodeDimensions.imageInput; const IMAGE_DIMENSION_TIMEOUT_MS = 10_000; +const IMAGE_LOAD_RETRY_DELAYS_MS = [600, 1600]; export interface ImageInputNodeViewProps { id: string; @@ -91,7 +92,7 @@ export function ImageInputNodeView({ id, data, selected, renderInputHandle = fal const { t } = useI18n(); const nodeData = data; const adaptiveImage = useAdaptiveImageSrc(nodeData.image, id); - const displayImage = nodeData.previewImage || adaptiveImage || nodeData.image; + const preferredDisplayImage = nodeData.previewImage || adaptiveImage || nodeData.image; const updateNodeData = useWorkflowStore((state) => state.updateNodeData); const selectSingleNode = useWorkflowStore((state) => state.selectSingleNode); const { setNodes } = useReactFlow(); @@ -99,12 +100,23 @@ export function ImageInputNodeView({ id, data, selected, renderInputHandle = fal const prevImageRef = useRef(null); const [isPreviewOpen, setIsPreviewOpen] = useState(false); const [isAssetPickerOpen, setIsAssetPickerOpen] = useState(false); + const [imageRetryNonce, setImageRetryNonce] = useState(0); + const [displayFallbackImage, setDisplayFallbackImage] = useState(null); const [imageLoadFailed, setImageLoadFailed] = useState(false); const isUploading = nodeData.uploadStatus === "loading"; + const displayImage = displayFallbackImage || preferredDisplayImage; + + const imageSrc = displayImage + ? imageRetryNonce > 0 && /^https?:\/\//i.test(displayImage) && !displayImage.includes("?") + ? `${displayImage}${displayImage.includes("?") ? "&" : "?"}canvasRetry=${imageRetryNonce}` + : displayImage + : null; useEffect(() => { + setDisplayFallbackImage(null); setImageLoadFailed(false); - }, [displayImage]); + setImageRetryNonce(0); + }, [preferredDisplayImage]); useEffect(() => { if (!nodeData.image || !nodeData.dimensions || nodeData.image === prevImageRef.current) { @@ -180,7 +192,7 @@ export function ImageInputNodeView({ id, data, selected, renderInputHandle = fal }) .catch((error) => { const message = error instanceof Error ? error.message : "Media upload failed"; - updateNodeData(id, { uploadStatus: "error", uploadError: message }); + updateNodeData(id, { uploadStatus: "idle", uploadError: message }); alert(message); }); }; @@ -467,10 +479,29 @@ export function ImageInputNodeView({ id, data, selected, renderInputHandle = fal className="relative group h-full w-full cursor-zoom-in overflow-clip rounded-lg bg-neutral-800" > {nodeData.filename setImageLoadFailed(false)} - onError={() => setImageLoadFailed(true)} + onError={() => { + if (nodeData.previewImage && displayImage === nodeData.previewImage && nodeData.image && nodeData.image !== nodeData.previewImage) { + setDisplayFallbackImage(nodeData.image); + setImageLoadFailed(false); + setImageRetryNonce(0); + return; + } + + const retryIndex = imageRetryNonce; + const retryDelay = IMAGE_LOAD_RETRY_DELAYS_MS[retryIndex]; + if (retryDelay !== undefined && displayImage && /^https?:\/\//i.test(displayImage)) { + window.setTimeout(() => { + setImageRetryNonce((current) => current === retryIndex ? current + 1 : current); + }, retryDelay); + return; + } + + setImageLoadFailed(true); + }} className="h-full w-full rounded-lg object-contain" /> {imageLoadFailed && ( diff --git a/src/components/nodes/SmartImageNode.tsx b/src/components/nodes/SmartImageNode.tsx index 893720c9..be028b5a 100644 --- a/src/components/nodes/SmartImageNode.tsx +++ b/src/components/nodes/SmartImageNode.tsx @@ -118,7 +118,7 @@ function SmartImageUploadToolbar({ }) .catch((error) => { const message = error instanceof Error ? error.message : "Media upload failed"; - updateNodeData(id, { uploadStatus: "error", uploadError: message }); + updateNodeData(id, { uploadStatus: "idle", uploadError: message }); alert(message); }); }; diff --git a/src/store/__tests__/workflowStore.integration.test.ts b/src/store/__tests__/workflowStore.integration.test.ts index 4152f53f..2ccdb7c0 100644 --- a/src/store/__tests__/workflowStore.integration.test.ts +++ b/src/store/__tests__/workflowStore.integration.test.ts @@ -1730,6 +1730,62 @@ describe("workflowStore integration tests", () => { } }); + it("should execute smartVideo during selected node execution", async () => { + const mockFetch = vi.fn().mockImplementation(async (url: string) => { + if (url === "/api/generate/task/create") { + return { + ok: true, + json: () => Promise.resolve({ success: true, taskId: "task-1" }), + }; + } + if (url.startsWith("/api/generate/task/poll")) { + return { + ok: true, + json: () => Promise.resolve({ + status: "completed", + result: { success: true, video: "https://example.com/generated.mp4" }, + }), + }; + } + throw new Error(`Unexpected fetch URL: ${url}`); + }); + vi.stubGlobal("fetch", mockFetch); + + useWorkflowStore.setState({ + nodes: [ + createTestNode("prompt-1", "prompt", { prompt: "test video" }), + createTestNode("smartVideo-1", "smartVideo", { + selectedModel: { provider: "popiserver", modelId: "video-model", displayName: "Video Model" }, + durationSeconds: 5, + resolution: "720p", + inputImages: [], + inputPrompt: null, + outputVideo: null, + videoHistory: [], + status: "idle", + }), + ], + edges: [ + createTestEdge("prompt-1", "smartVideo-1", "output", "input"), + ], + }); + + const store = useWorkflowStore.getState(); + await store.executeSelectedNodes(["prompt-1", "smartVideo-1"]); + + const videoNode = useWorkflowStore.getState().nodes.find(n => n.id === "smartVideo-1"); + expect(videoNode?.data).toHaveProperty("status", "complete"); + expect(videoNode?.data).toHaveProperty("outputVideo", "https://example.com/generated.mp4"); + expect(mockFetch).toHaveBeenCalledWith( + "/api/generate/task/create", + expect.objectContaining({ + body: expect.stringContaining('"mediaType":"video"'), + }) + ); + + vi.unstubAllGlobals(); + }); + it("should stop execution on error (subsequent nodes not executed)", async () => { vi.stubGlobal("fetch", vi.fn()); diff --git a/src/store/workflowStore.ts b/src/store/workflowStore.ts index 31039f77..7dba16d6 100644 --- a/src/store/workflowStore.ts +++ b/src/store/workflowStore.ts @@ -65,6 +65,7 @@ import { createDefaultImageModel } from "./utils/defaultImageModel"; import { isSmartImageGenerateMode } from "@/utils/smartImageMode"; import { isSmartAudioGenerateMode } from "@/utils/smartAudioMode"; import { isSmartTextLlmMode } from "@/utils/smartTextMode"; +import { isSmartVideoGenerateMode } from "@/utils/smartVideoMode"; import { getNodeOutputMediaType, normalizeConnectionHandles, @@ -2919,6 +2920,12 @@ const workflowStoreImpl: StateCreator = (set, get) => ({ } break; } + case "smartVideo": { + if (isSmartVideoGenerateMode(node.id, get().edges)) { + await executeGenerateVideo(executionCtx, regenOptions); + } + break; + } case "glbViewer": await executeGlbViewer(executionCtx); break;