"use client"; import React, { useCallback, useState, useEffect, useMemo, useRef } from "react"; import { CloudUploadOutlined, PictureOutlined } from "@ant-design/icons"; import { Position, NodeProps, Node, useReactFlow } 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 { getImageDimensions, calculateMediaNodeSizeByShortSide } from "@/utils/nodeDimensions"; 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 { createCroppedImageBlob } from "@/components/media/imageCropUtils"; import { uploadBlobToGatewayMedia } from "@/utils/gatewayMediaUpload"; import { SplitGridCapsuleMenu, type SplitGridSelection } from "@/components/media/SplitGridCapsuleMenu"; import { createSplitGridImageNodes } from "@/utils/splitGridNodes"; import { createDerivedImageNode } from "@/utils/derivedImageNodes"; import { SplitGridSelectionOverlay } from "@/components/media/SplitGridSelectionOverlay"; import { SplitGridSelectionToolbar } from "@/components/media/SplitGridSelectionToolbar"; 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 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 [isCropUploading, setIsCropUploading] = useState(false); 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 { setNodes } = useReactFlow(); 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, 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 ?? null; 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, nodeData.imageHistory, nodeData.selectedHistoryIndex, updateNodeData]); const handleConfirmCrop = useCallback(async (payload: MediaCropConfirmPayload) => { const sourceImage = nodeData.outputImage || displayImage; if (!sourceImage) return; let blob: Blob | null = null; try { blob = await createCroppedImageBlob(sourceImage, payload); } catch { alert(t("imageInput.cropFailed")); return; } if (!blob) { alert(t("imageInput.cropFailed")); return; } setIsCropUploading(true); try { const filename = `crop-${Date.now()}.png`; const uploaded = await uploadBlobToGatewayMedia(blob, filename, "image/png", "image"); const currentNode = getNodeById(id); 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; createDerivedImageNode({ sourceNodeId: id, position: { x: currentPosition.x + currentWidth + 220, y: currentPosition.y, }, image: uploaded.url, previewImage: uploaded.previewUrl, filename, dimensions: { width: payload.outputWidth, height: payload.outputHeight }, label: t("imageInput.crop"), operation: "crop", addNode, onConnect, selectSingleNode, }); setIsCropping(false); } catch (error) { alert(error instanceof Error ? error.message : t("imageInput.cropFailed")); } finally { setIsCropUploading(false); } }, [addNode, displayImage, getNodeById, id, nodeData.outputImage, onConnect, 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) { updateNodeData(id, { outputImage: image, previewImg: imageItem.previewImg, selectedHistoryIndex: newIndex, status: "idle", error: null, }); } }, [id, nodeData.imageHistory, nodeData.selectedHistoryIndex, isLoadingCarouselImage, updateNodeData]); 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) { updateNodeData(id, { outputImage: image, previewImg: imageItem.previewImg, selectedHistoryIndex: newIndex, status: "idle", error: null, }); } }, [id, nodeData.imageHistory, nodeData.selectedHistoryIndex, isLoadingCarouselImage, updateNodeData]); // 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]); // Auto-resize node when output image changes const prevOutputImageRef = useRef(null); useEffect(() => { // Only resize when outputImage transitions from null/different to a new value if (!nodeData.outputImage || nodeData.outputImage === prevOutputImageRef.current) { prevOutputImageRef.current = nodeData.outputImage ?? null; return; } prevOutputImageRef.current = nodeData.outputImage; // Use requestAnimationFrame to avoid React Flow update conflicts requestAnimationFrame(() => { getImageDimensions(nodeData.outputImage!).then((dims) => { if (!dims) return; const aspectRatio = dims.width / dims.height; setNodes((nodes) => nodes.map((node) => { if (node.id !== id) return node; const newSize = calculateMediaNodeSizeByShortSide(aspectRatio); return { ...node, width: newSize.width, height: newSize.height, style: { ...node.style, width: newSize.width, height: newSize.height }, }; }) ); }); }); }, [id, nodeData.outputImage, setNodes]); const selectedNodeCount = useSelectedNodeCount(); const editableImage = displayImage; 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"), 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"), panel: ({ close }) => ( { close(); handleStartGridSelection(selection); }} /> ), }, { key: "multi-angle", icon: ( ), label: t("imageInput.multiAngle"), onClick: (event) => { event.preventDefault(); event.stopPropagation(); openInlinePanel(id, "multiAngle"); }, }, { key: "save-to-assets", icon: , label: t("assetLibrary.saveToLibrary"), onClick: (event) => { event.preventDefault(); event.stopPropagation(); setIsSaveToAssetLibraryOpen(true); }, }, { key: "download", icon: ( ), label: t("imageInput.download"), onClick: (event) => { event.preventDefault(); event.stopPropagation(); downloadMedia(editableImage, "image").catch(() => {}); }, }, { key: "preview", icon: ( ), label: t("imageInput.focusPreview"), onClick: (event) => { event.preventDefault(); event.stopPropagation(); setIsPreviewOpen(true); }, }, { key: "clear", icon: ( ), label: t("imageInput.remove"), 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) { setCropImageDimensions({ width: image.naturalWidth, height: image.naturalHeight }); } }} 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="w-full h-full object-contain 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 };