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.
828 lines
32 KiB
828 lines
32 KiB
"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<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 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 [isCropUploading, setIsCropUploading] = useState(false);
|
|
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 { 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<string | null>(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: (
|
|
<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 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"),
|
|
onClick: (event) => {
|
|
event.preventDefault();
|
|
event.stopPropagation();
|
|
setActiveMediaTool(MEDIA_EDIT_TOOL.crop);
|
|
setCropAspectRatio("original");
|
|
setCropPayload(null);
|
|
setIsCropping(true);
|
|
},
|
|
},
|
|
{
|
|
key: "split-grid",
|
|
icon: (
|
|
<svg className="h-6 w-6" 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"),
|
|
panel: ({ close }) => (
|
|
<SplitGridCapsuleMenu
|
|
busy={isSplitGridRunning}
|
|
onSelect={(selection) => {
|
|
close();
|
|
handleStartGridSelection(selection);
|
|
}}
|
|
/>
|
|
),
|
|
},
|
|
{
|
|
key: "multi-angle",
|
|
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="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"),
|
|
onClick: (event) => {
|
|
event.preventDefault();
|
|
event.stopPropagation();
|
|
openInlinePanel(id, "multiAngle");
|
|
},
|
|
},
|
|
{
|
|
key: "save-to-assets",
|
|
icon: <CloudUploadOutlined className="text-[24px]" />,
|
|
label: t("assetLibrary.saveToLibrary"),
|
|
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"),
|
|
onClick: (event) => {
|
|
event.preventDefault();
|
|
event.stopPropagation();
|
|
downloadMedia(editableImage, "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"),
|
|
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"),
|
|
tone: "danger",
|
|
onClick: (event) => {
|
|
event.preventDefault();
|
|
event.stopPropagation();
|
|
void handleClearImage();
|
|
},
|
|
},
|
|
]
|
|
: [];
|
|
|
|
return (
|
|
<>
|
|
<BaseNode
|
|
id={id}
|
|
selected={selected}
|
|
isExecuting={isRunning}
|
|
hasError={nodeData.status === "error"}
|
|
fullBleed
|
|
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}
|
|
busy={isCropUploading}
|
|
onCancel={() => setIsCropping(false)}
|
|
context={{
|
|
value: cropAspectRatio,
|
|
onChange: setCropAspectRatio,
|
|
onConfirm: () => {
|
|
if (cropPayload) void handleConfirmCrop(cropPayload);
|
|
},
|
|
}}
|
|
toolComponents={{
|
|
[MEDIA_EDIT_TOOL.crop]: CropRatioSelect,
|
|
}}
|
|
/>
|
|
)}
|
|
|
|
<div
|
|
className="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) {
|
|
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 && (
|
|
<MediaCropOverlay
|
|
kind="image"
|
|
mediaElementRef={imageRef}
|
|
naturalWidth={overlayImageDimensions?.width}
|
|
naturalHeight={overlayImageDimensions?.height}
|
|
aspectRatio={cropAspectRatio}
|
|
busy={isCropUploading}
|
|
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 };
|
|
|