diff --git a/web/src/lib/canvas/canvas-generation-layout.ts b/web/src/lib/canvas/canvas-generation-layout.ts new file mode 100644 index 000000000..75ad47b13 --- /dev/null +++ b/web/src/lib/canvas/canvas-generation-layout.ts @@ -0,0 +1,60 @@ +import { CanvasNodeType, type CanvasNodeData, type Position } from "@/types/canvas"; + +type Size = { + width: number; + height: number; +}; + +const BATCH_CHILD_OFFSET_X = 120; +const BATCH_CHILD_GAP = 36; +const NODE_CLEARANCE = 36; + +export function canGenerateImageInPlace(sourceNode: CanvasNodeData | undefined) { + return sourceNode?.type === CanvasNodeType.Image && !sourceNode.metadata?.content; +} + +export function imageGenerationGroupSize(rootSize: Size, imageSize: Size, childCount: number): Size { + if (childCount <= 0) return rootSize; + const columns = Math.min(childCount, 2); + const rows = Math.ceil(childCount / 2); + return { + width: rootSize.width + BATCH_CHILD_OFFSET_X + columns * imageSize.width + (columns - 1) * BATCH_CHILD_GAP, + height: Math.max(rootSize.height, rows * imageSize.height + (rows - 1) * BATCH_CHILD_GAP), + }; +} + +export function imageGenerationChildPosition(rootPosition: Position, rootWidth: number, imageSize: Size, index: number): Position { + return { + x: rootPosition.x + rootWidth + BATCH_CHILD_OFFSET_X + (index % 2) * (imageSize.width + BATCH_CHILD_GAP), + y: rootPosition.y + Math.floor(index / 2) * (imageSize.height + BATCH_CHILD_GAP), + }; +} + +export function findAvailableGenerationGroupPosition(nodes: CanvasNodeData[], preferred: Position, groupSize: Size): Position { + const down = resolveCollisions(nodes, preferred, groupSize, "down"); + const right = resolveCollisions(nodes, preferred, groupSize, "right"); + const downDistance = down.y - preferred.y; + const rightDistance = right.x - preferred.x; + return downDistance <= rightDistance ? down : right; +} + +// 分别沿下方和右侧求出最近空位,再选择移动距离更短的方向,避免生成组覆盖已有节点。 +function resolveCollisions(nodes: CanvasNodeData[], preferred: Position, groupSize: Size, direction: "down" | "right"): Position { + const candidate = { ...preferred }; + for (let attempt = 0; attempt <= nodes.length; attempt += 1) { + const collisions = nodes.filter((node) => rectanglesOverlap(candidate, groupSize, node.position, node)); + if (!collisions.length) return candidate; + if (direction === "down") candidate.y = Math.max(...collisions.map((node) => node.position.y + node.height + NODE_CLEARANCE)); + else candidate.x = Math.max(...collisions.map((node) => node.position.x + node.width + NODE_CLEARANCE)); + } + return candidate; +} + +function rectanglesOverlap(firstPosition: Position, firstSize: Size, secondPosition: Position, secondSize: Size) { + return !( + firstPosition.x + firstSize.width + NODE_CLEARANCE <= secondPosition.x || + firstPosition.x >= secondPosition.x + secondSize.width + NODE_CLEARANCE || + firstPosition.y + firstSize.height + NODE_CLEARANCE <= secondPosition.y || + firstPosition.y >= secondPosition.y + secondSize.height + NODE_CLEARANCE + ); +} diff --git a/web/src/pages/canvas/canvas-generation-executor-types.ts b/web/src/pages/canvas/canvas-generation-executor-types.ts index ec361e15e..a70d31b62 100644 --- a/web/src/pages/canvas/canvas-generation-executor-types.ts +++ b/web/src/pages/canvas/canvas-generation-executor-types.ts @@ -21,6 +21,7 @@ export type CanvasGenerationExecutorDependencies = { export type CanvasGenerationExecution = CanvasGenerationExecutorDependencies & { nodeId: string; sourceNode: CanvasNodeData | undefined; + canvasNodes: CanvasNodeData[]; prompt: string; effectivePrompt: string; generationConfig: AiConfig; diff --git a/web/src/pages/canvas/canvas-image-generation-executor.ts b/web/src/pages/canvas/canvas-image-generation-executor.ts index 2a5e16466..9836185c1 100644 --- a/web/src/pages/canvas/canvas-image-generation-executor.ts +++ b/web/src/pages/canvas/canvas-image-generation-executor.ts @@ -1,9 +1,9 @@ import { nanoid } from "nanoid"; import { NODE_DEFAULT_SIZE } from "@/constant/canvas"; +import { canGenerateImageInPlace, findAvailableGenerationGroupPosition, imageGenerationChildPosition, imageGenerationGroupSize } from "@/lib/canvas/canvas-generation-layout"; import { imageMetadata } from "@/lib/canvas/canvas-generation-task-sync"; import { fitNodeSize, nodeSizeFromRatio } from "@/lib/canvas/canvas-node-size"; -import { prepareInPlaceMediaVersion } from "@/lib/canvas/canvas-media-versions"; import { buildImageGenerationMetadata, getGenerationCount, isGenerationCanceled, runBackendCanvasGenerationTask } from "@/lib/canvas/canvas-project-generation"; import { CONTENT_MODERATION_ERROR_CODE, generationFailureMetadata, type GenerationFailureMetadata } from "@/lib/generation-error"; import { uploadImage } from "@/services/image-storage"; @@ -19,6 +19,7 @@ const NODE_STATUS_IDLE = "idle" as const; export async function executeImageGeneration({ nodeId, sourceNode, + canvasNodes, prompt, effectivePrompt, generationConfig, @@ -39,8 +40,8 @@ export async function executeImageGeneration({ const count = getGenerationCount(generationConfig.count); const isConfigNode = sourceNode?.type === CanvasNodeType.Config; const isImageNode = sourceNode?.type === CanvasNodeType.Image; - const isEmptyImageNode = isImageNode && !sourceNode?.metadata?.content; - // 当前图片节点的直接生成是原位重生成;参考图只来自入边,避免把旧结果误当成自身输入。 + const reuseSourceNode = canGenerateImageInPlace(sourceNode); + // 已有图片生成新结果并保留旧版本;参考图只来自入边,避免把旧结果误当成自身输入。 const referenceImages = generationContext.referenceImages; const generationType = referenceImages.length ? ("edit" as const) : ("generation" as const); const generationMetadata = buildImageGenerationMetadata(generationType, generationConfig, count, referenceImages); @@ -49,24 +50,23 @@ export async function executeImageGeneration({ // 生成中占位框按设置比例显示,避免 16:9 任务显示成默认 340x240。 const imageConfig = nodeSizeFromRatio(generationConfig.size || "auto", imageDefaults.width, imageDefaults.height) || imageDefaults; const parentPosition = sourceNode?.position || { x: 0, y: 0 }; - const rootId = isImageNode ? nodeId : nanoid(); + const rootId = reuseSourceNode ? nodeId : nanoid(); const childIds = count > 1 ? Array.from({ length: count }, () => nanoid()) : []; const targetIds = count > 1 ? childIds : [rootId]; - registerPendingNodeIds(isEmptyImageNode ? childIds : [rootId, ...childIds]); - // 空节点可直接按目标比例改尺寸;已有内容的原位重生保留原尺寸,避免布局跳动。 - const rootWidth = isImageNode && !isEmptyImageNode ? sourceNode?.width || imageConfig.width : imageConfig.width; - const rootHeight = isImageNode && !isEmptyImageNode ? sourceNode?.height || imageConfig.height : imageConfig.height; + registerPendingNodeIds(reuseSourceNode ? childIds : [rootId, ...childIds]); + const rootWidth = imageConfig.width; + const rootHeight = imageConfig.height; + const preferredPosition = { + x: parentPosition.x + parentConfig.width + 96, + y: parentPosition.y + parentConfig.height / 2 - rootHeight / 2, + }; + const rootPosition = reuseSourceNode ? parentPosition : findAvailableGenerationGroupPosition(canvasNodes, preferredPosition, imageGenerationGroupSize({ width: rootWidth, height: rootHeight }, imageConfig, childIds.length)); const rootNode: CanvasNodeData = { id: rootId, type: CanvasNodeType.Image, title: effectivePrompt.slice(0, 32) || "Generated Image", - position: isImageNode - ? parentPosition - : { - x: parentPosition.x + parentConfig.width + 96, - y: parentPosition.y + parentConfig.height / 2 - rootHeight / 2, - }, + position: rootPosition, width: rootWidth, height: rootHeight, metadata: { @@ -87,29 +87,32 @@ export async function executeImageGeneration({ id, type: CanvasNodeType.Image, title: effectivePrompt.slice(0, 32) || "Generated Image", - position: { - x: rootNode.position.x + rootNode.width + 120 + (index % 2) * (imageConfig.width + 36), - y: rootNode.position.y + Math.floor(index / 2) * (imageConfig.height + 36), - }, + position: imageGenerationChildPosition(rootNode.position, rootNode.width, imageConfig, index), width: imageConfig.width, height: imageConfig.height, metadata: { prompt: effectivePrompt, status: NODE_STATUS_LOADING, size: generationConfig.size, batchRootId: count > 1 ? rootId : undefined, ...generationMetadata, generationErrorCode: undefined, failedPromptFingerprint: undefined }, })); - const batchConnections = [...(isImageNode ? [] : [{ id: nanoid(), fromNodeId: nodeId, toNodeId: rootId }]), ...childIds.map((childId) => ({ id: nanoid(), fromNodeId: rootId, toNodeId: childId }))]; + const batchConnections = [...(reuseSourceNode ? [] : [{ id: nanoid(), fromNodeId: nodeId, toNodeId: rootId }]), ...childIds.map((childId) => ({ id: nanoid(), fromNodeId: rootId, toNodeId: childId }))]; setNodes((current) => { - const versioned = isImageNode && !isEmptyImageNode ? prepareInPlaceMediaVersion(current, nodeId) : current; return [ - ...versioned.map((node) => { - if (node.id !== nodeId) return node; - if (isConfigNode) return { ...node, metadata: { ...node.metadata, prompt: effectivePrompt, status: NODE_STATUS_LOADING, errorDetails: undefined } }; - if (isEmptyImageNode) return { ...node, position: rootNode.position, width: rootNode.width, height: rootNode.height, title: rootNode.title, metadata: { ...node.metadata, ...rootNode.metadata, errorDetails: undefined } }; - if (isImageNode) return { ...node, title: rootNode.title, metadata: { ...node.metadata, ...rootNode.metadata, errorDetails: undefined } }; - return { ...node, type: CanvasNodeType.Text, title: prompt.slice(0, 32) || "Prompt", width: parentConfig.width, height: parentConfig.height, metadata: { ...node.metadata, content: prompt, richText: undefined, prompt, status: NODE_STATUS_SUCCESS, fontSize: 14, errorDetails: undefined } }; - }), - ...(isImageNode ? [] : [rootNode]), - ...childNodes, - ]; + ...current.map((node) => { + if (node.id !== nodeId) return node; + if (isConfigNode) return { ...node, metadata: { ...node.metadata, prompt: effectivePrompt, status: NODE_STATUS_LOADING, errorDetails: undefined } }; + if (reuseSourceNode) return { ...node, position: rootNode.position, width: rootNode.width, height: rootNode.height, title: rootNode.title, metadata: { ...node.metadata, ...rootNode.metadata, errorDetails: undefined } }; + if (isImageNode) return node; + return { + ...node, + type: CanvasNodeType.Text, + title: prompt.slice(0, 32) || "Prompt", + width: parentConfig.width, + height: parentConfig.height, + metadata: { ...node.metadata, content: prompt, richText: undefined, prompt, status: NODE_STATUS_SUCCESS, fontSize: 14, errorDetails: undefined }, + }; + }), + ...(reuseSourceNode ? [] : [rootNode]), + ...childNodes, + ]; }); setConnections((current) => [...current, ...batchConnections]); setSelectedNodeIds(new Set([nodeId])); @@ -124,7 +127,17 @@ export async function executeImageGeneration({ await Promise.all( targetIds.map(async (targetId) => { try { - const result = await runBackendCanvasGenerationTask({ projectId, nodeId: targetId, mode: "image", prompt: effectivePrompt, config: { ...generationConfig, count: "1" }, referenceImages, signal: controller.signal, metadata: { sourceNodeId: nodeId, resolvedCharacterVersions: generationContext.resolvedCharacterVersions }, onTaskCreated: (task) => bindGenerationTask(targetId, task) }); + const result = await runBackendCanvasGenerationTask({ + projectId, + nodeId: targetId, + mode: "image", + prompt: effectivePrompt, + config: { ...generationConfig, count: "1" }, + referenceImages, + signal: controller.signal, + metadata: { sourceNodeId: nodeId, resolvedCharacterVersions: generationContext.resolvedCharacterVersions }, + onTaskCreated: (task) => bindGenerationTask(targetId, task), + }); const image = result.images?.[0]; if (!image?.dataUrl) throw new Error("后端任务没有返回图片"); const uploaded = await uploadImage(image.dataUrl); @@ -163,8 +176,15 @@ export async function executeImageGeneration({ if (hasFailure) showError(hasSuccess ? "部分图片生成失败" : "全部图片生成失败"); setNodes((current) => current.map((node) => - node.id === nodeId && (isConfigNode || isEmptyImageNode) - ? { ...node, metadata: { ...node.metadata, status: hasSuccess ? NODE_STATUS_SUCCESS : NODE_STATUS_ERROR, ...(hasSuccess ? { errorDetails: undefined, generationErrorCode: undefined, failedPromptFingerprint: undefined } : representativeFailure || { errorDetails: "全部图片生成失败" }) } } + node.id === nodeId && (isConfigNode || reuseSourceNode) + ? { + ...node, + metadata: { + ...node.metadata, + status: hasSuccess ? NODE_STATUS_SUCCESS : NODE_STATUS_ERROR, + ...(hasSuccess ? { errorDetails: undefined, generationErrorCode: undefined, failedPromptFingerprint: undefined } : representativeFailure || { errorDetails: "全部图片生成失败" }), + }, + } : node.id === rootId && !hasSuccess ? { ...node, metadata: { ...node.metadata, status: NODE_STATUS_ERROR, ...(representativeFailure || { errorDetails: "全部图片生成失败" }) } } : node, diff --git a/web/src/pages/canvas/use-canvas-generation-executor.ts b/web/src/pages/canvas/use-canvas-generation-executor.ts index ee6104b2b..df7fc653d 100644 --- a/web/src/pages/canvas/use-canvas-generation-executor.ts +++ b/web/src/pages/canvas/use-canvas-generation-executor.ts @@ -168,6 +168,7 @@ export function useCanvasGenerationExecutor({ projectId, nodeId, sourceNode, + canvasNodes: nodesRef.current, prompt, effectivePrompt, generationConfig, diff --git a/web/test/canvas-generation-layout.test.ts b/web/test/canvas-generation-layout.test.ts new file mode 100644 index 000000000..2672397f5 --- /dev/null +++ b/web/test/canvas-generation-layout.test.ts @@ -0,0 +1,36 @@ +import { describe, expect, test } from "bun:test"; + +import { canGenerateImageInPlace, findAvailableGenerationGroupPosition, imageGenerationGroupSize } from "../src/lib/canvas/canvas-generation-layout"; +import { CanvasNodeType, type CanvasNodeData } from "../src/types/canvas"; + +function node(id: string, x: number, y: number, width = 340, height = 240): CanvasNodeData { + return { id, type: CanvasNodeType.Image, title: id, position: { x, y }, width, height }; +} + +describe("findAvailableGenerationGroupPosition", () => { + test("首选位置没有占用时保持原坐标", () => { + expect(findAvailableGenerationGroupPosition([node("source", 0, 0)], { x: 436, y: 0 }, { width: 340, height: 240 })).toEqual({ x: 436, y: 0 }); + }); + + test("右侧已有节点时选择距离更短的下方空位", () => { + expect(findAvailableGenerationGroupPosition([node("occupied", 436, 0)], { x: 436, y: 0 }, { width: 340, height: 240 })).toEqual({ x: 436, y: 276 }); + }); + + test("纵向大节点遮挡时改为向右避让", () => { + expect(findAvailableGenerationGroupPosition([node("occupied", 436, 0, 340, 900)], { x: 436, y: 0 }, { width: 340, height: 240 })).toEqual({ x: 812, y: 0 }); + }); + + test("按完整批次范围检测碰撞", () => { + const groupSize = imageGenerationGroupSize({ width: 340, height: 240 }, { width: 340, height: 240 }, 4); + expect(groupSize).toEqual({ width: 1176, height: 516 }); + expect(findAvailableGenerationGroupPosition([node("child-area", 900, 0)], { x: 436, y: 0 }, groupSize)).toEqual({ x: 436, y: 276 }); + }); +}); + +describe("canGenerateImageInPlace", () => { + test("只有空图片节点复用原节点", () => { + expect(canGenerateImageInPlace(node("empty", 0, 0))).toBe(true); + expect(canGenerateImageInPlace({ ...node("result", 0, 0), metadata: { content: "image-url" } })).toBe(false); + expect(canGenerateImageInPlace({ ...node("text", 0, 0), type: CanvasNodeType.Text })).toBe(false); + }); +});