|
|
|
@ -6,7 +6,7 @@ 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 { getActiveNodeTool, useNodeToolStore } from "@/store/nodeToolStore"; |
|
|
|
import { useGenerationPreferenceStore } from "@/store/generationPreferenceStore"; |
|
|
|
import { NanoBananaNodeData, SelectedModel } from "@/types"; |
|
|
|
import { ProviderModel } from "@/lib/providers/types"; |
|
|
|
@ -29,28 +29,15 @@ import { SINGLE_INPUT_HANDLE_ID, SINGLE_OUTPUT_HANDLE_ID } from "@/utils/nodeHan |
|
|
|
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 type { ImageEditTool } from "@/components/media/image-edit/types"; |
|
|
|
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") { |
|
|
|
@ -138,17 +125,7 @@ export function GenerateImageNodeView({ id, data, selected, renderInputHandle = |
|
|
|
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 [splitGridSelection, setSplitGridSelection] = useState<SplitGridSelection | null>(null); |
|
|
|
const [cropImageDimensions, setCropImageDimensions] = useState<{ width: number; height: number } | null>(null); |
|
|
|
const imageRef = useRef<HTMLImageElement>(null); |
|
|
|
|
|
|
|
@ -267,111 +244,21 @@ export function GenerateImageNodeView({ id, data, selected, renderInputHandle = |
|
|
|
}, 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); |
|
|
|
const openNodeTool = useCallback((tool: ImageEditTool) => { |
|
|
|
setSplitGridSelection(null); |
|
|
|
switchNodeTool(id, tool); |
|
|
|
}, [id, resetToolDraftState, switchNodeTool]); |
|
|
|
}, [id, switchNodeTool]); |
|
|
|
|
|
|
|
const closeNodeTool = useCallback(() => { |
|
|
|
resetToolDraftState(); |
|
|
|
setSplitGridSelection(null); |
|
|
|
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]); |
|
|
|
}, [closeNodeToolForNode, id]); |
|
|
|
|
|
|
|
const handleStartGridSelection = useCallback((selection: SplitGridSelection) => { |
|
|
|
resetToolDraftState(); |
|
|
|
setSplitGridSelection(null); |
|
|
|
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]); |
|
|
|
setSplitGridSelection(selection); |
|
|
|
}, [id, switchNodeTool]); |
|
|
|
|
|
|
|
const handleCreateHighDefinitionNode = useCallback(async () => { |
|
|
|
const currentNode = getNodeById(id); |
|
|
|
@ -398,64 +285,6 @@ export function GenerateImageNodeView({ id, data, selected, renderInputHandle = |
|
|
|
}); |
|
|
|
}, [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]); |
|
|
|
@ -586,12 +415,16 @@ export function GenerateImageNodeView({ id, data, selected, renderInputHandle = |
|
|
|
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 activeImageEditTool: ImageEditTool | null = |
|
|
|
activeNodeTool === "crop" || |
|
|
|
activeNodeTool === "outpainting" || |
|
|
|
activeNodeTool === "inpainting" || |
|
|
|
activeNodeTool === "splitGrid" |
|
|
|
? activeNodeTool |
|
|
|
: null; |
|
|
|
const isEditingImage = Boolean(activeImageEditTool); |
|
|
|
const isMultiAnglePanelOpen = activeNodeTool === "multiAngle"; |
|
|
|
const renderedImageSrc = isCropping || isOutpainting || isInpainting ? editableImage : outputImageSrc; |
|
|
|
const renderedImageSrc = isEditingImage ? editableImage : outputImageSrc; |
|
|
|
const selectedActions: NodeActionCapsuleAction[] = editableImage |
|
|
|
? [ |
|
|
|
createMediaEditActionDropdown({ |
|
|
|
@ -654,7 +487,7 @@ export function GenerateImageNodeView({ id, data, selected, renderInputHandle = |
|
|
|
section: "feature", |
|
|
|
panel: ({ close }) => ( |
|
|
|
<SplitGridCapsuleMenu |
|
|
|
busy={isSplitGridRunning} |
|
|
|
busy={activeImageEditTool === "splitGrid"} |
|
|
|
onSelect={(selection) => { |
|
|
|
close(); |
|
|
|
handleStartGridSelection(selection); |
|
|
|
@ -768,15 +601,6 @@ export function GenerateImageNodeView({ id, data, selected, renderInputHandle = |
|
|
|
|
|
|
|
{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" |
|
|
|
@ -820,31 +644,9 @@ export function GenerateImageNodeView({ id, data, selected, renderInputHandle = |
|
|
|
}} |
|
|
|
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 && ( |
|
|
|
{activeImageEditTool && editableImage && ( |
|
|
|
<ImageEditSession |
|
|
|
tool="inpainting" |
|
|
|
tool={activeImageEditTool} |
|
|
|
nodeId={id} |
|
|
|
imageRef={imageRef} |
|
|
|
imageSource={editableImage} |
|
|
|
@ -853,57 +655,8 @@ export function GenerateImageNodeView({ id, data, selected, renderInputHandle = |
|
|
|
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} |
|
|
|
splitGridSelection={splitGridSelection} |
|
|
|
onClose={closeNodeTool} |
|
|
|
/> |
|
|
|
)} |
|
|
|
{/* Loading overlay for generation */} |
|
|
|
|