"use client"; import React, { useCallback, useState, useEffect, useMemo, useRef } from "react"; import { CloudUploadOutlined, PictureOutlined } from "@ant-design/icons"; import { Position, NodeProps, Node } from "@xyflow/react"; import { BaseNode } from "./BaseNode"; import { NodeHandle } from "./NodeHandle"; import { useWorkflowStore } from "@/store/workflowStore"; import { getActiveNodeTool, useNodeToolStore, type NodeToolType } from "@/store/nodeToolStore"; import { useGenerationPreferenceStore } from "@/store/generationPreferenceStore"; import { NanoBananaNodeData, SelectedModel } from "@/types"; import { ProviderModel } from "@/lib/providers/types"; import { ModelSearchDialog } from "@/components/modals/ModelSearchDialog"; import { useToast } from "@/components/Toast"; import { browseRegistry } from "@/utils/browseRegistry"; import { useAdaptiveImageSrc } from "@/hooks/useAdaptiveImageSrc"; import { downloadMedia } from "@/utils/downloadMedia"; import { useI18n } from "@/i18n"; import { MediaPreviewModal } from "@/components/MediaPreviewModal"; import { createPopiserverDefaultImageModel } from "@/store/utils/defaultImageModel"; import { POPI_PROVIDER_ID } from "@/lib/providerMode"; import { chooseModelFromList, toSelectedModel } from "@/utils/modelSelection"; import { useModelStore } from "@/store/modelStore"; import { readImageGenerationConfig, } from "@/utils/generationConfig"; import { formatUserFacingError } from "@/utils/userFacingErrors"; import { SINGLE_INPUT_HANDLE_ID, SINGLE_OUTPUT_HANDLE_ID } from "@/utils/nodeHandles"; import { useSelectedNodeCount } from "@/hooks/useSelectedNodeCount"; import { NodeActionCapsule, type NodeActionCapsuleAction } from "./NodeActionCapsule"; import { SaveToAssetLibraryModal } from "@/components/SaveToAssetLibraryModal"; import { MediaCropOverlay, type MediaCropAspectRatio, type MediaCropConfirmPayload, type MediaEditFrameRect } from "@/components/media/MediaCropOverlay"; import { CropRatioSelect, MEDIA_EDIT_TOOL, MediaEditToolbar, type MediaEditTool, type MediaEditToolOption } from "@/components/media/MediaEditToolbar"; import { createMediaEditActionDropdown, getMediaEditMenuLabel, type MediaEditMenuTool } from "@/components/media/MediaEditActionDropdown"; import { MediaOutpaintingOverlay, type MediaOutpaintingFrameRect, type MediaOutpaintingParams } from "@/components/media/MediaOutpaintingOverlay"; import { MediaOutpaintingToolbar, type OutpaintingBatchSize } from "@/components/media/MediaOutpaintingToolbar"; import { SplitGridCapsuleMenu, type SplitGridSelection } from "@/components/media/SplitGridCapsuleMenu"; import { createSplitGridImageNodes } from "@/utils/splitGridNodes"; import { createPendingDerivedImageNode } from "@/utils/derivedImageNodes"; import { SplitGridSelectionOverlay } from "@/components/media/SplitGridSelectionOverlay"; import { SplitGridSelectionToolbar } from "@/components/media/SplitGridSelectionToolbar"; import { ImageEditSession } from "@/components/media/image-edit/ImageEditSession"; import { GridToolIcon, MultiAngleToolIcon } from "@/components/media/MediaToolbarIcons"; import { getAutoMediaElementClassName, getAutoMediaFrameClassName } from "./mediaAutoSize"; import { chooseHighDefinitionModel, createHighDefinitionImageNode, readHighDefinitionSourceImageDimensions } from "@/utils/highDefinitionNodes"; import { deriveOutpaintingOutputDimensions, OUTPAINTING_DEFAULT_PROMPT } from "@/utils/outpaintingNodes"; import { resolveMediaNodeCreationSizeFromDimensions } from "@/utils/nodeSizing"; import { toSelectedModel as providerModelToSelectedModel } from "@/utils/selectedModel"; const IMAGE_LOAD_RETRY_DELAYS_MS = [600, 1600]; const IMAGE_MEDIA_EDIT_TOOLS: MediaEditToolOption[] = [ { key: MEDIA_EDIT_TOOL.crop, label: "裁剪" }, ]; function GenerateImageEditMenuIcon({ tool, className = "h-5 w-5" }: { tool: MediaEditMenuTool; className?: string }) { if (tool === "high-definition") { return ( ); } if (tool === "crop") { return ( ); } if (tool === "inpainting") { return ( ); } return ( ); } function asImageDimensions(value: unknown): { width: number; height: number } | null { if (!value || typeof value !== "object") return null; const dimensions = value as { width?: unknown; height?: unknown }; return typeof dimensions.width === "number" && typeof dimensions.height === "number" ? { width: dimensions.width, height: dimensions.height } : null; } function isSameSelectedModel(a: SelectedModel | undefined, b: SelectedModel | undefined): boolean { return Boolean(a?.provider && b?.provider && a.provider === b.provider && a.modelId === b.modelId); } type NanoBananaNodeType = Node; export interface GenerateImageNodeViewProps { id: string; data: NanoBananaNodeData; selected?: boolean; renderInputHandle?: boolean; } export function GenerateImageNodeView({ id, data, selected, renderInputHandle = true }: GenerateImageNodeViewProps) { const nodeData = data; const { t } = useI18n(); const storedImage = (data as NanoBananaNodeData & { image?: string | null; previewImage?: string | null }).image ?? null; const storedPreviewImage = (data as NanoBananaNodeData & { image?: string | null; previewImage?: string | null }).previewImage ?? null; const primaryImage = data.outputImage || storedImage; const primaryPreviewImage = data.previewImg || storedPreviewImage; const preferredDisplayImage = primaryPreviewImage || primaryImage; const [imageRetryNonce, setImageRetryNonce] = useState(0); const [displayFallbackImage, setDisplayFallbackImage] = useState(null); const displayImage = displayFallbackImage || preferredDisplayImage; const adaptiveOutputImage = useAdaptiveImageSrc(displayImage, id); const outputImageSrc = adaptiveOutputImage; const updateNodeData = useWorkflowStore((state) => state.updateNodeData); const updateMediaNodeData = useWorkflowStore((state) => state.updateMediaNodeData); const addNode = useWorkflowStore((state) => state.addNode); const onConnect = useWorkflowStore((state) => state.onConnect); const getNodeById = useWorkflowStore((state) => state.getNodeById); const selectSingleNode = useWorkflowStore((state) => state.selectSingleNode); const activeNodeTool = useNodeToolStore((state) => getActiveNodeTool(state, id)); const switchNodeTool = useNodeToolStore((state) => state.switchTool); const closeNodeToolForNode = useNodeToolStore((state) => state.closeToolForNode); const updateImagePreference = useGenerationPreferenceStore((state) => state.updateImagePreference); const popiserverImageModels = useModelStore((state) => state.byKind.image); const highDefinitionModels = useModelStore((state) => state.byKind.highDefinition); const outpaintingModel = useModelStore((state) => state.byKind.outpainting[0] ?? null); const inpaintingModel = useModelStore((state) => state.byKind.inpainting?.[0] ?? null); const [isLoadingCarouselImage, setIsLoadingCarouselImage] = useState(false); const [isBrowseDialogOpen, setIsBrowseDialogOpen] = useState(false); const [isPreviewOpen, setIsPreviewOpen] = useState(false); const [isSaveToAssetLibraryOpen, setIsSaveToAssetLibraryOpen] = useState(false); const [activeEditMenuTool, setActiveEditMenuTool] = useState("crop"); const [cropAspectRatio, setCropAspectRatio] = useState("original"); const [cropPayload, setCropPayload] = useState(null); const [cropFrameRect, setCropFrameRect] = useState(null); const [cropImageBoundsRect, setCropImageBoundsRect] = useState(null); const [outpaintingParams, setOutpaintingParams] = useState(null); const [outpaintingFrameRect, setOutpaintingFrameRect] = useState(null); const [outpaintingImageBoundsRect, setOutpaintingImageBoundsRect] = useState(null); const [outpaintingBatchSize, setOutpaintingBatchSize] = useState(1); const [isSplitGridRunning, setIsSplitGridRunning] = useState(false); const [gridSelection, setGridSelection] = useState(null); const [selectedGridCellKeys, setSelectedGridCellKeys] = useState>(() => new Set()); const [cropImageDimensions, setCropImageDimensions] = useState<{ width: number; height: number } | null>(null); const imageRef = useRef(null); useEffect(() => { setDisplayFallbackImage(null); setImageRetryNonce(0); setCropImageDimensions(null); }, [preferredDisplayImage]); // Register browse callback for floating header button useEffect(() => { browseRegistry.register(id, () => setIsBrowseDialogOpen(true)); return () => { browseRegistry.unregister(id); }; }, [id]); const defaultSelectedModel = useMemo( () => nodeData.selectedModel ?? createPopiserverDefaultImageModel(), [nodeData.selectedModel] ); const nodeConfig = useMemo( () => readImageGenerationConfig(nodeData, defaultSelectedModel), [defaultSelectedModel, nodeData] ); const modelOptions = popiserverImageModels; // Keep imported nodes on the Popi gateway. useEffect(() => { if (!nodeData.selectedModel || nodeData.selectedModel.provider !== POPI_PROVIDER_ID) { const newSelectedModel = createPopiserverDefaultImageModel(); updateNodeData(id, { selectedModel: newSelectedModel }); } }, [id, nodeConfig, nodeData.selectedModel, updateNodeData]); useEffect(() => { if (modelOptions.length === 0) return; if (nodeConfig.selectedModel?.provider === POPI_PROVIDER_ID && nodeConfig.selectedModel.modelId) return; const selectedModel = chooseModelFromList(modelOptions, nodeConfig.selectedModel); if (!selectedModel || selectedModel.modelId === nodeConfig.selectedModel?.modelId) return; updateImagePreference({ model: selectedModel }); updateNodeData(id, { selectedModel, parameters: {}, modelSchemaRequestId: Date.now(), }); }, [id, modelOptions, nodeConfig.selectedModel, nodeData, updateImagePreference, updateNodeData]); const regenerateNode = useWorkflowStore((state) => state.regenerateNode); const isRunning = useWorkflowStore((state) => state.runningNodeIds.has(id)); const handleRegenerate = useCallback(() => { regenerateNode(id); }, [id, regenerateNode]); const handleClearImage = useCallback(async () => { const history = nodeData.imageHistory || []; if (history.length === 0) { updateNodeData(id, { image: null, imageRef: undefined, assetId: undefined, previewImage: undefined, outputImage: null, previewImg: undefined, outputImageRef: undefined, outputImageStorageStatus: undefined, dimensions: null, imageHistory: [], selectedHistoryIndex: 0, derivedImage: undefined, 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, { image: null, imageRef: undefined, assetId: undefined, previewImage: undefined, outputImage: null, previewImg: undefined, outputImageRef: undefined, outputImageStorageStatus: undefined, dimensions: null, imageHistory: [], selectedHistoryIndex: 0, derivedImage: undefined, status: "idle", error: null, }); return; } const nextIndex = Math.min(currentIndex, nextHistory.length - 1); const nextItem = nextHistory[nextIndex]; setIsLoadingCarouselImage(true); const nextImage = nextItem.image ?? null; setIsLoadingCarouselImage(false); updateMediaNodeData(id, { outputImage: nextImage, previewImg: nextItem.previewImg, outputImageRef: undefined, outputImageStorageStatus: nextImage ? /^https?:\/\//i.test(nextImage) ? "remote-only" : "localized" : undefined, dimensions: nextItem.dimensions ?? null, imageHistory: nextHistory, selectedHistoryIndex: nextIndex, status: "idle", error: nextImage ? null : "Selected image could not be loaded", }, nextItem.dimensions ?? null); }, [id, nodeData.imageHistory, nodeData.selectedHistoryIndex, updateMediaNodeData, updateNodeData]); const resetToolDraftState = useCallback(() => { setCropPayload(null); setCropFrameRect(null); setCropImageBoundsRect(null); setOutpaintingParams(null); setOutpaintingFrameRect(null); setOutpaintingImageBoundsRect(null); setGridSelection(null); setSelectedGridCellKeys(new Set()); }, []); const openNodeTool = useCallback((tool: NodeToolType) => { resetToolDraftState(); if (tool === "crop") setCropAspectRatio("original"); if (tool === "outpainting") setOutpaintingBatchSize(1); switchNodeTool(id, tool); }, [id, resetToolDraftState, switchNodeTool]); const closeNodeTool = useCallback(() => { resetToolDraftState(); closeNodeToolForNode(id); }, [closeNodeToolForNode, id, resetToolDraftState]); const handleConfirmCrop = useCallback((payload: MediaCropConfirmPayload) => { const currentNode = getNodeById(id); if (!currentNode) return; const currentPosition = currentNode.position ?? { x: 0, y: 0 }; const currentWidth = typeof currentNode.style?.width === "number" ? currentNode.style.width : 300; const filename = `crop-${Date.now()}.png`; const dimensions = { width: payload.outputWidth, height: payload.outputHeight }; const size = resolveMediaNodeCreationSizeFromDimensions(dimensions) ?? undefined; const nodeId = createPendingDerivedImageNode({ sourceNodeId: id, position: { x: currentPosition.x + currentWidth + 220, y: currentPosition.y, }, filename, dimensions, customTitle: t("imageInput.crop"), operation: "crop", task: { cropRect: payload.cropRect, outputWidth: payload.outputWidth, outputHeight: payload.outputHeight, aspectRatio: payload.aspectRatio, }, size, addNode, onConnect, selectSingleNode, }); closeNodeTool(); void regenerateNode(nodeId); }, [addNode, closeNodeTool, getNodeById, id, onConnect, regenerateNode, selectSingleNode, t]); const handleStartGridSelection = useCallback((selection: SplitGridSelection) => { resetToolDraftState(); switchNodeTool(id, "splitGrid"); setGridSelection(selection); setSelectedGridCellKeys(new Set()); }, [id, resetToolDraftState, switchNodeTool]); const handleToggleGridCell = useCallback((cellKey: string) => { setSelectedGridCellKeys((current) => { const next = new Set(current); if (next.has(cellKey)) { next.delete(cellKey); } else { next.add(cellKey); } return next; }); }, []); const handleCancelGridSelection = useCallback(() => { closeNodeTool(); }, [closeNodeTool]); const handleConfirmSplitGrid = useCallback(async () => { const sourceNode = getNodeById(id); if (!sourceNode || !gridSelection || selectedGridCellKeys.size === 0) return; setIsSplitGridRunning(true); try { const createdNodeIds = createSplitGridImageNodes({ sourceNode, rows: gridSelection.rows, cols: gridSelection.cols, selectedCellKeys: selectedGridCellKeys, addNode, onConnect, selectSingleNode, }); handleCancelGridSelection(); createdNodeIds.forEach((nodeId) => void regenerateNode(nodeId)); } catch (error) { alert(t("workflow.splitGrid.failed", { error: error instanceof Error ? error.message : "Unknown error" })); } finally { setIsSplitGridRunning(false); } }, [addNode, getNodeById, gridSelection, handleCancelGridSelection, id, onConnect, regenerateNode, selectSingleNode, selectedGridCellKeys, t]); const handleCreateHighDefinitionNode = useCallback(async () => { const currentNode = getNodeById(id); const imageSource = primaryImage || displayImage; if (!currentNode || !imageSource) return; const currentPosition = currentNode.position ?? { x: 0, y: 0 }; const currentWidth = typeof currentNode.style?.width === "number" ? currentNode.style.width : 300; const dimensions = await readHighDefinitionSourceImageDimensions(currentNode.data as { dimensions?: unknown }, imageSource); createHighDefinitionImageNode({ sourceNodeId: id, position: { x: currentPosition.x + currentWidth + 220, y: currentPosition.y, }, imageSource, dimensions, selectedModel: chooseHighDefinitionModel(highDefinitionModels), addNode, onConnect, selectSingleNode, }); }, [addNode, displayImage, getNodeById, highDefinitionModels, id, primaryImage, onConnect, selectSingleNode]); const handleConfirmOutpainting = useCallback(async (params: MediaOutpaintingParams) => { const currentNode = getNodeById(id); const imageSource = primaryImage || displayImage; if (!currentNode || !imageSource || !outpaintingModel) return; const currentPosition = currentNode.position ?? { x: 0, y: 0 }; const currentWidth = typeof currentNode.style?.width === "number" ? currentNode.style.width : 300; const sourceDimensions = await readHighDefinitionSourceImageDimensions(currentNode.data as { dimensions?: unknown }, imageSource); const dimensions = deriveOutpaintingOutputDimensions(sourceDimensions, params) ?? sourceDimensions; const size = resolveMediaNodeCreationSizeFromDimensions(dimensions) ?? undefined; const selectedModel = providerModelToSelectedModel(outpaintingModel); const nodeId = addNode("smartImage", { x: currentPosition.x + currentWidth + 220, y: currentPosition.y, }, { inputImages: [imageSource], inputPrompt: OUTPAINTING_DEFAULT_PROMPT, selectedModel, dimensions, batchSize: outpaintingBatchSize, parameters: { batchSize: outpaintingBatchSize, }, extraTaskParams: params, filename: `outpainting-${Date.now()}.png`, customTitle: "扩图", derivedImage: { sourceNodeId: id, operation: "outpainting", }, status: "idle", error: null, }, size); onConnect({ source: id, sourceHandle: SINGLE_OUTPUT_HANDLE_ID, target: nodeId, targetHandle: SINGLE_INPUT_HANDLE_ID, }); selectSingleNode(nodeId); closeNodeTool(); await regenerateNode(nodeId); }, [ addNode, closeNodeTool, displayImage, getNodeById, id, primaryImage, onConnect, outpaintingBatchSize, outpaintingModel, regenerateNode, selectSingleNode, ]); const startCropTool = useCallback(() => { openNodeTool("crop"); }, [openNodeTool]); const startOutpaintingTool = useCallback(() => { openNodeTool("outpainting"); }, [openNodeTool]); const startInpaintingTool = useCallback(() => { openNodeTool("inpainting"); }, [openNodeTool]); const handleEditMenuPrimaryClick = useCallback((event: React.MouseEvent) => { event.preventDefault(); event.stopPropagation(); if (activeEditMenuTool === "high-definition") { void handleCreateHighDefinitionNode(); return; } if (activeEditMenuTool === "outpainting") { startOutpaintingTool(); return; } if (activeEditMenuTool === "inpainting") { startInpaintingTool(); return; } setActiveEditMenuTool("crop"); startCropTool(); }, [activeEditMenuTool, handleCreateHighDefinitionNode, startCropTool, startInpaintingTool, startOutpaintingTool]); const handleCarouselPrevious = useCallback(async () => { const history = nodeData.imageHistory || []; if (history.length === 0 || isLoadingCarouselImage) return; const currentIndex = nodeData.selectedHistoryIndex || 0; const newIndex = currentIndex === 0 ? history.length - 1 : currentIndex - 1; const imageItem = history[newIndex]; setIsLoadingCarouselImage(true); const image = imageItem.image ?? null; setIsLoadingCarouselImage(false); if (image) { updateMediaNodeData(id, { outputImage: image, previewImg: imageItem.previewImg, dimensions: imageItem.dimensions ?? null, selectedHistoryIndex: newIndex, status: "idle", error: null, }, imageItem.dimensions ?? null); } }, [id, nodeData.imageHistory, nodeData.selectedHistoryIndex, isLoadingCarouselImage, updateMediaNodeData]); const handleCarouselNext = useCallback(async () => { const history = nodeData.imageHistory || []; if (history.length === 0 || isLoadingCarouselImage) return; const currentIndex = nodeData.selectedHistoryIndex || 0; const newIndex = (currentIndex + 1) % history.length; const imageItem = history[newIndex]; setIsLoadingCarouselImage(true); const image = imageItem.image ?? null; setIsLoadingCarouselImage(false); if (image) { updateMediaNodeData(id, { outputImage: image, previewImg: imageItem.previewImg, dimensions: imageItem.dimensions ?? null, selectedHistoryIndex: newIndex, status: "idle", error: null, }, imageItem.dimensions ?? null); } }, [id, nodeData.imageHistory, nodeData.selectedHistoryIndex, isLoadingCarouselImage, updateMediaNodeData]); // Handle model selection from browse dialog const handleBrowseModelSelect = useCallback((model: ProviderModel) => { const newSelectedModel = toSelectedModel(model); if (isSameSelectedModel(nodeConfig.selectedModel, newSelectedModel)) { updateNodeData(id, { modelSchemaRequestId: Date.now() }); } else { updateImagePreference({ model: newSelectedModel }); updateNodeData(id, { selectedModel: newSelectedModel, parameters: {}, modelSchemaRequestId: Date.now(), }); } setIsBrowseDialogOpen(false); }, [id, nodeConfig.selectedModel, nodeData, updateImagePreference, updateNodeData]); // Dynamic title based on selected model - just the model name const displayTitle = useMemo(() => { if (nodeConfig.selectedModel?.displayName && nodeConfig.selectedModel.modelId) { return nodeConfig.selectedModel.displayName; } if (nodeData.outputImage && nodeData.lastUsedModel?.displayName) { return nodeData.lastUsedModel.displayName; } return t("node.selectModel"); }, [nodeConfig.selectedModel?.displayName, nodeConfig.selectedModel?.modelId, nodeData.lastUsedModel?.displayName, nodeData.outputImage, t]); const displayError = formatUserFacingError(nodeData.error, t, displayTitle); const hasCarouselImages = (nodeData.imageHistory || []).length > 1; // Track previous status to detect error transitions const prevStatusRef = useRef(nodeData.status); // Show toast when error occurs useEffect(() => { if (nodeData.status === "error" && prevStatusRef.current !== "error" && displayError) { useToast.getState().show(t("node.generationFailed"), "error", true, displayError); } prevStatusRef.current = nodeData.status; }, [nodeData.status, displayError, t]); useEffect(() => { if (selected) return; closeNodeTool(); }, [closeNodeTool, selected]); const selectedNodeCount = useSelectedNodeCount(); const editableImage = primaryImage || displayImage; const downloadImage = nodeData.outputImage || editableImage; const overlayImageDimensions = cropImageDimensions || asImageDimensions(nodeData.dimensions); const showSelectedActions = Boolean(selected && selectedNodeCount === 1 && editableImage); const isCropping = activeNodeTool === "crop"; const isOutpainting = activeNodeTool === "outpainting"; const isInpainting = activeNodeTool === "inpainting"; const isSplitGrid = activeNodeTool === "splitGrid"; const isMultiAnglePanelOpen = activeNodeTool === "multiAngle"; const renderedImageSrc = isCropping || isOutpainting || isInpainting ? editableImage : outputImageSrc; const selectedActions: NodeActionCapsuleAction[] = editableImage ? [ createMediaEditActionDropdown({ label: getMediaEditMenuLabel(activeEditMenuTool), icon: , onClick: handleEditMenuPrimaryClick, items: [ { key: "high-definition", icon: , label: t("imageInput.highDefinition"), }, { key: "outpainting", icon: , label: "扩图", disabled: !outpaintingModel, }, { key: "inpainting", icon: , label: "擦除", disabled: !inpaintingModel, }, { key: "crop", icon: , label: t("imageInput.crop"), }, ], menuOnClick: ({ key, domEvent }) => { domEvent.preventDefault(); domEvent.stopPropagation(); if (key === "high-definition") { setActiveEditMenuTool("high-definition"); void handleCreateHighDefinitionNode(); return; } if (key === "outpainting") { setActiveEditMenuTool("outpainting"); startOutpaintingTool(); return; } if (key === "inpainting") { setActiveEditMenuTool("inpainting"); startInpaintingTool(); return; } if (key === "crop") { setActiveEditMenuTool("crop"); startCropTool(); } }, }), { key: "split-grid", icon: , label: t("imageInput.splitGrid"), text: t("imageInput.splitGrid"), section: "feature", panel: ({ close }) => ( { close(); handleStartGridSelection(selection); }} /> ), }, { key: "multi-angle", icon: , label: t("imageInput.multiAngle"), text: t("imageInput.multiAngle"), section: "feature", onClick: (event) => { event.preventDefault(); event.stopPropagation(); switchNodeTool(id, "multiAngle"); }, }, { key: "save-to-assets", icon: , label: t("assetLibrary.saveToLibrary"), section: "basic", onClick: (event) => { event.preventDefault(); event.stopPropagation(); setIsSaveToAssetLibraryOpen(true); }, }, { key: "download", icon: ( ), label: t("imageInput.download"), section: "basic", onClick: (event) => { event.preventDefault(); event.stopPropagation(); if (downloadImage) { downloadMedia(downloadImage, "image").catch(() => {}); } }, }, { key: "preview", icon: ( ), label: t("imageInput.focusPreview"), section: "basic", onClick: (event) => { event.preventDefault(); event.stopPropagation(); setIsPreviewOpen(true); }, }, { key: "clear", icon: ( ), label: t("imageInput.remove"), section: "basic", tone: "danger", onClick: (event) => { event.preventDefault(); event.stopPropagation(); void handleClearImage(); }, }, ] : []; return ( <> {renderInputHandle && ( )} {showSelectedActions && !activeNodeTool && } {isSplitGrid && gridSelection && ( void handleConfirmSplitGrid()} /> )}
{/* Preview area */} {displayImage ? ( <> Generated { setImageRetryNonce(0); const image = event.currentTarget; if ((renderedImageSrc === nodeData.outputImage || renderedImageSrc === storedImage) && image.naturalWidth > 0 && image.naturalHeight > 0) { const dimensions = { width: image.naturalWidth, height: image.naturalHeight }; setCropImageDimensions(dimensions); updateMediaNodeData(id, { dimensions }, dimensions); } }} onError={() => { if (primaryPreviewImage && displayImage === primaryPreviewImage && primaryImage && primaryImage !== primaryPreviewImage) { setDisplayFallbackImage(primaryImage); 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(); setIsPreviewOpen(true); }} className={getAutoMediaElementClassName(overlayImageDimensions, "cursor-zoom-in")} /> {isCropping && ( )} {isOutpainting && ( )} {isInpainting && editableImage && ( { closeNodeTool(); }} /> )} {isCropping && ( { closeNodeTool(); }} context={{ value: cropAspectRatio, onChange: setCropAspectRatio, onConfirm: () => { if (cropPayload) void handleConfirmCrop(cropPayload); }, }} toolComponents={{ [MEDIA_EDIT_TOOL.crop]: CropRatioSelect, }} /> )} {isOutpainting && ( { closeNodeTool(); }} onConfirm={() => { if (outpaintingParams) void handleConfirmOutpainting(outpaintingParams); }} /> )} {isSplitGrid && gridSelection && ( )} {/* Loading overlay for generation */} {nodeData.status === "loading" && (
)} {/* Error overlay when generation failed */} {nodeData.status === "error" && (
{t("node.generationFailed")} See toast for details
)} {/* Loading overlay for carousel navigation */} {isLoadingCarouselImage && (
)} {/* Carousel controls - overlaid on image bottom */} {hasCarouselImages && (
{(nodeData.selectedHistoryIndex || 0) + 1} / {(nodeData.imageHistory || []).length}
)} ) : (
{nodeData.status === "loading" ? ( ) : nodeData.status === "error" ? ( {displayError || "Failed"} ) : ( )}
)}
{isPreviewOpen && editableImage && ( setIsPreviewOpen(false)} /> )} setIsSaveToAssetLibraryOpen(false)} /> {/* Model browse dialog */} {isBrowseDialogOpen && ( setIsBrowseDialogOpen(false)} onModelSelected={handleBrowseModelSelect} initialCapabilityFilter="image" /> )} ); } export function GenerateImageNode({ id, data, selected }: NodeProps) { return ; } /** * @deprecated Use `GenerateImageNode` instead. This alias is kept for backward compatibility * with existing workflows but will be removed in a future version. */ export { GenerateImageNode as NanoBananaNode };