fix(canvas): 图片生成 - 避让已有节点并保留重新生成结果

Fixes #57

Fixes #58
This commit is contained in:
ddCat
2026-07-31 13:58:44 +08:00
parent 674c74d509
commit 93672ce245
5 changed files with 151 additions and 33 deletions

View File

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

View File

@@ -21,6 +21,7 @@ export type CanvasGenerationExecutorDependencies = {
export type CanvasGenerationExecution = CanvasGenerationExecutorDependencies & {
nodeId: string;
sourceNode: CanvasNodeData | undefined;
canvasNodes: CanvasNodeData[];
prompt: string;
effectivePrompt: string;
generationConfig: AiConfig;

View File

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

View File

@@ -168,6 +168,7 @@ export function useCanvasGenerationExecutor({
projectId,
nodeId,
sourceNode,
canvasNodes: nodesRef.current,
prompt,
effectivePrompt,
generationConfig,

View File

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