You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 

793 lines
31 KiB

"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<NanoBananaNodeData, "nanoBanana">;
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<string | null>(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<MediaEditTool>(MEDIA_EDIT_TOOL.crop);
const [cropAspectRatio, setCropAspectRatio] = useState<MediaCropAspectRatio>("original");
const [cropPayload, setCropPayload] = useState<MediaCropConfirmPayload | null>(null);
const [isSplitGridRunning, setIsSplitGridRunning] = useState(false);
const [gridSelection, setGridSelection] = useState<SplitGridSelection | null>(null);
const [selectedGridCellKeys, setSelectedGridCellKeys] = useState<Set<string>>(() => new Set());
const [cropImageDimensions, setCropImageDimensions] = useState<{ width: number; height: number } | null>(null);
const imageRef = useRef<HTMLImageElement>(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: (
<svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.8}>
<path strokeLinecap="round" strokeLinejoin="round" d="M6 3v12a3 3 0 0 0 3 3h12" />
<path strokeLinecap="round" strokeLinejoin="round" d="M3 6h12a3 3 0 0 1 3 3v12" />
</svg>
),
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: (
<svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.8}>
<rect x="4" y="4" width="16" height="16" rx="2" />
<path strokeLinecap="round" d="M9.33 4v16M14.67 4v16M4 9.33h16M4 14.67h16" />
</svg>
),
label: t("imageInput.splitGrid"),
text: t("imageInput.splitGrid"),
section: "feature",
panel: ({ close }) => (
<SplitGridCapsuleMenu
busy={isSplitGridRunning}
onSelect={(selection) => {
close();
handleStartGridSelection(selection);
}}
/>
),
},
{
key: "multi-angle",
icon: (
<svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.8}>
<path strokeLinecap="round" strokeLinejoin="round" d="M4 12a8 8 0 0 1 16 0" />
<path strokeLinecap="round" strokeLinejoin="round" d="M8 12a4 4 0 0 1 8 0" />
<path strokeLinecap="round" strokeLinejoin="round" d="M12 12l5-5M12 12l-5 5" />
<circle cx="12" cy="12" r="1.5" />
</svg>
),
label: t("imageInput.multiAngle"),
text: t("imageInput.multiAngle"),
section: "feature",
onClick: (event) => {
event.preventDefault();
event.stopPropagation();
openInlinePanel(id, "multiAngle");
},
},
{
key: "save-to-assets",
icon: <CloudUploadOutlined className="text-[24px]" />,
label: t("assetLibrary.saveToLibrary"),
section: "basic",
onClick: (event) => {
event.preventDefault();
event.stopPropagation();
setIsSaveToAssetLibraryOpen(true);
},
},
{
key: "download",
icon: (
<svg className="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.8}>
<path strokeLinecap="round" strokeLinejoin="round" d="M12 4v11m0 0 4-4m-4 4-4-4M4 17v2h16v-2" />
</svg>
),
label: t("imageInput.download"),
section: "basic",
onClick: (event) => {
event.preventDefault();
event.stopPropagation();
if (downloadImage) {
downloadMedia(downloadImage, "image").catch(() => {});
}
},
},
{
key: "preview",
icon: (
<svg className="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.8}>
<path strokeLinecap="round" strokeLinejoin="round" d="M8 4H4v4M16 4h4v4M8 20H4v-4M20 16v4h-4" />
</svg>
),
label: t("imageInput.focusPreview"),
section: "basic",
onClick: (event) => {
event.preventDefault();
event.stopPropagation();
setIsPreviewOpen(true);
},
},
{
key: "clear",
icon: (
<svg className="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.8}>
<path strokeLinecap="round" strokeLinejoin="round" d="M6 7h12M9 7V5h6v2m-8 0 1 13h8l1-13" />
</svg>
),
label: t("imageInput.remove"),
section: "basic",
tone: "danger",
onClick: (event) => {
event.preventDefault();
event.stopPropagation();
void handleClearImage();
},
},
]
: [];
return (
<>
<BaseNode
id={id}
selected={selected}
isExecuting={isRunning}
hasError={nodeData.status === "error"}
fullBleed
autoSizeContent={Boolean(displayImage)}
dataTutorial="generate-image-node"
>
<NodeHandle
type="target"
position={Position.Left}
id={SINGLE_INPUT_HANDLE_ID}
style={{ top: "50%", zIndex: 10 }}
handleType="generic-input"
data-tutorial="generate-text-input-handle"
isConnectable={true}
/>
<NodeHandle
type="source"
position={Position.Right}
id={SINGLE_OUTPUT_HANDLE_ID}
style={{ top: "50%", zIndex: 10 }}
handleType="image"
/>
{showSelectedActions && !isCropping && !gridSelection && <NodeActionCapsule actions={selectedActions} />}
{gridSelection && (
<SplitGridSelectionToolbar
selectedCount={selectedGridCellKeys.size}
busy={isSplitGridRunning}
onCancel={handleCancelGridSelection}
onConfirm={() => void handleConfirmSplitGrid()}
/>
)}
{isCropping && (
<MediaEditToolbar
activeTool={activeMediaTool}
tools={IMAGE_MEDIA_EDIT_TOOLS}
onCancel={() => setIsCropping(false)}
context={{
value: cropAspectRatio,
onChange: setCropAspectRatio,
onConfirm: () => {
if (cropPayload) void handleConfirmCrop(cropPayload);
},
}}
toolComponents={{
[MEDIA_EDIT_TOOL.crop]: CropRatioSelect,
}}
/>
)}
<div
className={displayImage ? getAutoMediaFrameClassName() : "relative w-full h-full min-h-0 overflow-hidden rounded-lg"}
data-tutorial="generate-output-area"
>
{/* Preview area */}
{displayImage ? (
<>
<img
ref={imageRef}
key={`${displayImage ?? "empty"}-${imageRetryNonce}`}
src={renderedImageSrc ?? undefined}
alt="Generated"
onLoad={(event) => {
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 && (
<MediaCropOverlay
kind="image"
mediaElementRef={imageRef}
naturalWidth={overlayImageDimensions?.width}
naturalHeight={overlayImageDimensions?.height}
aspectRatio={cropAspectRatio}
onCropChange={setCropPayload}
/>
)}
{gridSelection && (
<SplitGridSelectionOverlay
mediaElementRef={imageRef}
naturalWidth={overlayImageDimensions?.width}
naturalHeight={overlayImageDimensions?.height}
rows={gridSelection.rows}
cols={gridSelection.cols}
selectedCellKeys={selectedGridCellKeys}
busy={isSplitGridRunning}
onToggleCell={handleToggleGridCell}
/>
)}
{/* Loading overlay for generation */}
{nodeData.status === "loading" && (
<div className="absolute inset-0 bg-neutral-900/70 flex items-center justify-center">
<svg
className="w-6 h-6 animate-spin text-white"
fill="none"
viewBox="0 0 24 24"
>
<circle
className="opacity-25"
cx="12"
cy="12"
r="10"
stroke="currentColor"
strokeWidth="3"
/>
<path
className="opacity-75"
fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
/>
</svg>
</div>
)}
{/* Error overlay when generation failed */}
{nodeData.status === "error" && (
<div className="absolute inset-0 bg-red-900/40 flex flex-col items-center justify-center gap-1">
<svg
className="w-6 h-6 text-white"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={2}
>
<path strokeLinecap="round" strokeLinejoin="round" d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
<span className="text-white text-xs font-medium">{t("node.generationFailed")}</span>
<span className="text-white/70 text-[10px]">See toast for details</span>
</div>
)}
{/* Loading overlay for carousel navigation */}
{isLoadingCarouselImage && (
<div className="absolute inset-0 bg-neutral-900/50 flex items-center justify-center">
<svg
className="w-4 h-4 animate-spin text-white"
fill="none"
viewBox="0 0 24 24"
>
<circle
className="opacity-25"
cx="12"
cy="12"
r="10"
stroke="currentColor"
strokeWidth="3"
/>
<path
className="opacity-75"
fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
/>
</svg>
</div>
)}
{/* Carousel controls - overlaid on image bottom */}
{hasCarouselImages && (
<div className="absolute bottom-0 left-0 right-0 flex items-center justify-center gap-2 py-1.5 bg-neutral-900/80">
<button
onClick={handleCarouselPrevious}
disabled={isLoadingCarouselImage}
className="w-5 h-5 rounded hover:bg-white/10 disabled:opacity-50 disabled:cursor-not-allowed flex items-center justify-center text-white/70 hover:text-white transition-colors"
title="Previous image"
>
<svg className="w-3 h-3" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M15 19l-7-7 7-7" />
</svg>
</button>
<span className="text-[10px] text-white/70 min-w-[32px] text-center">
{(nodeData.selectedHistoryIndex || 0) + 1} / {(nodeData.imageHistory || []).length}
</span>
<button
onClick={handleCarouselNext}
disabled={isLoadingCarouselImage}
className="w-5 h-5 rounded hover:bg-white/10 disabled:opacity-50 disabled:cursor-not-allowed flex items-center justify-center text-white/70 hover:text-white transition-colors"
title="Next image"
>
<svg className="w-3 h-3" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M9 5l7 7-7 7" />
</svg>
</button>
</div>
)}
</>
) : (
<div className="w-full h-full min-h-[112px] bg-[var(--surface-1)] flex flex-col items-center justify-center">
{nodeData.status === "loading" ? (
<svg
className="w-4 h-4 animate-spin text-[var(--text-muted)]"
fill="none"
viewBox="0 0 24 24"
>
<circle
className="opacity-25"
cx="12"
cy="12"
r="10"
stroke="currentColor"
strokeWidth="3"
/>
<path
className="opacity-75"
fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
/>
</svg>
) : nodeData.status === "error" ? (
<span className="text-[10px] text-red-400 text-center px-2">
{displayError || "Failed"}
</span>
) : (
<PictureOutlined className="text-4xl text-[var(--text-disabled)]" />
)}
</div>
)}
</div>
</BaseNode>
{isPreviewOpen && nodeData.outputImage && (
<MediaPreviewModal
type="image"
src={nodeData.outputImage}
downloadSrc={nodeData.outputImage}
alt="Generated preview"
onClose={() => setIsPreviewOpen(false)}
/>
)}
<SaveToAssetLibraryModal
open={isSaveToAssetLibraryOpen}
mediaUrl={nodeData.outputImage || null}
onClose={() => setIsSaveToAssetLibraryOpen(false)}
/>
{/* Model browse dialog */}
{isBrowseDialogOpen && (
<ModelSearchDialog
isOpen={isBrowseDialogOpen}
onClose={() => setIsBrowseDialogOpen(false)}
onModelSelected={handleBrowseModelSelect}
initialCapabilityFilter="image"
/>
)}
</>
);
}
export function GenerateImageNode({ id, data, selected }: NodeProps<NanoBananaNodeType>) {
return <GenerateImageNodeView id={id} data={data} selected={selected} />;
}
/**
* @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 };