Browse Source

refactor(GenerateImageNode): 统一编辑工具走 ImageEditSession

删除内联的 crop/扩图/多宫格 overlay、草稿 state 与 confirm 逻辑
(handleConfirmCrop/handleConfirmOutpainting/handleConfirmSplitGrid 等),
改为像 ImageInputNode 一样渲染单个 ImageEditSession,复用
useImageDerivedNodeActions。保留 renderedImageSrc 的全分辨率切换
与胶囊入口。为后续把工具抽到画布级宿主铺路。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
feature/handleReconstruction
yun 2 days ago
parent
commit
50211e57d8
  1. 297
      src/components/nodes/GenerateImageNode.tsx

297
src/components/nodes/GenerateImageNode.tsx

@ -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 */}

Loading…
Cancel
Save