From 1dfc8ce7ac21587719b17fa6f7315a14e03a621f Mon Sep 17 00:00:00 2001 From: tanzui <305375675@qq.com> Date: Mon, 5 Oct 2026 15:26:51 +0800 Subject: [PATCH] =?UTF-8?q?fix(canvas):=20=E8=8A=82=E7=82=B9=E5=A4=8D?= =?UTF-8?q?=E5=88=B6=20-=20=E4=BF=AE=E5=A4=8D=E7=94=9F=E5=9B=BE=E5=89=AF?= =?UTF-8?q?=E6=9C=AC=E7=94=9F=E6=88=90=E5=90=8E=E6=B6=88=E5=A4=B1=20(#676)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: tanzui <305375675@.com> --- docs/content/docs/progress/pending-test.mdx | 6 ++ web/src/lib/canvas/canvas-node-copy.ts | 9 ++- web/test/canvas-node-copy.test.ts | 3 + .../generation-storage-consistency.test.ts | 12 +++- .../generation-storage-consistency.worker.ts | 61 +++++++++++++++++-- 5 files changed, 85 insertions(+), 6 deletions(-) diff --git a/docs/content/docs/progress/pending-test.mdx b/docs/content/docs/progress/pending-test.mdx index 90d75f5d5..2183e1210 100644 --- a/docs/content/docs/progress/pending-test.mdx +++ b/docs/content/docs/progress/pending-test.mdx @@ -5,6 +5,12 @@ description: 当前项目已实现功能的手工验证清单与未确认边界 # 待测试 +## 复制生图节点后节点消失 + +- 已修复:副本不再继承原节点的 `generationEffectKeys`,避免普通保存把它误判为未确认的生成结果。图片内容、提示词和参考字段仍保留,原节点不受影响。 +- [x] 回归测试在修改前复现副本无法保存;修改后覆盖复制、普通保存、新生成结果提交及重新读取。 +- [ ] 在真实画布中通过复制粘贴和“创建副本”分别验证,生成完成并刷新后副本仍保留。已丢失的副本不会自动恢复,也不会自动重试生图。 + ## Agent 失败消息合并与展示 - 已修复:同一运行的 `run_failed` / `error` 与失败状态快照共用一条错误消息;以公开失败原因补全内容,重放、乱序和空原因不覆盖已有详细说明。不同运行即使错误文本相同也分别保留,工具失败与连接错误不合并。 diff --git a/web/src/lib/canvas/canvas-node-copy.ts b/web/src/lib/canvas/canvas-node-copy.ts index b4babd046..93a557c2e 100644 --- a/web/src/lib/canvas/canvas-node-copy.ts +++ b/web/src/lib/canvas/canvas-node-copy.ts @@ -46,9 +46,16 @@ function copyStoryboardRow(row: StoryboardRow, idMap: ReadonlyMap): CanvasNodeMetadata { const metadata = resetGenerationTaskMetadata(node.metadata, node.metadata?.content ? "success" : "idle"); + // 原节点的提交标记不能证明副本已保存;继承后,普通保存会把新副本误判为未确认结果并排除。 + delete metadata.generationEffectKeys; delete metadata.generationBatches; delete metadata.batchRootId; delete metadata.batchChildIds; diff --git a/web/test/canvas-node-copy.test.ts b/web/test/canvas-node-copy.test.ts index ba4b14ad3..d072d0adb 100644 --- a/web/test/canvas-node-copy.test.ts +++ b/web/test/canvas-node-copy.test.ts @@ -43,6 +43,7 @@ describe("canvas generation copy metadata", () => { videoStartFrameNodeId: "reference-start", videoEndFrameNodeId: "reference-end", 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.copiedFromNodeId).toBe("source"); expect(metadata.generationResultPlacement).toBe("replace-node"); + expect(metadata.generationEffectKeys).toBeUndefined(); + expect(source.metadata?.generationEffectKeys).toEqual(["attach-node:source-task:source:0"]); }); test("复制到副本的入边继续解析为同一张参考图", () => { diff --git a/web/test/generation-storage-consistency.test.ts b/web/test/generation-storage-consistency.test.ts index 59c74ab43..596de13a2 100644 --- a/web/test/generation-storage-consistency.test.ts +++ b/web/test/generation-storage-consistency.test.ts @@ -1,6 +1,6 @@ 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 () => { type Node = import("../src/types/canvas").CanvasNodeData; @@ -21,6 +21,16 @@ test("一次任务四图经真实消费链路堆叠并保存,刷新和重放 }); type ScenarioResponse = { 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 () => { type Node = import("../src/types/canvas").CanvasNodeData; const result = await runScenario<{ edited: boolean; live: Node[]; restored: Node[] }>("canvas-batch-commit-race"); diff --git a/web/test/helpers/generation-storage-consistency.worker.ts b/web/test/helpers/generation-storage-consistency.worker.ts index 86db3244c..3a4848eac 100644 --- a/web/test/helpers/generation-storage-consistency.worker.ts +++ b/web/test/helpers/generation-storage-consistency.worker.ts @@ -4,7 +4,7 @@ import { getActiveUserScope, setActiveUserScope } from "../../src/lib/user-scope type InstanceHook = (storeName: string, key: string, value: unknown) => Promise | 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() { 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) => { try { const result = @@ -533,9 +584,11 @@ self.onmessage = async (event: MessageEvent) => { ? await runScopeCleanupAfterLateCanvasReference() : event.data === "canvas-multi-output" ? await runCanvasBatchCommitRace(true) - : event.data === "canvas-batch-commit-race" - ? await runCanvasBatchCommitRace() - : await runMediaCommitRace(event.data === "audio-commit-race" ? "audio" : "video"); + : event.data === "canvas-copy-generation" + ? await runCanvasCopyGeneration() + : event.data === "canvas-batch-commit-race" + ? await runCanvasBatchCommitRace() + : await runMediaCommitRace(event.data === "audio-commit-race" ? "audio" : "video"); self.postMessage({ ok: true, result }); } catch (error) { self.postMessage({ ok: false, error: error instanceof Error ? `${error.name}: ${error.message}` : String(error) });