diff --git a/src/components/media/image-edit/useImageDerivedNodeActions.ts b/src/components/media/image-edit/useImageDerivedNodeActions.ts index 5f1c1d50..ae001911 100644 --- a/src/components/media/image-edit/useImageDerivedNodeActions.ts +++ b/src/components/media/image-edit/useImageDerivedNodeActions.ts @@ -15,7 +15,8 @@ import { SINGLE_INPUT_HANDLE_ID, SINGLE_OUTPUT_HANDLE_ID } from "@/utils/nodeHan import { createPendingDerivedImageNode } from "@/utils/derivedImageNodes"; import { createSplitGridImageNodes } from "@/utils/splitGridNodes"; import { readHighDefinitionSourceImageDimensions } from "@/utils/highDefinitionNodes"; -import { OUTPAINTING_DEFAULT_PROMPT } from "@/utils/outpaintingNodes"; +import { deriveOutpaintingOutputDimensions, OUTPAINTING_DEFAULT_PROMPT } from "@/utils/outpaintingNodes"; +import { resolveMediaNodeCreationSizeFromDimensions } from "@/utils/nodeSizing"; import { toSelectedModel } from "@/utils/selectedModel"; interface UseImageDerivedNodeActionsOptions { @@ -79,10 +80,13 @@ export function useImageDerivedNodeActions({ const createCropNode = useCallback((payload: MediaCropConfirmPayload, customTitle: string) => { const placement = getSourcePlacement(); if (!placement) return null; + const dimensions = { width: payload.outputWidth, height: payload.outputHeight }; + const size = resolveMediaNodeCreationSizeFromDimensions(dimensions) ?? undefined; const nodeId = createPendingDerivedImageNode({ sourceNodeId, position: placement.position, filename: `crop-${Date.now()}.png`, + dimensions, customTitle, operation: "crop", task: { @@ -91,6 +95,7 @@ export function useImageDerivedNodeActions({ outputHeight: payload.outputHeight, aspectRatio: payload.aspectRatio, }, + size, addNode, onConnect, selectSingleNode, @@ -102,7 +107,9 @@ export function useImageDerivedNodeActions({ const createOutpaintingNode = useCallback(async ({ model, params, batchSize }: CreateOutpaintingNodeOptions) => { const placement = getSourcePlacement(); if (!placement) return null; - const dimensions = await readSourceDimensions(); + const sourceDimensions = await readSourceDimensions(); + const dimensions = deriveOutpaintingOutputDimensions(sourceDimensions, params) ?? sourceDimensions; + const size = resolveMediaNodeCreationSizeFromDimensions(dimensions) ?? undefined; const selectedModel = toSelectedModel(model); const nodeId = addNode("smartImage", placement.position, { inputImages: [imageSource], @@ -122,7 +129,7 @@ export function useImageDerivedNodeActions({ }, status: "idle", error: null, - } satisfies Partial); + } satisfies Partial, size); onConnect({ source: sourceNodeId, sourceHandle: SINGLE_OUTPUT_HANDLE_ID, diff --git a/src/components/nodes/GenerateImageNode.tsx b/src/components/nodes/GenerateImageNode.tsx index f391f3cc..8e2f96cd 100644 --- a/src/components/nodes/GenerateImageNode.tsx +++ b/src/components/nodes/GenerateImageNode.tsx @@ -43,7 +43,8 @@ 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 { OUTPAINTING_DEFAULT_PROMPT } from "@/utils/outpaintingNodes"; +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]; @@ -300,6 +301,8 @@ export function GenerateImageNodeView({ id, data, selected, renderInputHandle = ? 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: { @@ -307,6 +310,7 @@ export function GenerateImageNodeView({ id, data, selected, renderInputHandle = y: currentPosition.y, }, filename, + dimensions, customTitle: t("imageInput.crop"), operation: "crop", task: { @@ -315,6 +319,7 @@ export function GenerateImageNodeView({ id, data, selected, renderInputHandle = outputHeight: payload.outputHeight, aspectRatio: payload.aspectRatio, }, + size, addNode, onConnect, selectSingleNode, @@ -408,7 +413,9 @@ export function GenerateImageNodeView({ id, data, selected, renderInputHandle = : typeof currentNode.style?.width === "number" ? currentNode.style.width : 300; - const dimensions = await readHighDefinitionSourceImageDimensions(currentNode.data as { dimensions?: unknown }, imageSource); + 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, @@ -431,7 +438,7 @@ export function GenerateImageNodeView({ id, data, selected, renderInputHandle = }, status: "idle", error: null, - }); + }, size); onConnect({ source: id, sourceHandle: SINGLE_OUTPUT_HANDLE_ID, diff --git a/src/store/__tests__/workflowStore.integration.test.ts b/src/store/__tests__/workflowStore.integration.test.ts index f9ab0dda..19e112ce 100644 --- a/src/store/__tests__/workflowStore.integration.test.ts +++ b/src/store/__tests__/workflowStore.integration.test.ts @@ -665,6 +665,27 @@ describe("workflowStore integration tests", () => { }); describe("smart input nodes created while another task is running", () => { + it("preserves copied node dimensions when pasting", () => { + const originalNode = createTestNode( + "image-1", + "smartImage", + { image: "https://cdn.example.com/source.png", dimensions: { width: 1600, height: 900 } }, + { x: 120, y: 160 } + ); + originalNode.selected = true; + originalNode.style = { width: 615, height: 346 }; + + useWorkflowStore.setState({ nodes: [originalNode], edges: [] }); + + act(() => { + useWorkflowStore.getState().copySelectedNodes(); + useWorkflowStore.getState().pasteNodes({ x: 80, y: 40 }); + }); + + const pastedNode = useWorkflowStore.getState().nodes.find((node) => node.id !== originalNode.id); + expect(pastedNode?.style).toEqual({ width: 615, height: 346 }); + }); + it("uses custom node dimensions when adding a node", () => { const nodeId = useWorkflowStore.getState().addNode( "nanoBanana", diff --git a/src/store/workflowStore.ts b/src/store/workflowStore.ts index e9d37e24..4788fc27 100644 --- a/src/store/workflowStore.ts +++ b/src/store/workflowStore.ts @@ -1493,7 +1493,7 @@ const workflowStoreImpl: StateCreator = (set, get) => ({ const finalPosition = options?.skipCollision ? position - : findNearestFreePosition(position, type, state.nodes, { width, height }); + : findNearestFreePosition(position, state.nodes, { width, height }); const newNode: WorkflowNode = { id, @@ -1544,7 +1544,7 @@ const workflowStoreImpl: StateCreator = (set, get) => ({ ...node, position: getAbsoluteNodePosition(node, nodeById), })); - const finalPosition = findNearestFreePosition(position, type, collisionNodes, { width, height }); + const finalPosition = findNearestFreePosition(position, collisionNodes, { width, height }); const groupNodeId = getGroupNodeId(groupId); const newNode: WorkflowNode = { @@ -2312,7 +2312,6 @@ const workflowStoreImpl: StateCreator = (set, get) => ({ // Create new nodes with updated IDs and offset positions const newNodes: WorkflowNode[] = clipboard.nodes.map((node) => { - const defaults = defaultNodeDimensions[node.type as NodeType] || DEFAULT_NODE_DIMENSIONS; return { ...node, id: idMapping.get(node.id)!, @@ -2321,9 +2320,7 @@ const workflowStoreImpl: StateCreator = (set, get) => ({ y: node.position.y + offset.y, }, selected: true, // Select newly pasted nodes - // Reset height to defaults so BaseNode's ResizeObserver - // can correctly add settings panel height from the right baseline - style: { width: node.style?.width ?? defaults.width, height: defaults.height }, + style: clonePreservingStrings(node.style) as WorkflowNode["style"], data: clonePreservingStrings(node.data), }; }); diff --git a/src/utils/__tests__/nodeSizing.test.ts b/src/utils/__tests__/nodeSizing.test.ts index 67c1cb47..6bcc5ca7 100644 --- a/src/utils/__tests__/nodeSizing.test.ts +++ b/src/utils/__tests__/nodeSizing.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from "vitest"; -import { resolveInitialNodeSize, resolveNodeAutoSize } from "../nodeSizing"; +import { resolveInitialNodeSize, resolveMediaNodeCreationSizeFromDimensions, resolveNodeAutoSize } from "../nodeSizing"; describe("resolveNodeAutoSize", () => { it("uses real media dimensions before aspect ratio settings", () => { @@ -64,3 +64,9 @@ describe("resolveInitialNodeSize", () => { ).toEqual({ width: 346, height: 619 }); }); }); + +describe("resolveMediaNodeCreationSizeFromDimensions", () => { + it("sizes media nodes from dimensions using the shared short-side rule", () => { + expect(resolveMediaNodeCreationSizeFromDimensions({ width: 600, height: 300 })).toEqual({ width: 692, height: 346 }); + }); +}); diff --git a/src/utils/__tests__/outpaintingNodes.test.ts b/src/utils/__tests__/outpaintingNodes.test.ts new file mode 100644 index 00000000..97b03752 --- /dev/null +++ b/src/utils/__tests__/outpaintingNodes.test.ts @@ -0,0 +1,20 @@ +import { describe, expect, it } from "vitest"; + +import { deriveOutpaintingOutputDimensions } from "../outpaintingNodes"; + +describe("deriveOutpaintingOutputDimensions", () => { + it("expands source dimensions from outpainting insets", () => { + expect( + deriveOutpaintingOutputDimensions( + { width: 1000, height: 500 }, + { left: 0.2, right: 0.3, top: 0.1, bottom: 0.1 } + ) + ).toEqual({ width: 1500, height: 600 }); + }); + + it("returns null when source dimensions are unavailable", () => { + expect( + deriveOutpaintingOutputDimensions(null, { left: 0.2, right: 0.3, top: 0.1, bottom: 0.1 }) + ).toBeNull(); + }); +}); diff --git a/src/utils/__tests__/splitGridNodes.test.ts b/src/utils/__tests__/splitGridNodes.test.ts index 6037eece..50f8e1ad 100644 --- a/src/utils/__tests__/splitGridNodes.test.ts +++ b/src/utils/__tests__/splitGridNodes.test.ts @@ -13,7 +13,7 @@ describe("createSplitGridImageNodes", () => { type: "imageInput", position: { x: 100, y: 200 }, data: {}, - width: 300, + style: { width: 300, height: 280 }, } as WorkflowNode; const createdNodeIds = createSplitGridImageNodes({ @@ -47,7 +47,7 @@ describe("createSplitGridImageNodes", () => { }, }, }), - undefined, + { width: 300, height: 280 }, { skipCollision: true } ); expect(addNode).toHaveBeenCalledWith( @@ -70,7 +70,7 @@ describe("createSplitGridImageNodes", () => { }, }, }), - undefined, + { width: 300, height: 280 }, { skipCollision: true } ); expect(onConnect).toHaveBeenCalledWith({ @@ -96,7 +96,7 @@ describe("createSplitGridImageNodes", () => { type: "imageInput", position: { x: 100, y: 200 }, data: {}, - width: 300, + style: { width: 300, height: 280 }, } as WorkflowNode; createSplitGridImageNodes({ @@ -113,7 +113,7 @@ describe("createSplitGridImageNodes", () => { "smartImage", expect.objectContaining({ x: 620, y: 200 }), expect.objectContaining({ filename: "split-1-1.png", customTitle: "1-1" }), - undefined, + { width: 300, height: 280 }, { skipCollision: true } ); expect(addNode).toHaveBeenNthCalledWith( @@ -121,7 +121,7 @@ describe("createSplitGridImageNodes", () => { "smartImage", expect.objectContaining({ x: 620 + 324, y: 200 }), expect.objectContaining({ filename: "split-1-3.png", customTitle: "1-3" }), - undefined, + { width: 300, height: 280 }, { skipCollision: true } ); expect(addNode).toHaveBeenNthCalledWith( @@ -129,7 +129,7 @@ describe("createSplitGridImageNodes", () => { "smartImage", expect.objectContaining({ x: 620 + 648, y: 200 }), expect.objectContaining({ filename: "split-2-2.png", customTitle: "2-2" }), - undefined, + { width: 300, height: 280 }, { skipCollision: true } ); expect(addNode).toHaveBeenNthCalledWith( @@ -137,7 +137,45 @@ describe("createSplitGridImageNodes", () => { "smartImage", expect.objectContaining({ x: 620, y: 200 + 304 }), expect.objectContaining({ filename: "split-3-3.png", customTitle: "3-3" }), - undefined, + { width: 300, height: 280 }, + { skipCollision: true } + ); + }); + + it("uses source media dimensions to size and place split image nodes", () => { + const addNode = vi.fn((type: string, _position: unknown, _data: unknown) => `${type}-node-${addNode.mock.calls.length}`); + const onConnect = vi.fn(); + const sourceNode = { + id: "source-1", + type: "imageInput", + position: { x: 100, y: 200 }, + data: { dimensions: { width: 1800, height: 900 } }, + style: { width: 692, height: 346 }, + } as WorkflowNode; + + createSplitGridImageNodes({ + sourceNode, + rows: 3, + cols: 3, + selectedCellKeys: new Set(["1-1", "1-2"]), + addNode, + onConnect, + }); + + expect(addNode).toHaveBeenNthCalledWith( + 1, + "smartImage", + expect.objectContaining({ x: 1012, y: 200 }), + expect.objectContaining({ filename: "split-1-1.png" }), + { width: 692, height: 346 }, + { skipCollision: true } + ); + expect(addNode).toHaveBeenNthCalledWith( + 2, + "smartImage", + expect.objectContaining({ x: 1012 + 716, y: 200 }), + expect.objectContaining({ filename: "split-1-2.png" }), + { width: 692, height: 346 }, { skipCollision: true } ); }); diff --git a/src/utils/derivedImageNodes.ts b/src/utils/derivedImageNodes.ts index fada1ad8..1cda3f28 100644 --- a/src/utils/derivedImageNodes.ts +++ b/src/utils/derivedImageNodes.ts @@ -8,11 +8,16 @@ interface AddDerivedImageNodeOptions { skipCollision?: boolean; } +interface DerivedImageNodeSize { + width?: number; + height?: number; +} + type AddSmartImageNode = ( type: "smartImage", position: XYPosition, initialData?: Partial, - size?: undefined, + size?: DerivedImageNodeSize, options?: AddDerivedImageNodeOptions ) => string; @@ -34,9 +39,11 @@ export interface CreatePendingDerivedImageNodeOptions { sourceNodeId: string; position: XYPosition; filename: string; + dimensions?: { width: number; height: number } | null; customTitle: string; operation: DerivedImageOperation; task: DerivedImageTask; + size?: DerivedImageNodeSize; skipCollision?: boolean; addNode: AddSmartImageNode; onConnect: (connection: Connection) => void; @@ -85,9 +92,11 @@ export function createPendingDerivedImageNode({ sourceNodeId, position, filename, + dimensions = null, customTitle, operation, task, + size, skipCollision = false, addNode, onConnect, @@ -99,7 +108,7 @@ export function createPendingDerivedImageNode({ assetId: undefined, previewImage: undefined, filename, - dimensions: null, + dimensions, customTitle, derivedImage: { sourceNodeId, @@ -108,7 +117,7 @@ export function createPendingDerivedImageNode({ }, status: "loading", error: null, - }, undefined, { skipCollision }); + }, size, { skipCollision }); onConnect({ source: sourceNodeId, diff --git a/src/utils/nodeSizing.ts b/src/utils/nodeSizing.ts index b0d99efd..ee6765b2 100644 --- a/src/utils/nodeSizing.ts +++ b/src/utils/nodeSizing.ts @@ -82,6 +82,12 @@ function resolveMediaNodeSize(dimensions: MediaDimensions | null): NodeSize | nu return width && height ? calculateMediaNodeSizeByShortSide(width / height) : null; } +export function resolveMediaNodeCreationSizeFromDimensions( + dimensions: MediaDimensions | null | undefined +): NodeSize | null { + return resolveMediaNodeSize(dimensions ?? null); +} + export function resolveNodeSizeFromAspectRatio(value: unknown): { width: number; height: number } | null { return getGenerationNodeSizeForAspectRatio(value); } diff --git a/src/utils/outpaintingNodes.ts b/src/utils/outpaintingNodes.ts index 30097e35..baa19a55 100644 --- a/src/utils/outpaintingNodes.ts +++ b/src/utils/outpaintingNodes.ts @@ -1 +1,24 @@ export const OUTPAINTING_DEFAULT_PROMPT = "将画面自然延展,补充完整场景"; + +interface MediaDimensions { + width: number; + height: number; +} + +interface OutpaintingInsets { + top: number; + left: number; + right: number; + bottom: number; +} + +export function deriveOutpaintingOutputDimensions( + sourceDimensions: MediaDimensions | null | undefined, + params: OutpaintingInsets +): MediaDimensions | null { + if (!sourceDimensions) return null; + return { + width: Math.max(1, Math.round(sourceDimensions.width * (1 + params.left + params.right))), + height: Math.max(1, Math.round(sourceDimensions.height * (1 + params.top + params.bottom))), + }; +} diff --git a/src/utils/spatialLayout.ts b/src/utils/spatialLayout.ts index ce88f642..f284917c 100644 --- a/src/utils/spatialLayout.ts +++ b/src/utils/spatialLayout.ts @@ -1,6 +1,4 @@ import { Node } from "@xyflow/react"; -import { defaultNodeDimensions } from "@/store/utils/nodeDefaults"; -import { NodeType } from "@/types"; const COLLISION_GAP = 20; // Same as STACK_GAP in MultiSelectToolbar @@ -29,17 +27,10 @@ export function rectanglesIntersect(a: Rectangle, b: Rectangle): boolean { */ export function findNearestFreePosition( targetPos: { x: number; y: number }, - nodeType: NodeType, existingNodes: Node[], - dimensions?: Partial<{ width: number; height: number }> + dimensions: { width: number; height: number } ): { x: number; y: number } { - const defaults = defaultNodeDimensions[nodeType]; - const width = typeof dimensions?.width === "number" && Number.isFinite(dimensions.width) && dimensions.width > 0 - ? dimensions.width - : defaults.width; - const height = typeof dimensions?.height === "number" && Number.isFinite(dimensions.height) && dimensions.height > 0 - ? dimensions.height - : defaults.height; + const { width, height } = dimensions; // Try original position first if (!hasCollision(targetPos, width, height, existingNodes)) { @@ -90,12 +81,9 @@ function hasCollision( }; for (const node of existingNodes) { - const nodeWidth = - (node.style?.width as number) || - 300; - const nodeHeight = - (node.style?.height as number) || - 300; + const nodeWidth = node.style?.width as number | undefined; + const nodeHeight = node.style?.height as number | undefined; + if (typeof nodeWidth !== "number" || typeof nodeHeight !== "number") continue; const existingRect: Rectangle = { x: node.position.x, diff --git a/src/utils/splitGridNodes.ts b/src/utils/splitGridNodes.ts index 4d074539..b91c1728 100644 --- a/src/utils/splitGridNodes.ts +++ b/src/utils/splitGridNodes.ts @@ -1,8 +1,8 @@ import type { XYPosition } from "@xyflow/react"; import type { Connection } from "@xyflow/react"; import type { SmartImageNodeData, WorkflowNode } from "@/types"; -import { defaultNodeDimensions } from "@/constants/nodeDimensions"; import { createPendingDerivedImageNode } from "@/utils/derivedImageNodes"; +import { resolveMediaNodeCreationSizeFromDimensions } from "@/utils/nodeSizing"; interface SplitGridNodeCreationOptions { sourceNode: WorkflowNode; @@ -15,8 +15,7 @@ interface SplitGridNodeCreationOptions { } function getNodeWidth(node: WorkflowNode) { - if (typeof node.style?.width === "number") return node.style.width; - return defaultNodeDimensions.imageInput.width; + return node.style?.width as number; } function getSplitNodeStartPosition(sourceNode: WorkflowNode): XYPosition { @@ -26,6 +25,37 @@ function getSplitNodeStartPosition(sourceNode: WorkflowNode): XYPosition { }; } +function readNodeDimensions(node: WorkflowNode): { width: number; height: number } | null { + const dimensions = (node.data as { dimensions?: unknown }).dimensions; + if (!dimensions || typeof dimensions !== "object") return null; + const record = dimensions as { width?: unknown; height?: unknown }; + return typeof record.width === "number" && + typeof record.height === "number" && + Number.isFinite(record.width) && + Number.isFinite(record.height) && + record.width > 0 && + record.height > 0 + ? { width: record.width, height: record.height } + : null; +} + +function resolveSplitNodeDimensions(sourceNode: WorkflowNode, rows: number, cols: number): { width: number; height: number } | null { + const sourceDimensions = readNodeDimensions(sourceNode); + return sourceDimensions + ? { width: sourceDimensions.width / cols, height: sourceDimensions.height / rows } + : null; +} + +function resolveSplitNodeSize(sourceNode: WorkflowNode, splitDimensions: { width: number; height: number } | null): { width: number; height: number } { + const splitNodeSize = resolveMediaNodeCreationSizeFromDimensions(splitDimensions); + if (!splitNodeSize) { + const width = sourceNode.style?.width as number; + const height = sourceNode.style?.height as number; + return { width, height }; + } + return splitNodeSize; +} + export function createSplitGridImageNodes({ sourceNode, rows, @@ -43,8 +73,10 @@ export function createSplitGridImageNodes({ .filter((cell) => Number.isFinite(cell.row) && Number.isFinite(cell.col)) .sort((a, b) => a.row - b.row || a.col - b.col); const start = getSplitNodeStartPosition(sourceNode); - const nodeWidth = defaultNodeDimensions.imageInput.width; - const nodeHeight = defaultNodeDimensions.imageInput.height; + const splitDimensions = resolveSplitNodeDimensions(sourceNode, rows, cols); + const splitNodeSize = resolveSplitNodeSize(sourceNode, splitDimensions); + const nodeWidth = splitNodeSize.width; + const nodeHeight = splitNodeSize.height; const gap = 24; const columns = Math.max(1, cols); const createdNodeIds: string[] = []; @@ -59,6 +91,7 @@ export function createSplitGridImageNodes({ y: start.y + rowIndex * (nodeHeight + gap), }, filename: `split-${selectedCell.row}-${selectedCell.col}.png`, + dimensions: splitDimensions, customTitle: `${selectedCell.row}-${selectedCell.col}`, operation: "splitGrid", task: { @@ -68,6 +101,7 @@ export function createSplitGridImageNodes({ col: selectedCell.col, cellKey: selectedCell.key, }, + size: splitNodeSize, skipCollision: true, addNode, onConnect,