fix(canvas): 画布批量生成 - 修复节点丢失、资源引用及刷新状态不一致

This commit is contained in:
ddCat
2026-09-21 21:13:29 +08:00
parent dc1ad68015
commit 6f45240599
26 changed files with 533 additions and 179 deletions

View File

@@ -0,0 +1,16 @@
type StateUpdate<T> = T | ((current: T) => T);
/** 编辑命令同步提交到 ref;React 只接收快照,不执行含副作用、可被重放的 updater。 */
export function createCanvasStateWriter<T>(
ref: { current: T },
publish: (snapshot: T) => void,
normalize: (before: T, after: T) => T = (_, after) => after,
) {
return (update: StateUpdate<T>) => {
const before = ref.current;
const after = normalize(before, typeof update === "function" ? (update as (current: T) => T)(before) : update);
// 兼容先提交 ref 再发布快照的调用方;相同引用不代表 React 已收到它。
ref.current = after;
publish(after);
};
}

View File

@@ -16,7 +16,7 @@ export function canGenerateImageInPlace(sourceNode: CanvasNodeData | undefined)
export function canGenerateMediaInPlace(sourceNode: CanvasNodeData | undefined, mediaType: typeof CanvasNodeType.Image | typeof CanvasNodeType.Video | typeof CanvasNodeType.Audio) {
if (sourceNode?.type !== mediaType) return false;
if (sourceNode.metadata?.generationResultPlacement) return sourceNode.metadata.generationResultPlacement === "replace-node";
if (!sourceNode.metadata?.content) return true;
if (!sourceNode.metadata?.content && !sourceNode.metadata?.storageKey) return true;
// 兼容显式落点字段引入前创建的复制节点和版本节点。
return Boolean(sourceNode.metadata?.copiedFromNodeId || sourceNode.metadata?.versionOfNodeId);
}

View File

@@ -0,0 +1,32 @@
import type { CanvasNodeData, CanvasNodeMetadata } from "@/types/canvas";
/** 异步阶段只准备结果;提交时按节点身份合并,绝不能用旧文档覆盖当前文档。 */
export function commitCanvasGenerationResult(current: CanvasNodeData[], before: CanvasNodeData, result: CanvasNodeData, taskId: string): CanvasNodeData[] {
const live = current.find((node) => node.id === before.id);
if (!live) throw new Error("生成结果对应节点已删除,结果仍保留在任务中心");
if (result.id !== before.id || (live.metadata?.taskId && live.metadata.taskId !== taskId) || (before.metadata?.taskId && !live.metadata?.taskId)) {
throw new Error("节点已绑定其他生成任务,旧任务结果不能覆盖当前节点");
}
const metadata: CanvasNodeMetadata = { ...live.metadata };
for (const key of new Set([...Object.keys(before.metadata || {}), ...Object.keys(result.metadata || {})]) as Set<keyof CanvasNodeMetadata>) {
if (JSON.stringify(before.metadata?.[key]) === JSON.stringify(result.metadata?.[key])) continue;
Object.assign(metadata, { [key]: result.metadata?.[key] });
}
const geometryChanged = live.width !== before.width || live.height !== before.height || live.metadata?.manualSize;
const moved = live.position.x !== before.position.x || live.position.y !== before.position.y;
const merged: CanvasNodeData = {
...live,
type: result.type === before.type ? live.type : result.type,
title: result.title === before.title || live.title !== before.title ? live.title : result.title,
width: geometryChanged ? live.width : result.width,
height: geometryChanged ? live.height : result.height,
position: moved || geometryChanged ? live.position : result.position,
metadata,
};
const versionRootId = merged.metadata?.versionOfNodeId;
return current.map((node) => {
if (node.id === merged.id) return versionRootId ? { ...merged, metadata: { ...merged.metadata, versionPrimary: true } } : merged;
if (!versionRootId || (node.metadata?.versionOfNodeId || node.id) !== versionRootId) return node;
return { ...node, metadata: { ...node.metadata, versionPrimary: false } };
});
}

View File

@@ -1,4 +1,5 @@
import { NODE_DEFAULT_SIZE } from "@/constant/canvas";
import { commitCanvasGenerationResult } from "@/lib/canvas/canvas-generation-result";
import { fitNodeSize, nodeSizeFromRatio, VIDEO_NODE_MAX_SIZE } from "@/lib/canvas/canvas-node-size";
import { compositeEmotionImage } from "@/lib/canvas/canvas-emotion";
import { storeGeneratedAudio } from "@/services/api/audio";
@@ -305,13 +306,13 @@ export async function syncGenerationTaskToCanvasStore(task: GenerationTask) {
const updatedNode = await buildGenerationTaskNodeResult(node, task, project.nodes);
const latest = useCanvasStore.getState().projects.find((item) => item.id === project.id);
if (!latest?.nodes.some((item) => item.id === node.id)) return false;
useCanvasStore.getState().updateProject(project.id, { nodes: latest.nodes.map((item) => (item.id === node.id ? updatedNode : item)) });
useCanvasStore.getState().updateProject(project.id, { nodes: commitCanvasGenerationResult(latest.nodes, node, updatedNode, task.id) });
return true;
}
function findGenerationTaskNode(nodes: CanvasNodeData[], task: GenerationTask, targetNodeId?: string) {
const nodeId = targetNodeId || generationTaskNodeId(task);
return nodes.find((node) => node.id === nodeId || node.metadata?.taskId === task.id);
return nodeId ? nodes.find((node) => node.id === nodeId) : nodes.find((node) => node.metadata?.taskId === task.id);
}
function completedTaskMetadata(task: GenerationTask): CanvasNodeMetadata {

View File

@@ -1,17 +1,22 @@
import { CanvasNodeType, type CanvasConnection, type CanvasNodeData, type CanvasNodeMetadata } from "@/types/canvas";
import { resetGenerationTaskMetadata } from "@/lib/canvas/canvas-task-state";
export function hasImageBatchResult(node: CanvasNodeData) {
return Boolean(node.metadata?.content || node.metadata?.storageKey);
}
export function liveImageBatchChildren(root: CanvasNodeData, nodes: CanvasNodeData[]) {
if (root.type !== CanvasNodeType.Image) return [];
const listed = new Set(root.metadata?.batchChildIds || []);
return nodes.filter((node) => node.id !== root.id && (node.metadata?.batchRootId === root.id || listed.has(node.id)));
// 清理是破坏性写路径,父节点的旧索引不能证明子节点仍归这个批次所有。
return nodes.filter((node) => node.id !== root.id && node.type === CanvasNodeType.Image && node.metadata?.batchRootId === root.id);
}
export function retireImageBatchChildren(root: CanvasNodeData, nodes: CanvasNodeData[], connections: CanvasConnection[]) {
const children = liveImageBatchChildren(root, nodes);
if (!children.length) return { nodes, connections, removedIds: [] as string[] };
const removedIds = children.filter((node) => !node.metadata?.content).map((node) => node.id);
const removedIds = children.filter((node) => !hasImageBatchResult(node)).map((node) => node.id);
const removed = new Set(removedIds);
const kept = children.filter((node) => Boolean(node.metadata?.content));
const kept = children.filter(hasImageBatchResult);
const nextNodes = nodes
.filter((node) => !removed.has(node.id))
.map((node) => {
@@ -20,7 +25,7 @@ export function retireImageBatchChildren(root: CanvasNodeData, nodes: CanvasNode
delete metadata.batchChildIds;
delete metadata.primaryImageId;
delete metadata.batchFailedCount;
if (!metadata.content) metadata.status = "idle";
if (!hasImageBatchResult(node)) metadata.status = "idle";
return { ...node, metadata };
}
const index = kept.findIndex((item) => item.id === node.id);
@@ -42,14 +47,14 @@ export function retireImageBatchChildren(root: CanvasNodeData, nodes: CanvasNode
export function cancelIncompleteImageBatch(rootId: string, childIds: string[], nodes: CanvasNodeData[], connections: CanvasConnection[]) {
const childIdSet = new Set(childIds);
const removedIds = nodes.filter((node) => childIdSet.has(node.id) && !node.metadata?.content).map((node) => node.id);
const removedIds = nodes.filter((node) => childIdSet.has(node.id) && node.metadata?.batchRootId === rootId && !hasImageBatchResult(node)).map((node) => node.id);
const removed = new Set(removedIds);
const nextNodes = nodes
.filter((node) => !removed.has(node.id))
.map((node) => {
if (node.id !== rootId) return node;
const remaining = (node.metadata?.batchChildIds || []).filter((id) => !removed.has(id) && nodes.some((item) => item.id === id && item.metadata?.content));
const hasContent = Boolean(node.metadata?.content);
const remaining = (node.metadata?.batchChildIds || []).filter((id) => !removed.has(id) && nodes.some((item) => item.id === id && item.metadata?.batchRootId === rootId && hasImageBatchResult(item)));
const hasContent = hasImageBatchResult(node);
const metadata = {
...node.metadata,
batchChildIds: remaining.length ? remaining : undefined,
@@ -75,7 +80,7 @@ export function cancelIncompleteImageBatch(rootId: string, childIds: string[], n
nextNodes.find((node) => node.id === rootId)?.metadata?.batchChildIds || [],
);
const detached = nextNodes.map((node) => {
if (!childIdSet.has(node.id) || remainingIds.has(node.id)) return node;
if (!childIdSet.has(node.id) || node.metadata?.batchRootId !== rootId || remainingIds.has(node.id)) return node;
const metadata = { ...node.metadata };
delete metadata.batchRootId;
return { ...node, metadata };
@@ -136,7 +141,7 @@ export function reconcileImageBatchRoot(root: CanvasNodeData, nodes: CanvasNodeD
.map((id) => nodeById.get(id))
.filter((node): node is CanvasNodeData => Boolean(node && node.type === CanvasNodeType.Image && node.metadata?.batchRootId === root.id));
if (!children.length) {
const metadata: CanvasNodeMetadata = { ...root.metadata };
const metadata: CanvasNodeMetadata = resetGenerationTaskMetadata(root.metadata, root.metadata?.status);
delete metadata.batchChildIds;
delete metadata.batchFailedCount;
delete metadata.primaryImageId;
@@ -146,19 +151,21 @@ export function reconcileImageBatchRoot(root: CanvasNodeData, nodes: CanvasNodeD
delete metadata.generationErrorCode;
delete metadata.resourceReloadAvailable;
delete metadata.failedPromptFingerprint;
if (!metadata.content) metadata.status = "idle";
if (!hasImageBatchResult(root)) metadata.status = "idle";
return { ...root, metadata };
}
const primary = children.find((node) => node.id === root.metadata?.primaryImageId && node.metadata?.content) || children.find((node) => node.metadata?.content);
const primary = children.find((node) => node.id === root.metadata?.primaryImageId && hasImageBatchResult(node)) || children.find(hasImageBatchResult);
const loading = children.some((node) => node.metadata?.status === "loading");
const failed = children.find((node) => node.metadata?.status === "error");
const metadata: CanvasNodeMetadata = { ...root.metadata };
// 批次父节点只是子任务的投影,不能保留上一轮单图任务的身份/终态。
const metadata: CanvasNodeMetadata = resetGenerationTaskMetadata(root.metadata, root.metadata?.status);
metadata.batchFailedCount = children.filter((node) => node.metadata?.status === "error").length;
if (primary) {
metadata.content = primary.metadata?.content;
metadata.storageKey = primary.metadata?.storageKey;
metadata.assetId = primary.metadata?.assetId;
metadata.mimeType = primary.metadata?.mimeType;
metadata.bytes = primary.metadata?.bytes;
metadata.naturalWidth = primary.metadata?.naturalWidth;
@@ -171,6 +178,7 @@ export function reconcileImageBatchRoot(root: CanvasNodeData, nodes: CanvasNodeD
} else {
delete metadata.content;
delete metadata.storageKey;
delete metadata.assetId;
delete metadata.mimeType;
delete metadata.bytes;
delete metadata.naturalWidth;

View File

@@ -0,0 +1,32 @@
import { canvasNodeBounds, type CanvasSpatialBounds, type CanvasSpatialIndex } from "@/lib/canvas/canvas-spatial-index";
import type { CanvasNodeData } from "@/types/canvas";
const intersects = (a: CanvasSpatialBounds, b: CanvasSpatialBounds) => a.right > b.left && a.left < b.right && a.bottom > b.top && a.top < b.bottom;
/** 预算只能裁减屏外预加载,不能裁掉屏内节点或正在交互的节点。 */
export function selectCanvasVisibleNodes({
index, nodeById, view, enter, retain, hiddenIds, retainedIds, forcedIds, budget,
}: {
index: CanvasSpatialIndex<string>;
nodeById: ReadonlyMap<string, CanvasNodeData>;
view: CanvasSpatialBounds;
enter: CanvasSpatialBounds;
retain: CanvasSpatialBounds;
hiddenIds: ReadonlySet<string>;
retainedIds: ReadonlySet<string>;
forcedIds: ReadonlySet<string>;
budget: number;
}) {
// 先做语义过滤再预算;折叠子图和屏外缓冲区不能抢占真正可见节点的名额。
const ids = new Set([...index.query(retain), ...forcedIds]);
const visible: CanvasNodeData[] = [];
const overscan: CanvasNodeData[] = [];
for (const id of ids) {
const node = nodeById.get(id);
if (!node || hiddenIds.has(id)) continue;
const bounds = canvasNodeBounds(node);
if (forcedIds.has(id) || intersects(bounds, view)) visible.push(node);
else if (retainedIds.has(id) || intersects(bounds, enter)) overscan.push(node);
}
return [...visible, ...overscan.slice(0, Math.max(0, budget - visible.length))];
}

View File

@@ -2,9 +2,7 @@ import { CanvasNodeType, type CanvasMediaPerformanceMode, type CanvasNodeData }
const STORAGE_KEY = "canvas-media-performance-mode";
// The DOM budget is deliberately below the connection budget. Image-heavy
// canvases pay a texture/compositing cost per mounted card, even when the
// cards themselves are already virtualized spatially.
// 屏外预加载的软预算;屏内节点不可因预算而消失,媒体仍由节点按需加载。
export const CANVAS_MAX_RENDERED_NODES = 720;
export const CANVAS_MAX_RENDERED_CONNECTIONS = 5000;

View File

@@ -170,34 +170,7 @@ export function generationTaskMetadata(task: GenerationTask): CanvasNodeMetadata
};
}
// 失败节点再次提交前必须移除旧任务绑定,否则批次调度会把它误判为仍在处理。
export function resetGenerationTaskMetadata(metadata: CanvasNodeMetadata | undefined, status: CanvasNodeMetadata["status"] = "idle"): CanvasNodeMetadata {
const next = {
...(metadata || {}),
status,
errorDetails: undefined,
generationErrorCode: undefined,
resourceReloadAvailable: undefined,
failedPromptFingerprint: undefined,
};
delete next.taskId;
delete next.taskClientOperationId;
delete next.retryOf;
delete next.attemptGroupId;
delete next.taskStatus;
delete next.taskProgress;
delete next.taskStage;
delete next.taskProvider;
delete next.taskStartedAt;
delete next.taskCompletedAt;
delete next.taskDurationMs;
delete next.taskErrorCode;
delete next.taskOfficialStatus;
delete next.taskReceiptRecorded;
delete next.taskCreatedAt;
delete next.taskUpdatedAt;
return next;
}
export { resetGenerationTaskMetadata } from "@/lib/canvas/canvas-task-state";
function normalizeTaskProgress(progress: number | undefined, status: GenerationTask["status"]) {
if (typeof progress === "number" && Number.isFinite(progress)) return Math.max(0, Math.min(100, Math.round(progress)));

View File

@@ -406,6 +406,12 @@ export function buildNodeMentionReferences(node: CanvasNodeData, nodes: CanvasNo
}
export function buildCanvasNodeMentionReferenceMap(nodes: CanvasNodeData[], connections: CanvasConnection[], targetNodes: CanvasNodeData[] = nodes) {
const resolve = buildCanvasResourceInputResolver(nodes, connections);
return new Map(targetNodes.map((node) => [node.id, labelResourceNodes(resolve(node.id), true)]));
}
/** 批次关系属于分组,不是媒体输入;展示与提交共用这一份输入顺序解析。 */
function buildCanvasResourceInputResolver(nodes: CanvasNodeData[], connections: CanvasConnection[]) {
const nodeById = new Map(nodes.map((node) => [node.id, node]));
const resourceInputsByTargetId = new Map<string, CanvasNodeData[]>();
const configTargetBySourceId = new Map<string, string>();
@@ -413,7 +419,7 @@ export function buildCanvasNodeMentionReferenceMap(nodes: CanvasNodeData[], conn
const source = nodeById.get(connection.fromNodeId);
const target = nodeById.get(connection.toNodeId);
if (!source || !target) continue;
if (isResourceNode(source)) {
if (isResourceNode(source) && target.metadata?.batchRootId !== source.id) {
const inputs = resourceInputsByTargetId.get(target.id) || [];
inputs.push(source);
resourceInputsByTargetId.set(target.id, inputs);
@@ -423,15 +429,20 @@ export function buildCanvasNodeMentionReferenceMap(nodes: CanvasNodeData[], conn
}
}
const referencesByNodeId = new Map<string, CanvasResourceReference[]>();
for (const node of targetNodes) {
const configTargetId = configTargetBySourceId.get(node.id);
const configInputs = configTargetId ? (resourceInputsByTargetId.get(configTargetId) || []).filter((input) => input.id !== node.id) : [];
const ownInputs = resourceInputsByTargetId.get(node.id) || [];
const inputs = configInputs.length ? configInputs : ownInputs.filter((input) => input.id !== node.id);
referencesByNodeId.set(node.id, labelResourceNodes(inputs, true));
}
return referencesByNodeId;
return function resolve(nodeId: string, includeSelf = false, visited = new Set<string>()): CanvasNodeData[] {
if (visited.has(nodeId)) return [];
visited.add(nodeId);
const node = nodeById.get(nodeId);
if (!node) return [];
const configTargetId = configTargetBySourceId.get(nodeId);
const configInputs = configTargetId ? (resourceInputsByTargetId.get(configTargetId) || []).filter((input) => input.id !== nodeId) : [];
const ownInputs = (resourceInputsByTargetId.get(nodeId) || []).filter((input) => input.id !== nodeId);
if (configInputs.length) return uniqueCanvasNodes(configInputs);
if (ownInputs.length) return uniqueCanvasNodes(ownInputs);
const batchRoot = node.metadata?.batchRootId ? nodeById.get(node.metadata.batchRootId) : undefined;
if (batchRoot?.metadata?.isBatchRoot) return resolve(batchRoot.id, false, visited);
return includeSelf && isResourceNode(node) ? [node] : [];
};
}
export function buildOrderedCanvasResourceReferences(nodes: CanvasNodeData[], active = true) {
@@ -447,22 +458,13 @@ export function imageGenerationReferenceConnections(sourceNodeId: string, target
}
export function getMentionResourceNodes(nodeId: string, nodes: CanvasNodeData[], connections: CanvasConnection[]) {
const configInputs = getConnectedConfigResourceNodes(nodeId, nodes, connections);
if (configInputs.length) return configInputs;
const ownInputs = getContextResourceNodes(nodeId, nodes, connections);
if (ownInputs.length) return ownInputs;
// 没有入边时,资源节点可以把自身当作 @图片1 / @视频1,用于图生图、视频再编辑。
// 有入边时仍只暴露上游,避免自身把槽位序号挤掉。
const self = nodes.find((node) => node.id === nodeId);
return self && isResourceNode(self) ? [self] : [];
return buildCanvasResourceInputResolver(nodes, connections)(nodeId, true);
}
export function getGenerationResourceNodes(nodeId: string, nodes: CanvasNodeData[], connections: CanvasConnection[]) {
const configInputs = getConnectedConfigResourceNodes(nodeId, nodes, connections);
if (configInputs.length) return configInputs;
const ownInputs = getContextResourceNodes(nodeId, nodes, connections);
if (ownInputs.length) return ownInputs;
return [];
return buildCanvasResourceInputResolver(nodes, connections)(nodeId);
}
/** 收集节点自身及其上游链路中的视频节点,用于时间线片段导入定位真正的视频源。 */
@@ -518,12 +520,6 @@ export function reorderCanvasResourceConnections(targetNodeId: string, orderedNo
return next;
}
function getConnectedConfigResourceNodes(nodeId: string, nodes: CanvasNodeData[], connections: CanvasConnection[]) {
const configConnection = connections.find((connection) => connection.fromNodeId === nodeId && nodes.find((node) => node.id === connection.toNodeId)?.type === CanvasNodeType.Config);
if (!configConnection) return [];
return getContextResourceNodes(configConnection.toNodeId, nodes, connections).filter((node) => node.id !== nodeId);
}
function labelResourceNodes(nodes: CanvasNodeData[], active: boolean) {
const counts: Record<CanvasResourceKind, number> = { image: 0, video: 0, audio: 0, text: 0, skill: 0, character: 0, tool: 0 };
let drawingCount = 0;

View File

@@ -0,0 +1,36 @@
import type { CanvasNodeData, CanvasNodeMetadata } from "@/types/canvas";
/** 查询/消费异常不是任务终态。只标记恢复诊断,不覆盖成功结果或新的任务绑定。 */
export function markCanvasTaskRecoveryUnconfirmed(node: CanvasNodeData, expectedTaskId: string | undefined, detail: string): CanvasNodeData {
if (node.metadata?.status === "success" || node.metadata?.taskId !== expectedTaskId) return node;
return { ...node, metadata: { ...node.metadata, errorDetails: detail } };
}
// 失败节点再次提交前必须移除旧任务绑定,否则批次调度会把它误判为仍在处理。
export function resetGenerationTaskMetadata(metadata: CanvasNodeMetadata | undefined, status: CanvasNodeMetadata["status"] = "idle"): CanvasNodeMetadata {
const next = {
...(metadata || {}),
status,
errorDetails: undefined,
generationErrorCode: undefined,
resourceReloadAvailable: undefined,
failedPromptFingerprint: undefined,
};
delete next.taskId;
delete next.taskClientOperationId;
delete next.retryOf;
delete next.attemptGroupId;
delete next.taskStatus;
delete next.taskProgress;
delete next.taskStage;
delete next.taskProvider;
delete next.taskStartedAt;
delete next.taskCompletedAt;
delete next.taskDurationMs;
delete next.taskErrorCode;
delete next.taskOfficialStatus;
delete next.taskReceiptRecorded;
delete next.taskCreatedAt;
delete next.taskUpdatedAt;
return next;
}