fix(canvas): 画布批量生成 - 修复节点丢失、资源引用及刷新状态不一致
This commit is contained in:
16
web/src/lib/canvas/canvas-editor-state.ts
Normal file
16
web/src/lib/canvas/canvas-editor-state.ts
Normal 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);
|
||||
};
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
32
web/src/lib/canvas/canvas-generation-result.ts
Normal file
32
web/src/lib/canvas/canvas-generation-result.ts
Normal 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 } };
|
||||
});
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
|
||||
32
web/src/lib/canvas/canvas-node-visibility.ts
Normal file
32
web/src/lib/canvas/canvas-node-visibility.ts
Normal 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))];
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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)));
|
||||
|
||||
@@ -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;
|
||||
|
||||
36
web/src/lib/canvas/canvas-task-state.ts
Normal file
36
web/src/lib/canvas/canvas-task-state.ts
Normal 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;
|
||||
}
|
||||
@@ -2,10 +2,10 @@ import { nanoid } from "nanoid";
|
||||
|
||||
import { NODE_DEFAULT_SIZE } from "@/constant/canvas";
|
||||
import { canGenerateImageInPlace, findAvailableGenerationGroupPosition, imageGenerationChildPosition, imageGenerationGroupSize } from "@/lib/canvas/canvas-generation-layout";
|
||||
import { cancelIncompleteImageBatch, retireImageBatchChildren } from "@/lib/canvas/canvas-image-batch-retry";
|
||||
import { cancelIncompleteImageBatch, hasImageBatchResult, reconcileImageBatchRoot, retireImageBatchChildren } from "@/lib/canvas/canvas-image-batch-retry";
|
||||
import { buildImageGenerationNodeTitle } from "@/lib/canvas/canvas-generation-title";
|
||||
import { nodeSizeFromRatio } from "@/lib/canvas/canvas-node-size";
|
||||
import { canvasImageReferenceLimitError, buildImageGenerationMetadata, getGenerationCount, isGenerationCanceled, runCanvasGenerationTaskToConsumer } from "@/lib/canvas/canvas-project-generation";
|
||||
import { canvasImageReferenceLimitError, buildImageGenerationMetadata, getGenerationCount, isGenerationCanceled, resetGenerationTaskMetadata, runCanvasGenerationTaskToConsumer } from "@/lib/canvas/canvas-project-generation";
|
||||
import { imageGenerationReferenceConnections } from "@/lib/canvas/canvas-resource-references";
|
||||
import { canvasGenerationPromptMetadata } from "@/lib/canvas/canvas-generation-submission";
|
||||
import { CONTENT_MODERATION_ERROR_CODE, generationFailureMetadata, type GenerationFailureMetadata } from "@/lib/generation-error";
|
||||
@@ -54,7 +54,7 @@ export async function executeImageGeneration({
|
||||
const count = getGenerationCount(generationConfig.count);
|
||||
const isConfigNode = sourceNode?.type === CanvasNodeType.Config;
|
||||
const isImageNode = sourceNode?.type === CanvasNodeType.Image;
|
||||
const isExistingImageNode = isImageNode && Boolean(sourceNode?.metadata?.content);
|
||||
const isExistingImageNode = isImageNode && hasImageBatchResult(sourceNode);
|
||||
const reuseSourceNode = canGenerateImageInPlace(sourceNode);
|
||||
const retired = reuseSourceNode && sourceNode ? retireImageBatchChildren(sourceNode, canvasNodes, canvasConnections) : { nodes: canvasNodes, connections: canvasConnections, removedIds: [] as string[] };
|
||||
const workingNodes = retired.nodes;
|
||||
@@ -70,7 +70,7 @@ export async function executeImageGeneration({
|
||||
const imageConfig = requestedImageSize || imageDefaults;
|
||||
// auto 图生图沿用来源节点尺寸;用户明确选择比例时必须以目标比例创建节点。
|
||||
const referenceNode = referenceImages.length === 1 ? canvasNodes.find((node) => node.id === referenceImages[0].id && node.type === CanvasNodeType.Image) : undefined;
|
||||
const imageSizeSource = requestedImageSize ? undefined : isImageNode && sourceNode?.metadata?.content ? sourceNode : referenceNode;
|
||||
const imageSizeSource = requestedImageSize ? undefined : isExistingImageNode ? sourceNode : referenceNode;
|
||||
const outputNodeSize = imageSizeSource ? { width: imageSizeSource.width, height: imageSizeSource.height } : imageConfig;
|
||||
const parentPosition = sourceNode?.position || { x: 0, y: 0 };
|
||||
const parentWidth = sourceNode?.width || parentConfig.width;
|
||||
@@ -95,7 +95,7 @@ export async function executeImageGeneration({
|
||||
width: rootWidth,
|
||||
height: rootHeight,
|
||||
metadata: {
|
||||
...(reuseSourceNode ? sourceNode?.metadata || {} : {}),
|
||||
...resetGenerationTaskMetadata(reuseSourceNode ? sourceNode?.metadata : undefined, NODE_STATUS_LOADING),
|
||||
...canvasGenerationPromptMetadata(prompt, effectivePrompt),
|
||||
status: NODE_STATUS_LOADING,
|
||||
size: generationConfig.size,
|
||||
@@ -105,6 +105,8 @@ export async function executeImageGeneration({
|
||||
batchUsesReferenceImages: referenceImages.length > 0,
|
||||
primaryImageId: undefined,
|
||||
content: reuseSourceNode ? "" : undefined,
|
||||
storageKey: undefined,
|
||||
assetId: undefined,
|
||||
...generationMetadata,
|
||||
...styleMetadata,
|
||||
...skillMetadata,
|
||||
@@ -145,7 +147,7 @@ export async function executeImageGeneration({
|
||||
...workingNodes.map((node) => {
|
||||
if (node.id !== nodeId) return node;
|
||||
if (isConfigNode) return { ...node, metadata: { ...node.metadata, ...canvasGenerationPromptMetadata(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 (reuseSourceNode) return { ...node, position: rootNode.position, width: rootNode.width, height: rootNode.height, title: rootNode.title, metadata: rootNode.metadata };
|
||||
if (isImageNode) return node;
|
||||
return {
|
||||
...node,
|
||||
@@ -211,7 +213,7 @@ export async function executeImageGeneration({
|
||||
setNodes((current) => {
|
||||
const child = current.find((node) => node.id === targetId);
|
||||
const root = current.find((node) => node.id === rootId);
|
||||
if (!child?.metadata?.content || !root || root.metadata?.primaryImageId) return current;
|
||||
if (!child || !hasImageBatchResult(child) || !root || root.metadata?.primaryImageId) return current;
|
||||
const center = { x: root.position.x + root.width / 2, y: root.position.y + root.height / 2 };
|
||||
const geometry = root.metadata?.locked
|
||||
? {}
|
||||
@@ -290,6 +292,7 @@ export async function executeImageGeneration({
|
||||
};
|
||||
}
|
||||
if (node.id === rootId) {
|
||||
if (count > 1) return reconcileImageBatchRoot(node, current);
|
||||
return {
|
||||
...node,
|
||||
metadata: {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { CanvasWorkspacePanel } from "@/components/canvas/canvas-workspace-panel";
|
||||
import { isCanvasNodeGenerating } from "@/lib/canvas/canvas-node-task-state";
|
||||
import { createCanvasStateWriter } from "@/lib/canvas/canvas-editor-state";
|
||||
import { lazy, Suspense, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
|
||||
import type { Dispatch, MouseEvent as ReactMouseEvent, SetStateAction } from "react";
|
||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
@@ -275,24 +276,14 @@ function InfiniteCanvasPage() {
|
||||
const directorOnboardingScope = useUserStore((state) => state.user?.id?.trim() || "");
|
||||
const nodesRef = useRef<CanvasNodeData[]>([]);
|
||||
const [nodes, setNodesState] = useState<CanvasNodeData[]>([]);
|
||||
const setNodes = useCallback<Dispatch<SetStateAction<CanvasNodeData[]>>>((value) => {
|
||||
if (typeof value === "function") {
|
||||
setNodesState((current) => {
|
||||
const next = stampCanvasNodeChanges(current, value(current));
|
||||
nodesRef.current = next;
|
||||
return next;
|
||||
});
|
||||
return;
|
||||
}
|
||||
const next = stampCanvasNodeChanges(nodesRef.current, value);
|
||||
nodesRef.current = next;
|
||||
setNodesState(next);
|
||||
}, []);
|
||||
const setNodes = useMemo(() => createCanvasStateWriter(nodesRef, setNodesState, stampCanvasNodeChanges), []);
|
||||
const [nodeStackOrder, setNodeStackOrder] = useState<CanvasNodeStackOrder>([]);
|
||||
const bringNodeToFront = useCallback((nodeId: string) => {
|
||||
setNodeStackOrder((current) => bringCanvasNodeToFront(current, nodeId));
|
||||
}, []);
|
||||
const [connections, setConnections] = useState<CanvasConnection[]>([]);
|
||||
const [connections, setConnectionsState] = useState<CanvasConnection[]>([]);
|
||||
const connectionsRef = useRef<CanvasConnection[]>([]);
|
||||
const setConnections = useMemo(() => createCanvasStateWriter(connectionsRef, setConnectionsState), []);
|
||||
const [chatSessions, setChatSessions] = useState<CanvasAssistantSession[]>([]);
|
||||
const [activeChatId, setActiveChatId] = useState<string | null>(null);
|
||||
const [viewport, setViewport] = useState<ViewportTransform>({ x: 0, y: 0, k: 1 });
|
||||
@@ -370,7 +361,6 @@ function InfiniteCanvasPage() {
|
||||
});
|
||||
}, [nodes]);
|
||||
|
||||
const connectionsRef = useRef(connections);
|
||||
const chatSessionsRef = useRef(chatSessions);
|
||||
const activeChatIdRef = useRef(activeChatId);
|
||||
const selectedNodeIdsRef = useRef(selectedNodeIds);
|
||||
@@ -637,8 +627,6 @@ function InfiniteCanvasPage() {
|
||||
}, [dialogNodeId]);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
nodesRef.current = nodes;
|
||||
connectionsRef.current = connections;
|
||||
chatSessionsRef.current = chatSessions;
|
||||
activeChatIdRef.current = activeChatId;
|
||||
selectedNodeIdsRef.current = selectedNodeIds;
|
||||
|
||||
@@ -3,6 +3,9 @@ import { useQueryClient } from "@tanstack/react-query";
|
||||
import { App } from "antd";
|
||||
|
||||
import { applyGenerationTaskResultToNodes, generationTaskCanReloadResource, generationTaskNodeId } from "@/lib/canvas/canvas-generation-task-sync";
|
||||
import { commitCanvasGenerationResult } from "@/lib/canvas/canvas-generation-result";
|
||||
import { reconcileImageBatchRoot } from "@/lib/canvas/canvas-image-batch-retry";
|
||||
import { markCanvasTaskRecoveryUnconfirmed } from "@/lib/canvas/canvas-task-state";
|
||||
import { applyCanvasGenerationTaskNodeEffect, isCanvasGenerationDurableAckError } from "@/services/canvas-generation-consumer";
|
||||
import { consumeGenerationTaskNode, ensureCanvasNodeAsset, retryCanvasAssetSyncAfterRateLimit } from "@/services/project-asset-sync";
|
||||
import { listGenerationTasks, listTaskLogs, queryGenerationTask, subscribeGenerationTasks, type GenerationTask, type TaskLog } from "@/services/api/task-center";
|
||||
@@ -254,7 +257,7 @@ export function useCanvasGeneration({ projectId, domainProjectId, projectLoaded,
|
||||
current.map((node) => {
|
||||
if (node.id !== targetNodeId) return node;
|
||||
const failed = task.status === "failed" || task.status === "cancelled";
|
||||
const hasCompletedContent = task.status === "succeeded" && Boolean(node.metadata?.content);
|
||||
const hasCompletedContent = task.status === "succeeded" && Boolean(node.metadata?.content || node.metadata?.storageKey);
|
||||
const failure = failed ? generationFailureMetadata(task.error || (task.status === "cancelled" ? "任务已取消" : "任务失败"), node.metadata?.composerContent || node.metadata?.prompt || task.prompt || "") : undefined;
|
||||
return {
|
||||
...node,
|
||||
@@ -274,7 +277,8 @@ export function useCanvasGeneration({ projectId, domainProjectId, projectLoaded,
|
||||
const saveGeneratedAsset = useCallback(
|
||||
async (node: CanvasNodeData, taskId: string, signal?: AbortSignal) => {
|
||||
const result = await retryCanvasAssetSyncAfterRateLimit(() => ensureCanvasNodeAsset({ canvasId: projectId, domainProjectId, node, source: "canvas-generation", taskId, signal }), { signal });
|
||||
setNodes((current) => current.map((item) => (item.id === node.id ? { ...item, metadata: { ...item.metadata, assetId: result.assetId } } : item)));
|
||||
if (signal?.aborted) return;
|
||||
setNodes((current) => current.map((item) => (item.id === node.id && item.metadata?.taskId === taskId ? { ...item, metadata: { ...item.metadata, assetId: result.assetId } } : item)));
|
||||
if (domainProjectId) await queryClient.invalidateQueries({ queryKey: ["project", domainProjectId] });
|
||||
},
|
||||
[domainProjectId, projectId, queryClient, setNodes],
|
||||
@@ -283,10 +287,13 @@ export function useCanvasGeneration({ projectId, domainProjectId, projectLoaded,
|
||||
const applyGenerationTaskResult = useCallback(
|
||||
async (nodeId: string, task: GenerationTask) => {
|
||||
const applyStoredTaskResult = async () => {
|
||||
const signal = consumerControllerRef.current.signal;
|
||||
const before = nodesRef.current.find((node) => node.id === nodeId);
|
||||
if (!before) throw new Error("画布中找不到对应任务节点");
|
||||
const applied = await applyGenerationTaskResultToNodes(nodesRef.current, task, nodeId);
|
||||
if (signal.aborted) throw new DOMException("The operation was aborted", "AbortError");
|
||||
if (!applied.updated || !applied.node) throw new Error("画布中找不到对应任务节点");
|
||||
nodesRef.current = applied.nodes;
|
||||
setNodes(applied.nodes);
|
||||
setNodes((current) => commitCanvasGenerationResult(current, before, applied.node!, task.id));
|
||||
};
|
||||
if (!task.outputs?.length && task.type === "canvas_text") {
|
||||
await applyStoredTaskResult();
|
||||
@@ -311,7 +318,7 @@ export function useCanvasGeneration({ projectId, domainProjectId, projectLoaded,
|
||||
},
|
||||
{ signal: consumerControllerRef.current.signal },
|
||||
);
|
||||
const currentNode = nodesRef.current.find((node) => node.id === nodeId || node.metadata?.taskId === task.id);
|
||||
const currentNode = nodesRef.current.find((node) => node.id === nodeId);
|
||||
if (task.status === "succeeded" && (!currentNode?.metadata?.content || currentNode.metadata.status !== NODE_STATUS_SUCCESS)) {
|
||||
// attach effect 可能已经完成,但旧画布快照仍停留在 loading。
|
||||
// 最终以节点是否真实拿到媒体结果为准,不能只信幂等记录。
|
||||
@@ -370,19 +377,25 @@ export function useCanvasGeneration({ projectId, domainProjectId, projectLoaded,
|
||||
if (!isCurrentProject()) return;
|
||||
const recoveryNodes = nodesRef.current.filter((node) => {
|
||||
const pendingAgentContinuation = node.metadata?.agentGenerationContinuation?.status === "pending";
|
||||
const aggregateBatchRoot = node.metadata?.isBatchRoot && node.metadata.batchChildIds?.length && !node.metadata.taskId;
|
||||
const aggregateBatchRoot = node.metadata?.isBatchRoot && node.metadata.batchChildIds?.length;
|
||||
if (aggregateBatchRoot && !pendingAgentContinuation) return false;
|
||||
return pendingAgentContinuation || node.metadata?.status === NODE_STATUS_LOADING || node.metadata?.errorDetails === "页面刷新后生成已中断,请重新生成。" || Boolean(node.metadata?.taskId && node.metadata.status !== NODE_STATUS_SUCCESS);
|
||||
});
|
||||
const needsDiscovery = recoveryNodes.some((node) => !node.metadata?.taskId && !node.metadata?.agentGenerationContinuation?.taskId);
|
||||
const projectTasks = needsDiscovery
|
||||
? (
|
||||
await listGenerationTasks(100, { projectId: startedProjectId }, undefined, signal).catch((error) => {
|
||||
if (!isCurrentProject()) throw error;
|
||||
return [];
|
||||
})
|
||||
).filter((task) => task.projectId === startedProjectId && task.type.startsWith("canvas_"))
|
||||
: [];
|
||||
let projectTasks: GenerationTask[] = [];
|
||||
if (needsDiscovery) {
|
||||
try {
|
||||
projectTasks = (await listGenerationTasks(100, { projectId: startedProjectId }, undefined, signal))
|
||||
.filter((task) => task.projectId === startedProjectId && task.type.startsWith("canvas_"));
|
||||
} catch (error) {
|
||||
if (!isCurrentProject() || (error instanceof Error && error.name === "AbortError")) return;
|
||||
// 发现接口失败不影响已有 taskId 的精确恢复,也不是“查无任务”的证据。
|
||||
message.warning({ key: `canvas-task-recovery:${startedProjectId}`, content: "任务列表暂不可用,未绑定任务的节点状态尚未确认;已绑定任务继续恢复。", duration: 6 });
|
||||
}
|
||||
if (isCurrentProject() && recoveryNodes.some((node) => !node.metadata?.taskId && !node.metadata?.agentGenerationContinuation?.taskId && !projectTasks.some((task) => generationTaskNodeId(task) === node.id))) {
|
||||
message.warning({ key: `canvas-task-recovery:${startedProjectId}`, content: "部分节点未在最近任务中找到,已保留原状态。请到任务中心核对结果,勿重复提交。", duration: 6 });
|
||||
}
|
||||
}
|
||||
if (!isCurrentProject()) return;
|
||||
await Promise.all(
|
||||
recoveryNodes.map(async (node) => {
|
||||
@@ -391,7 +404,7 @@ export function useCanvasGeneration({ projectId, domainProjectId, projectLoaded,
|
||||
if (!taskId) {
|
||||
if (!isCurrentProject()) return;
|
||||
setNodes((current) =>
|
||||
isCurrentProject() ? current.map((item) => (item.id === node.id ? { ...item, metadata: { ...item.metadata, status: NODE_STATUS_ERROR, errorDetails: "页面刷新后找不到对应任务,请重新生成。" } } : item)) : current,
|
||||
isCurrentProject() ? current.map((item) => (item.id === node.id ? markCanvasTaskRecoveryUnconfirmed(item, undefined, "最近任务中未找到对应记录,状态尚未确认;请到任务中心核对,勿重复提交。") : item)) : current,
|
||||
);
|
||||
return;
|
||||
}
|
||||
@@ -400,7 +413,8 @@ export function useCanvasGeneration({ projectId, domainProjectId, projectLoaded,
|
||||
const continuationOnly = !node.metadata?.taskId && node.metadata?.agentGenerationContinuation?.taskId === taskId && !discoveredTask;
|
||||
try {
|
||||
const completed = await observeSubscribedGenerationTask(taskId, signal, (task) => {
|
||||
if (isCurrentProject() && !continuationOnly) bindGenerationTask(node.id, task);
|
||||
const live = nodesRef.current.find((item) => item.id === node.id);
|
||||
if (isCurrentProject() && !continuationOnly && live && (!live.metadata?.taskId || live.metadata.taskId === taskId)) bindGenerationTask(node.id, task);
|
||||
});
|
||||
if (!isCurrentProject()) return;
|
||||
await recoverCanvasGenerationTaskNode({
|
||||
@@ -420,13 +434,12 @@ export function useCanvasGeneration({ projectId, domainProjectId, projectLoaded,
|
||||
setNodes((current) =>
|
||||
isCurrentProject()
|
||||
? current.map((item) =>
|
||||
item.id === node.id
|
||||
item.id === node.id && (!item.metadata?.taskId || item.metadata.taskId === taskId)
|
||||
? {
|
||||
...item,
|
||||
metadata: {
|
||||
...item.metadata,
|
||||
status: continuationOnly ? item.metadata?.status : NODE_STATUS_ERROR,
|
||||
...(continuationOnly ? {} : failure),
|
||||
...(continuationOnly ? {} : markCanvasTaskRecoveryUnconfirmed(item, item.metadata?.taskId, failure.errorDetails || "任务恢复暂不可用,请到任务中心核对。").metadata),
|
||||
...(item.metadata?.agentGenerationContinuation?.status === "pending"
|
||||
? {
|
||||
agentGenerationContinuation: { ...item.metadata.agentGenerationContinuation, status: "failed" as const },
|
||||
@@ -447,35 +460,12 @@ export function useCanvasGeneration({ projectId, domainProjectId, projectLoaded,
|
||||
setNodes((current) =>
|
||||
isCurrentProject()
|
||||
? current.map((node) => {
|
||||
if (!node.metadata?.isBatchRoot || !node.metadata.batchChildIds?.length || node.metadata.taskId) return node;
|
||||
const children = node.metadata.batchChildIds.map((id) => current.find((item) => item.id === id)).filter(Boolean) as CanvasNodeData[];
|
||||
const primary = children.find((item) => item.id === node.metadata?.primaryImageId && item.metadata?.content) || children.find((item) => item.metadata?.content);
|
||||
const loading = children.some((item) => item.metadata?.status === NODE_STATUS_LOADING);
|
||||
const failed = children.find((item) => item.metadata?.status === NODE_STATUS_ERROR);
|
||||
return {
|
||||
...node,
|
||||
metadata: {
|
||||
...node.metadata,
|
||||
...(primary
|
||||
? {
|
||||
content: primary.metadata?.content,
|
||||
storageKey: primary.metadata?.storageKey,
|
||||
mimeType: primary.metadata?.mimeType,
|
||||
bytes: primary.metadata?.bytes,
|
||||
naturalWidth: primary.metadata?.naturalWidth,
|
||||
naturalHeight: primary.metadata?.naturalHeight,
|
||||
primaryImageId: primary.id,
|
||||
}
|
||||
: {}),
|
||||
status: primary ? NODE_STATUS_SUCCESS : loading ? NODE_STATUS_LOADING : NODE_STATUS_ERROR,
|
||||
errorDetails: primary ? undefined : failed?.metadata?.errorDetails || "全部图片生成失败",
|
||||
},
|
||||
};
|
||||
return reconcileImageBatchRoot(node, current);
|
||||
})
|
||||
: current,
|
||||
);
|
||||
},
|
||||
[applyGenerationTaskResult, bindGenerationTask, nodesRef, observeSubscribedGenerationTask, setNodes],
|
||||
[applyGenerationTaskResult, bindGenerationTask, message, nodesRef, observeSubscribedGenerationTask, setNodes],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -491,12 +481,13 @@ export function useCanvasGeneration({ projectId, domainProjectId, projectLoaded,
|
||||
await runGenerationConsumer(context.signal, async (signal) => recoverInterruptedGenerationTasks(context.projectId, signal, () => !signal.aborted && context.isCurrentProject()));
|
||||
})
|
||||
.catch((error) => {
|
||||
if (!(error instanceof Error && error.name === "AbortError")) throw error;
|
||||
if (error instanceof Error && error.name === "AbortError") return;
|
||||
message.warning({ key: `canvas-task-recovery:${projectId}`, content: "任务恢复暂不可用,已保留画布状态。请检查网络后刷新,或到任务中心核对结果。", duration: 6 });
|
||||
});
|
||||
return () => {
|
||||
void coordinator.abortAndDrain();
|
||||
};
|
||||
}, [projectId, projectLoaded, recoverInterruptedGenerationTasks]);
|
||||
}, [message, projectId, projectLoaded, recoverInterruptedGenerationTasks]);
|
||||
|
||||
useEffect(
|
||||
() => () => {
|
||||
|
||||
@@ -7,6 +7,7 @@ import { canvasNodeRenderBudget, canvasNodeRenderPadding, CANVAS_MAX_RENDERED_CO
|
||||
import { buildCanvasNodeMentionReferenceMap, buildCanvasResourceReferences, buildToolMentionReference, parseToolMentionTokens } from "@/lib/canvas/canvas-resource-references";
|
||||
import { buildSkillMentionReferences } from "@/lib/canvas/canvas-skill-mentions";
|
||||
import { buildCanvasSpatialIndex, canvasNodeBounds, type CanvasSpatialIndex, type CanvasSpatialIndexEntry } from "@/lib/canvas/canvas-spatial-index";
|
||||
import { selectCanvasVisibleNodes } from "@/lib/canvas/canvas-node-visibility";
|
||||
import type { Skill } from "@/services/api/skills";
|
||||
import type { Asset, ImageAsset } from "@/stores/use-asset-store";
|
||||
import type { DirectorScene } from "@/types/director";
|
||||
@@ -154,6 +155,7 @@ export function useCanvasRenderModel({
|
||||
const viewWidth = viewportSize.width / viewport.k;
|
||||
const viewHeight = viewportSize.height / viewport.k;
|
||||
return {
|
||||
view: { left: viewLeft, top: viewTop, right: viewLeft + viewWidth, bottom: viewTop + viewHeight },
|
||||
enter: { left: viewLeft - enterPadding, top: viewTop - enterPadding, right: viewLeft + viewWidth + enterPadding, bottom: viewTop + viewHeight + enterPadding },
|
||||
retain: { left: viewLeft - retainPadding, top: viewTop - retainPadding, right: viewLeft + viewWidth + retainPadding, bottom: viewTop + viewHeight + retainPadding },
|
||||
};
|
||||
@@ -177,30 +179,19 @@ export function useCanvasRenderModel({
|
||||
const visibleNodes = useMemo(() => {
|
||||
const frames: CanvasNodeData[] = [];
|
||||
const regular: CanvasNodeData[] = [];
|
||||
const renderedNodeIds = renderedNodeIdsRef.current;
|
||||
const renderBudget = canvasNodeRenderBudget(viewport.k);
|
||||
const forcedNodeIds = new Set([...selectedNodeIds, ...(dragPreview?.nodeIds || [])].slice(0, renderBudget));
|
||||
const candidates = nodeSpatialIndex
|
||||
.query(renderBounds.retain, renderBudget + forcedNodeIds.size)
|
||||
.map((nodeId) => nodeById.get(nodeId))
|
||||
.filter((node): node is CanvasNodeData => Boolean(node));
|
||||
const candidateIds = new Set(candidates.map((node) => node.id));
|
||||
for (const nodeId of forcedNodeIds) {
|
||||
if (candidateIds.has(nodeId)) continue;
|
||||
const node = nodeById.get(nodeId);
|
||||
if (node) candidates.push(node);
|
||||
}
|
||||
const prioritized = candidates.filter((node) => forcedNodeIds.has(node.id));
|
||||
const remaining = candidates.filter((node) => !forcedNodeIds.has(node.id)).slice(0, Math.max(0, renderBudget - prioritized.length));
|
||||
[...prioritized, ...remaining].forEach((node) => {
|
||||
if (renderHiddenNodeIds.has(node.id)) return;
|
||||
const retained = forcedNodeIds.has(node.id) || renderedNodeIds.has(node.id);
|
||||
const insideEnterBounds = node.position.x + node.width > renderBounds.enter.left && node.position.x < renderBounds.enter.right && node.position.y + node.height > renderBounds.enter.top && node.position.y < renderBounds.enter.bottom;
|
||||
if (!retained && !insideEnterBounds) return;
|
||||
selectCanvasVisibleNodes({
|
||||
index: nodeSpatialIndex,
|
||||
nodeById,
|
||||
...renderBounds,
|
||||
hiddenIds: renderHiddenNodeIds,
|
||||
retainedIds: renderedNodeIdsRef.current,
|
||||
forcedIds: new Set([...selectedNodeIds, ...(dragPreview?.nodeIds || [])]),
|
||||
budget: canvasNodeRenderBudget(viewport.k),
|
||||
}).forEach((node) => {
|
||||
(isFrameNode(node) ? frames : regular).push(node);
|
||||
});
|
||||
return [...frames, ...regular];
|
||||
}, [dragPreview, nodeById, nodeSpatialIndex, renderBounds, renderHiddenNodeIds, selectedNodeIds]);
|
||||
}, [dragPreview, nodeById, nodeSpatialIndex, renderBounds, renderHiddenNodeIds, selectedNodeIds, viewport.k]);
|
||||
useEffect(() => {
|
||||
renderedNodeIdsRef.current = new Set(visibleNodes.map((node) => node.id));
|
||||
}, [visibleNodes]);
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import type { Dispatch, SetStateAction } from "react";
|
||||
|
||||
import { applyMaterializedGenerationTaskResultToNodes } from "@/lib/canvas/canvas-generation-task-sync";
|
||||
import { commitCanvasGenerationResult } from "@/lib/canvas/canvas-generation-result";
|
||||
import { sameCanvasContent } from "@/lib/canvas/canvas-content";
|
||||
import { parseCanvasStorageDocument, rebaseCanvasProjects, serializeCanvasStorageDocument } from "@/lib/canvas/canvas-storage-revision";
|
||||
import { localForageStorageForScope } from "@/lib/localforage-storage";
|
||||
import { getActiveUserScope } from "@/lib/user-scope";
|
||||
@@ -114,18 +116,24 @@ export async function applyCanvasGenerationTaskNodeEffect(input: {
|
||||
setNodes: Dispatch<SetStateAction<CanvasNodeData[]>>;
|
||||
}) {
|
||||
throwIfAborted(input.signal);
|
||||
const previousNodes = input.nodesRef.current;
|
||||
const applied = await applyMaterializedGenerationTaskResultToNodes(previousNodes, input.task, input.output, input.effectKey, input.nodeId);
|
||||
const snapshot = input.nodesRef.current;
|
||||
const before = snapshot.find((node) => node.id === input.nodeId);
|
||||
if (!before) throw new Error("画布中找不到对应任务节点");
|
||||
const applied = await applyMaterializedGenerationTaskResultToNodes(snapshot, input.task, input.output, input.effectKey, input.nodeId);
|
||||
if (!applied.updated || !applied.node) throw new Error("画布中找不到对应任务节点");
|
||||
const persistedProject = await persistCanvasGenerationEffect({
|
||||
throwIfAborted(input.signal);
|
||||
const previousNodes = input.nodesRef.current;
|
||||
const nodes = commitCanvasGenerationResult(previousNodes, before, applied.node, input.task.id);
|
||||
await persistCanvasGenerationEffect({
|
||||
projectId: input.projectId,
|
||||
effectKey: input.effectKey,
|
||||
previousNodes,
|
||||
nodes: applied.nodes,
|
||||
nodes,
|
||||
signal: input.signal,
|
||||
});
|
||||
input.nodesRef.current = persistedProject.nodes;
|
||||
input.setNodes(persistedProject.nodes);
|
||||
throwIfAborted(input.signal);
|
||||
// 持久化已完成;最后一次 await 之后仍按节点合并,保留期间发生的编辑。
|
||||
input.setNodes((current) => commitCanvasGenerationResult(current, before, applied.node!, input.task.id));
|
||||
}
|
||||
|
||||
export async function persistCanvasOperationContinuationEffect(input: {
|
||||
@@ -512,11 +520,19 @@ export async function persistCanvasGenerationEffect(input: CanvasGenerationEffec
|
||||
const persistedProject = rebaseCommittedCanvasGenerationOntoLiveProject(scope, input.projectId, finalDocument, memoryProject, baseRevision) ?? generationCommittedProject;
|
||||
if (!persistedProject) throw new Error("画布项目不存在,无法确认生成副作用");
|
||||
if (getActiveUserScope() === scope) {
|
||||
withCanvasStorePersistenceSuppressed(() => {
|
||||
const publish = () => {
|
||||
useCanvasStore.setState((state) => ({
|
||||
projects: state.projects.map((project) => (project.id === input.projectId ? persistedProject : project)),
|
||||
}));
|
||||
});
|
||||
};
|
||||
const durableProject = finalDocument.state.projects.find((project) => project.id === input.projectId);
|
||||
// 只有已落盘的快照才能跳过普通保存。rebase 带回的编辑仍是脏数据,
|
||||
// 否则 store 已接受编辑、后续 updateProject 判定无变化,刷新就会丢失。
|
||||
if (sameCanvasContent(durableProject, persistedProject) && JSON.stringify(durableProject?.viewport) === JSON.stringify(persistedProject.viewport)) {
|
||||
withCanvasStorePersistenceSuppressed(publish);
|
||||
} else {
|
||||
publish();
|
||||
}
|
||||
}
|
||||
return persistedProject;
|
||||
} catch (error) {
|
||||
|
||||
Reference in New Issue
Block a user