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.
 
 

1073 lines
43 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 { 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 (
<svg className={className} fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.8}>
<rect x="4" y="5" width="16" height="14" rx="2" />
<path strokeLinecap="round" strokeLinejoin="round" d="M8 15V9m0 3h4m4-3v6M12 9v6" />
</svg>
);
}
if (tool === "crop") {
return (
<svg className={className} 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>
);
}
if (tool === "inpainting") {
return (
<svg className={className} fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.8}>
<path strokeLinecap="round" strokeLinejoin="round" d="m4 16 8.5-8.5a3 3 0 0 1 4.25 0l1.75 1.75a3 3 0 0 1 0 4.25L10 22H4v-6Z" />
<path strokeLinecap="round" strokeLinejoin="round" d="m9 11 4 4M13 22h7" />
</svg>
);
}
return (
<svg className={className} fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.8}>
<rect x="7" y="7" width="10" height="10" rx="1.5" />
<path strokeLinecap="round" strokeLinejoin="round" d="M4 10V4h6M14 4h6v6M20 14v6h-6M10 20H4v-6" />
</svg>
);
}
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;
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<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 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<MediaEditMenuTool>("crop");
const [cropAspectRatio, setCropAspectRatio] = useState<MediaCropAspectRatio>("original");
const [cropPayload, setCropPayload] = useState<MediaCropConfirmPayload | null>(null);
const [cropFrameRect, setCropFrameRect] = useState<MediaEditFrameRect | null>(null);
const [cropImageBoundsRect, setCropImageBoundsRect] = useState<MediaEditFrameRect | null>(null);
const [outpaintingParams, setOutpaintingParams] = useState<MediaOutpaintingParams | null>(null);
const [outpaintingFrameRect, setOutpaintingFrameRect] = useState<MediaOutpaintingFrameRect | null>(null);
const [outpaintingImageBoundsRect, setOutpaintingImageBoundsRect] = useState<MediaOutpaintingFrameRect | null>(null);
const [outpaintingBatchSize, setOutpaintingBatchSize] = useState<OutpaintingBatchSize>(1);
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, {
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<HTMLButtonElement>) => {
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: <GenerateImageEditMenuIcon tool={activeEditMenuTool} />,
onClick: handleEditMenuPrimaryClick,
items: [
{
key: "high-definition",
icon: <GenerateImageEditMenuIcon tool="high-definition" className="h-4 w-4" />,
label: t("imageInput.highDefinition"),
},
{
key: "outpainting",
icon: <GenerateImageEditMenuIcon tool="outpainting" className="h-4 w-4" />,
label: "扩图",
disabled: !outpaintingModel,
},
{
key: "inpainting",
icon: <GenerateImageEditMenuIcon tool="inpainting" className="h-4 w-4" />,
label: "擦除",
disabled: !inpaintingModel,
},
{
key: "crop",
icon: <GenerateImageEditMenuIcon tool="crop" className="h-4 w-4" />,
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: <GridToolIcon />,
label: t("imageInput.splitGrid"),
text: t("imageInput.splitGrid"),
section: "feature",
panel: ({ close }) => (
<SplitGridCapsuleMenu
busy={isSplitGridRunning}
onSelect={(selection) => {
close();
handleStartGridSelection(selection);
}}
/>
),
},
{
key: "multi-angle",
icon: <MultiAngleToolIcon />,
label: t("imageInput.multiAngle"),
text: t("imageInput.multiAngle"),
section: "feature",
onClick: (event) => {
event.preventDefault();
event.stopPropagation();
switchNodeTool(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"
>
{renderInputHandle && (
<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 && !activeNodeTool && <NodeActionCapsule actions={selectedActions} />}
{isSplitGrid && gridSelection && (
<SplitGridSelectionToolbar
selectedCount={selectedGridCellKeys.size}
busy={isSplitGridRunning}
onCancel={handleCancelGridSelection}
onConfirm={() => void handleConfirmSplitGrid()}
/>
)}
<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 ((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 && (
<MediaCropOverlay
kind="image"
mediaElementRef={imageRef}
naturalWidth={overlayImageDimensions?.width}
naturalHeight={overlayImageDimensions?.height}
aspectRatio={cropAspectRatio}
onCropChange={setCropPayload}
onFrameChange={setCropFrameRect}
onImageBoundsChange={setCropImageBoundsRect}
/>
)}
{isOutpainting && (
<MediaOutpaintingOverlay
mediaElementRef={imageRef}
naturalWidth={overlayImageDimensions?.width}
naturalHeight={overlayImageDimensions?.height}
onChange={setOutpaintingParams}
onFrameChange={setOutpaintingFrameRect}
onImageBoundsChange={setOutpaintingImageBoundsRect}
/>
)}
{isInpainting && editableImage && (
<ImageEditSession
tool="inpainting"
nodeId={id}
imageRef={imageRef}
imageSource={editableImage}
dimensions={overlayImageDimensions}
models={{
outpainting: outpaintingModel,
inpainting: inpaintingModel,
}}
onClose={() => {
closeNodeTool();
}}
/>
)}
{isCropping && (
<MediaEditToolbar
activeTool={MEDIA_EDIT_TOOL.crop}
tools={IMAGE_MEDIA_EDIT_TOOLS}
frameRect={cropFrameRect}
imageBoundsRect={cropImageBoundsRect}
onCancel={() => {
closeNodeTool();
}}
context={{
value: cropAspectRatio,
onChange: setCropAspectRatio,
onConfirm: () => {
if (cropPayload) void handleConfirmCrop(cropPayload);
},
}}
toolComponents={{
[MEDIA_EDIT_TOOL.crop]: CropRatioSelect,
}}
/>
)}
{isOutpainting && (
<MediaOutpaintingToolbar
value={outpaintingParams}
batchSize={outpaintingBatchSize}
frameRect={outpaintingFrameRect}
imageBoundsRect={outpaintingImageBoundsRect}
onBatchSizeChange={setOutpaintingBatchSize}
onCancel={() => {
closeNodeTool();
}}
onConfirm={() => {
if (outpaintingParams) void handleConfirmOutpainting(outpaintingParams);
}}
/>
)}
{isSplitGrid && 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 && editableImage && (
<MediaPreviewModal
type="image"
src={editableImage}
downloadSrc={editableImage}
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 };