fix(canvas): 节点复制 - 修复生图副本生成后消失 (#676)
Co-authored-by: tanzui <305375675@.com>
This commit is contained in:
@@ -5,6 +5,12 @@ description: 当前项目已实现功能的手工验证清单与未确认边界
|
|||||||
|
|
||||||
# 待测试
|
# 待测试
|
||||||
|
|
||||||
|
## 复制生图节点后节点消失
|
||||||
|
|
||||||
|
- 已修复:副本不再继承原节点的 `generationEffectKeys`,避免普通保存把它误判为未确认的生成结果。图片内容、提示词和参考字段仍保留,原节点不受影响。
|
||||||
|
- [x] 回归测试在修改前复现副本无法保存;修改后覆盖复制、普通保存、新生成结果提交及重新读取。
|
||||||
|
- [ ] 在真实画布中通过复制粘贴和“创建副本”分别验证,生成完成并刷新后副本仍保留。已丢失的副本不会自动恢复,也不会自动重试生图。
|
||||||
|
|
||||||
## Agent 失败消息合并与展示
|
## Agent 失败消息合并与展示
|
||||||
|
|
||||||
- 已修复:同一运行的 `run_failed` / `error` 与失败状态快照共用一条错误消息;以公开失败原因补全内容,重放、乱序和空原因不覆盖已有详细说明。不同运行即使错误文本相同也分别保留,工具失败与连接错误不合并。
|
- 已修复:同一运行的 `run_failed` / `error` 与失败状态快照共用一条错误消息;以公开失败原因补全内容,重放、乱序和空原因不覆盖已有详细说明。不同运行即使错误文本相同也分别保留,工具失败与连接错误不合并。
|
||||||
|
|||||||
@@ -46,9 +46,16 @@ function copyStoryboardRow(row: StoryboardRow, idMap: ReadonlyMap<string, string
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// 副本只能继承内容和用户引用,运行中任务、批次及指向源生成结果的关系必须隔离。
|
/**
|
||||||
|
* 副本继承内容和用户引用,但不能继承原节点的任务、批次与结果持久化身份。
|
||||||
|
* @param node 原节点,复制过程不会修改它的元数据。
|
||||||
|
* @param idMap 本次复制的节点 ID 映射,用于更新副本内部引用。
|
||||||
|
* @returns 可独立生成与保存的副本元数据。
|
||||||
|
*/
|
||||||
export function isolateCopiedNodeMetadata(node: CanvasNodeData, idMap: ReadonlyMap<string, string>): CanvasNodeMetadata {
|
export function isolateCopiedNodeMetadata(node: CanvasNodeData, idMap: ReadonlyMap<string, string>): CanvasNodeMetadata {
|
||||||
const metadata = resetGenerationTaskMetadata(node.metadata, node.metadata?.content ? "success" : "idle");
|
const metadata = resetGenerationTaskMetadata(node.metadata, node.metadata?.content ? "success" : "idle");
|
||||||
|
// 原节点的提交标记不能证明副本已保存;继承后,普通保存会把新副本误判为未确认结果并排除。
|
||||||
|
delete metadata.generationEffectKeys;
|
||||||
delete metadata.generationBatches;
|
delete metadata.generationBatches;
|
||||||
delete metadata.batchRootId;
|
delete metadata.batchRootId;
|
||||||
delete metadata.batchChildIds;
|
delete metadata.batchChildIds;
|
||||||
|
|||||||
@@ -43,6 +43,7 @@ describe("canvas generation copy metadata", () => {
|
|||||||
videoStartFrameNodeId: "reference-start",
|
videoStartFrameNodeId: "reference-start",
|
||||||
videoEndFrameNodeId: "reference-end",
|
videoEndFrameNodeId: "reference-end",
|
||||||
generationResultPlacement: "new-version",
|
generationResultPlacement: "new-version",
|
||||||
|
generationEffectKeys: ["attach-node:source-task:source:0"],
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -53,6 +54,8 @@ describe("canvas generation copy metadata", () => {
|
|||||||
expect(metadata.videoEndFrameNodeId).toBe("reference-end");
|
expect(metadata.videoEndFrameNodeId).toBe("reference-end");
|
||||||
expect(metadata.copiedFromNodeId).toBe("source");
|
expect(metadata.copiedFromNodeId).toBe("source");
|
||||||
expect(metadata.generationResultPlacement).toBe("replace-node");
|
expect(metadata.generationResultPlacement).toBe("replace-node");
|
||||||
|
expect(metadata.generationEffectKeys).toBeUndefined();
|
||||||
|
expect(source.metadata?.generationEffectKeys).toEqual(["attach-node:source-task:source:0"]);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("复制到副本的入边继续解析为同一张参考图", () => {
|
test("复制到副本的入边继续解析为同一张参考图", () => {
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { expect, test } from "bun:test";
|
import { expect, test } from "bun:test";
|
||||||
|
|
||||||
type Scenario = "image-cleanup" | "scope-cleanup-switch" | "scope-cleanup-late-canvas-reference" | "video-commit-race" | "audio-commit-race" | "canvas-batch-commit-race" | "canvas-multi-output";
|
type Scenario = "image-cleanup" | "scope-cleanup-switch" | "scope-cleanup-late-canvas-reference" | "video-commit-race" | "audio-commit-race" | "canvas-batch-commit-race" | "canvas-multi-output" | "canvas-copy-generation";
|
||||||
|
|
||||||
test("一次任务四图经真实消费链路堆叠并保存,刷新和重放保留四个独立资源", async () => {
|
test("一次任务四图经真实消费链路堆叠并保存,刷新和重放保留四个独立资源", async () => {
|
||||||
type Node = import("../src/types/canvas").CanvasNodeData;
|
type Node = import("../src/types/canvas").CanvasNodeData;
|
||||||
@@ -21,6 +21,16 @@ test("一次任务四图经真实消费链路堆叠并保存,刷新和重放
|
|||||||
});
|
});
|
||||||
type ScenarioResponse<T> = { ok: true; result: T } | { ok: false; error: string };
|
type ScenarioResponse<T> = { ok: true; result: T } | { ok: false; error: string };
|
||||||
|
|
||||||
|
test("已生成图片的副本在普通保存和新生成结果持久化后仍保留,原节点不受影响", async () => {
|
||||||
|
type Node = import("../src/types/canvas").CanvasNodeData;
|
||||||
|
const result = await runScenario<{ beforeGeneration: Node[]; restored: Node[] }>("canvas-copy-generation");
|
||||||
|
expect(result.beforeGeneration.map((node) => node.id)).toEqual(["source", "copy"]);
|
||||||
|
expect(result.beforeGeneration.find((node) => node.id === "copy")?.metadata?.generationEffectKeys).toBeUndefined();
|
||||||
|
expect(result.restored.map((node) => node.id)).toEqual(["source", "copy"]);
|
||||||
|
expect(result.restored.find((node) => node.id === "source")?.metadata).toMatchObject({ content: "original-image", generationEffectKeys: ["attach-node:old-task:source:0"] });
|
||||||
|
expect(result.restored.find((node) => node.id === "copy")?.metadata).toMatchObject({ content: "new-image", status: "success", generationEffectKeys: ["attach-node:new-task:copy:0"] });
|
||||||
|
});
|
||||||
|
|
||||||
test("并发生成结果经真实持久化消费链路及重新读取后保留成功、失败和用户编辑", async () => {
|
test("并发生成结果经真实持久化消费链路及重新读取后保留成功、失败和用户编辑", async () => {
|
||||||
type Node = import("../src/types/canvas").CanvasNodeData;
|
type Node = import("../src/types/canvas").CanvasNodeData;
|
||||||
const result = await runScenario<{ edited: boolean; live: Node[]; restored: Node[] }>("canvas-batch-commit-race");
|
const result = await runScenario<{ edited: boolean; live: Node[]; restored: Node[] }>("canvas-batch-commit-race");
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import { getActiveUserScope, setActiveUserScope } from "../../src/lib/user-scope
|
|||||||
|
|
||||||
type InstanceHook = (storeName: string, key: string, value: unknown) => Promise<void> | void;
|
type InstanceHook = (storeName: string, key: string, value: unknown) => Promise<void> | void;
|
||||||
|
|
||||||
type Scenario = "image-cleanup" | "scope-cleanup-switch" | "scope-cleanup-late-canvas-reference" | "video-commit-race" | "audio-commit-race" | "canvas-batch-commit-race" | "canvas-multi-output";
|
type Scenario = "image-cleanup" | "scope-cleanup-switch" | "scope-cleanup-late-canvas-reference" | "video-commit-race" | "audio-commit-race" | "canvas-batch-commit-race" | "canvas-multi-output" | "canvas-copy-generation";
|
||||||
|
|
||||||
function installStorageHarness() {
|
function installStorageHarness() {
|
||||||
const originalCreateInstance = localforage.createInstance.bind(localforage);
|
const originalCreateInstance = localforage.createInstance.bind(localforage);
|
||||||
@@ -522,6 +522,57 @@ async function runCanvasBatchCommitRace(multiOutput = false) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 使用真实画布存储和生成持久化入口验证复制流程,不调用供应商或生成付费媒体。 */
|
||||||
|
async function runCanvasCopyGeneration() {
|
||||||
|
const harness = installStorageHarness();
|
||||||
|
const previousScope = getActiveUserScope();
|
||||||
|
try {
|
||||||
|
setActiveUserScope("canvas-copy-generation");
|
||||||
|
const { useCanvasStore, flushCanvasStorePersistence, CANVAS_STORE_KEY } = await import("../../src/stores/canvas/use-canvas-store");
|
||||||
|
const { persistCanvasGenerationEffect } = await import("../../src/services/canvas-generation-consumer");
|
||||||
|
const { isolateCopiedNodeMetadata } = await import("../../src/lib/canvas/canvas-node-copy");
|
||||||
|
const { localForageStorageForScope } = await import("../../src/lib/localforage-storage");
|
||||||
|
const { parseCanvasStorageDocument } = await import("../../src/lib/canvas/canvas-storage-revision");
|
||||||
|
const { CanvasNodeType } = await import("../../src/types/canvas");
|
||||||
|
const projectId = useCanvasStore.getState().createProject("copy generation");
|
||||||
|
const readNodes = async () => {
|
||||||
|
const stored = parseCanvasStorageDocument(await localForageStorageForScope(getActiveUserScope()).getItem(CANVAS_STORE_KEY));
|
||||||
|
return stored.state.projects.find((project) => project.id === projectId)!.nodes;
|
||||||
|
};
|
||||||
|
const initial: import("../../src/types/canvas").CanvasNodeData = {
|
||||||
|
id: "source", type: CanvasNodeType.Image, title: "source", position: { x: 0, y: 0 }, width: 320, height: 240,
|
||||||
|
metadata: { content: "original-image", prompt: "test prompt", status: "success" },
|
||||||
|
};
|
||||||
|
useCanvasStore.getState().updateProject(projectId, { nodes: [initial] });
|
||||||
|
await flushCanvasStorePersistence();
|
||||||
|
const sourceEffect = "attach-node:old-task:source:0";
|
||||||
|
await persistCanvasGenerationEffect({
|
||||||
|
projectId, effectKey: sourceEffect, previousNodes: [initial],
|
||||||
|
nodes: [{ ...initial, metadata: { ...initial.metadata, generationEffectKeys: [sourceEffect] } }],
|
||||||
|
});
|
||||||
|
const source = (await readNodes())[0]!;
|
||||||
|
const copy = { ...source, id: "copy", title: "source_copy1", position: { x: 400, y: 0 }, metadata: isolateCopiedNodeMetadata(source, new Map([["source", "copy"]])) };
|
||||||
|
useCanvasStore.getState().updateProject(projectId, { nodes: [source, copy] });
|
||||||
|
await flushCanvasStorePersistence();
|
||||||
|
const beforeGeneration = await readNodes();
|
||||||
|
if (!beforeGeneration.some((node) => node.id === "copy")) throw new Error("复制节点在普通保存后丢失");
|
||||||
|
|
||||||
|
const pendingNodes = [source, { ...copy, metadata: { ...copy.metadata, status: "loading" as const, taskId: "new-task" } }];
|
||||||
|
useCanvasStore.getState().updateProject(projectId, { nodes: pendingNodes });
|
||||||
|
await flushCanvasStorePersistence();
|
||||||
|
const copyEffect = "attach-node:new-task:copy:0";
|
||||||
|
await persistCanvasGenerationEffect({
|
||||||
|
projectId, effectKey: copyEffect, previousNodes: pendingNodes,
|
||||||
|
nodes: pendingNodes.map((node) => node.id === "copy" ? { ...node, metadata: { ...node.metadata, content: "new-image", status: "success", generationEffectKeys: [copyEffect] } } : node),
|
||||||
|
});
|
||||||
|
await flushCanvasStorePersistence();
|
||||||
|
return { beforeGeneration, restored: await readNodes() };
|
||||||
|
} finally {
|
||||||
|
setActiveUserScope(previousScope);
|
||||||
|
harness.restore();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
self.onmessage = async (event: MessageEvent<Scenario>) => {
|
self.onmessage = async (event: MessageEvent<Scenario>) => {
|
||||||
try {
|
try {
|
||||||
const result =
|
const result =
|
||||||
@@ -533,9 +584,11 @@ self.onmessage = async (event: MessageEvent<Scenario>) => {
|
|||||||
? await runScopeCleanupAfterLateCanvasReference()
|
? await runScopeCleanupAfterLateCanvasReference()
|
||||||
: event.data === "canvas-multi-output"
|
: event.data === "canvas-multi-output"
|
||||||
? await runCanvasBatchCommitRace(true)
|
? await runCanvasBatchCommitRace(true)
|
||||||
: event.data === "canvas-batch-commit-race"
|
: event.data === "canvas-copy-generation"
|
||||||
? await runCanvasBatchCommitRace()
|
? await runCanvasCopyGeneration()
|
||||||
: await runMediaCommitRace(event.data === "audio-commit-race" ? "audio" : "video");
|
: event.data === "canvas-batch-commit-race"
|
||||||
|
? await runCanvasBatchCommitRace()
|
||||||
|
: await runMediaCommitRace(event.data === "audio-commit-race" ? "audio" : "video");
|
||||||
self.postMessage({ ok: true, result });
|
self.postMessage({ ok: true, result });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
self.postMessage({ ok: false, error: error instanceof Error ? `${error.name}: ${error.message}` : String(error) });
|
self.postMessage({ ok: false, error: error instanceof Error ? `${error.name}: ${error.message}` : String(error) });
|
||||||
|
|||||||
Reference in New Issue
Block a user