"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 { useNodeInlinePanelStore } from "@/store/nodeInlinePanelStore"; 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 } from "@/components/media/MediaCropOverlay"; import { CropRatioSelect, MEDIA_EDIT_TOOL, MediaEditToolbar, type MediaEditTool, type MediaEditToolOption } from "@/components/media/MediaEditToolbar"; 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 { getAutoMediaElementClassName, getAutoMediaFrameClassName } from "./mediaAutoSize"; const IMAGE_LOAD_RETRY_DELAYS_MS = [600, 1600]; const IMAGE_MEDIA_EDIT_TOOLS: MediaEditToolOption[] = [ { key: MEDIA_EDIT_TOOL.crop, label: "裁剪" }, ]; 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; } export function GenerateImageNodeView({ id, data, selected }: GenerateImageNodeViewProps) { const nodeData = data; const { t } = useI18n(); 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; 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 openInlinePanel = useNodeInlinePanelStore((state) => state.openPanel); const updateImagePreference = useGenerationPreferenceStore((state) => state.updateImagePreference); const popiserverImageModels = useModelStore((state) => state.byKind.image); const [isLoadingCarouselImage, setIsLoadingCarouselImage] = useState(false); const [isBrowseDialogOpen, setIsBrowseDialogOpen] = useState(false); const [isPreviewOpen, setIsPreviewOpen] = useState(false); const [isSaveToAssetLibraryOpen, setIsSaveToAssetLibraryOpen] = useState(false); const [isCropping, setIsCropping] = useState(false); const [activeMediaTool, setActiveMediaTool] = useState(MEDIA_EDIT_TOOL.crop); const [cropAspectRatio, setCropAspectRatio] = useState("original"); const [cropPayload, setCropPayload] = useState(null); 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, { 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, { 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 handleConfirmCrop = useCallback((payload: MediaCropConfirmPayload) => { const currentNode = getNodeById(id); if (!currentNode) return; const currentPosition = currentNode.position ?? { x: 0, y: 0 }; const currentWidth = typeof currentNode.width === "number" ? currentNode.width : typeof currentNode.style?.width === "number" ? currentNode.style.width : 300; const filename = `crop-${Date.now()}.png`; const nodeId = createPendingDerivedImageNode({ sourceNodeId: id, position: { x: currentPosition.x + currentWidth + 220, y: currentPosition.y, }, filename, customTitle: t("imageInput.crop"), operation: "crop", task: { cropRect: payload.cropRect, outputWidth: payload.outputWidth, outputHeight: payload.outputHeight, aspectRatio: payload.aspectRatio, }, addNode, onConnect, selectSingleNode, }); setIsCropping(false); setCropPayload(null); void regenerateNode(nodeId); }, [addNode, getNodeById, id, onConnect, regenerateNode, selectSingleNode, t]); const handleStartGridSelection = useCallback((selection: SplitGridSelection) => { setIsCropping(false); setCropPayload(null); setGridSelection(selection); setSelectedGridCellKeys(new Set()); }, []); 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(() => { setGridSelection(null); setSelectedGridCellKeys(new Set()); }, []); 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 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]); const selectedNodeCount = useSelectedNodeCount(); const editableImage = displayImage; const downloadImage = nodeData.outputImage || editableImage; const overlayImageDimensions = cropImageDimensions || asImageDimensions(nodeData.dimensions); const showSelectedActions = Boolean(selected && selectedNodeCount === 1 && editableImage); const renderedImageSrc = isCropping ? editableImage : outputImageSrc; const selectedActions: NodeActionCapsuleAction[] = editableImage ? [ { key: "crop", icon: ( ), label: t("imageInput.crop"), text: t("imageInput.crop"), section: "feature", onClick: (event) => { event.preventDefault(); event.stopPropagation(); setActiveMediaTool(MEDIA_EDIT_TOOL.crop); setCropAspectRatio("original"); setCropPayload(null); setIsCropping(true); }, }, { 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(); openInlinePanel(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 ( <> {showSelectedActions && !isCropping && !gridSelection && } {gridSelection && ( void handleConfirmSplitGrid()} /> )} {isCropping && ( setIsCropping(false)} context={{ value: cropAspectRatio, onChange: setCropAspectRatio, onConfirm: () => { if (cropPayload) void handleConfirmCrop(cropPayload); }, }} toolComponents={{ [MEDIA_EDIT_TOOL.crop]: CropRatioSelect, }} /> )}
{/* Preview area */} {displayImage ? ( <> Generated { setImageRetryNonce(0); const image = event.currentTarget; if (image.naturalWidth > 0 && image.naturalHeight > 0) { const dimensions = { width: image.naturalWidth, height: image.naturalHeight }; setCropImageDimensions(dimensions); updateMediaNodeData(id, { dimensions }, dimensions); } }} 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(); setIsPreviewOpen(true); }} className={getAutoMediaElementClassName(overlayImageDimensions, "cursor-zoom-in")} /> {isCropping && ( )} {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 && nodeData.outputImage && ( 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 };