Browse Source

节点尺寸计算

feature/0710
yun 3 days ago
parent
commit
71b40c01b5
  1. 13
      src/components/media/image-edit/useImageDerivedNodeActions.ts
  2. 13
      src/components/nodes/GenerateImageNode.tsx
  3. 21
      src/store/__tests__/workflowStore.integration.test.ts
  4. 9
      src/store/workflowStore.ts
  5. 8
      src/utils/__tests__/nodeSizing.test.ts
  6. 20
      src/utils/__tests__/outpaintingNodes.test.ts
  7. 54
      src/utils/__tests__/splitGridNodes.test.ts
  8. 15
      src/utils/derivedImageNodes.ts
  9. 6
      src/utils/nodeSizing.ts
  10. 23
      src/utils/outpaintingNodes.ts
  11. 22
      src/utils/spatialLayout.ts
  12. 44
      src/utils/splitGridNodes.ts

13
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<SmartImageNodeData>);
} satisfies Partial<SmartImageNodeData>, size);
onConnect({
source: sourceNodeId,
sourceHandle: SINGLE_OUTPUT_HANDLE_ID,

13
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,

21
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",

9
src/store/workflowStore.ts

@ -1493,7 +1493,7 @@ const workflowStoreImpl: StateCreator<WorkflowStore> = (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<WorkflowStore> = (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<WorkflowStore> = (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<WorkflowStore> = (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),
};
});

8
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 });
});
});

20
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();
});
});

54
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 }
);
});

15
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<SmartImageNodeData>,
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,

6
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);
}

23
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))),
};
}

22
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,

44
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,

Loading…
Cancel
Save