From 6ecf4f4a10d90131931466a6b45fd57d245bf6d5 Mon Sep 17 00:00:00 2001 From: dlh <2773843782@qq.com> Date: Wed, 19 Aug 2026 20:34:12 +0800 Subject: [PATCH] =?UTF-8?q?fix(canvas):=20=E8=8A=82=E7=82=B9=E6=A8=A1?= =?UTF-8?q?=E5=9E=8B=E9=85=8D=E7=BD=AE=20-=20=E5=88=87=E6=8D=A2=E6=A8=A1?= =?UTF-8?q?=E5=9E=8B=E6=97=B6=E6=B8=85=E7=90=86=E7=94=9F=E6=88=90=E5=8F=82?= =?UTF-8?q?=E6=95=B0=E6=AE=8B=E7=95=99?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 修复 issue #254;集成 PR #265,保留原 PR 开发者为提交作者。 --- web/src/lib/canvas/canvas-project-domain.ts | 29 +++++++- web/test/canvas-node-config-reset.test.ts | 78 +++++++++++++++++++++ 2 files changed, 106 insertions(+), 1 deletion(-) create mode 100644 web/test/canvas-node-config-reset.test.ts diff --git a/web/src/lib/canvas/canvas-project-domain.ts b/web/src/lib/canvas/canvas-project-domain.ts index d88e2059b..f498d6edb 100644 --- a/web/src/lib/canvas/canvas-project-domain.ts +++ b/web/src/lib/canvas/canvas-project-domain.ts @@ -126,14 +126,41 @@ export function storyboardRowsFromTask(task: GenerationTask) { } +// 模型切换时必须清理的节点级生成参数:这些参数属于旧模型的能力档位(分辨率/宽高比/质量等), +// 新模型不支持时若残留,会在 buildNodeConfig 的「节点优先、全局兜底」合并中反复叠加(issue #254)。 +const NODE_MODEL_GENERATION_PARAMS: ReadonlyArray = [ + "size", + "quality", + "transparentBackground", + "count", + "seconds", + "vquality", + "generateAudio", + "watermark", +]; + export function applyNodeConfigPatch(node: CanvasNodeData, patch: Partial) { const safePatch = patch || {}; - const next = { ...node, metadata: { ...node.metadata, ...safePatch } }; + const nextPatch = resetGenerationParamsOnModelSwitch(node, safePatch); + const next = { ...node, metadata: { ...node.metadata, ...nextPatch } }; const spec = node.type === CanvasNodeType.Video ? NODE_DEFAULT_SIZE[CanvasNodeType.Video] : NODE_DEFAULT_SIZE[CanvasNodeType.Image]; const size = typeof safePatch.size === "string" && !node.metadata?.content ? nodeSizeFromRatio(safePatch.size, spec.width, spec.height) : null; return size && (node.type === CanvasNodeType.Image || node.type === CanvasNodeType.Video) ? { ...next, ...size, position: { x: node.position.x + node.width / 2 - size.width / 2, y: node.position.y + node.height / 2 - size.height / 2 } } : next; } +// 切换模型(不同模型标识)时,节点级生成参数必须随旧模型一起失效,回落全局配置; +// 显式传入的同批 patch(如用户同时调整了参数)仍然优先。 +function resetGenerationParamsOnModelSwitch(node: CanvasNodeData, patch: Partial): Partial { + if (typeof patch.model !== "string" || patch.model === node.metadata?.model) { + return patch; + } + const reset: Partial = {}; + for (const key of NODE_MODEL_GENERATION_PARAMS) { + reset[key] = undefined; + } + return { ...reset, ...patch }; +} + export function getConnectionTargetAnchor(node: CanvasNodeData, current: ConnectionHandle, handleId?: string, scrollTop = 0, anchorRatio?: number) { return { x: current.handleType === "source" ? node.position.x : node.position.x + node.width, diff --git a/web/test/canvas-node-config-reset.test.ts b/web/test/canvas-node-config-reset.test.ts new file mode 100644 index 000000000..b79fc4bf6 --- /dev/null +++ b/web/test/canvas-node-config-reset.test.ts @@ -0,0 +1,78 @@ +import { describe, expect, test } from "bun:test"; +import { applyNodeConfigPatch } from "../src/lib/canvas/canvas-project-domain"; +import { CanvasNodeType, type CanvasNodeData } from "../src/types/canvas"; + +function imageNode(metadata: Record = {}): CanvasNodeData { + return { + id: "image-1", + type: CanvasNodeType.Image, + title: "图片", + position: { x: 0, y: 0 }, + width: 400, + height: 400, + metadata: metadata as CanvasNodeData["metadata"], + }; +} + +describe("applyNodeConfigPatch 模型切换清理生成参数", () => { + test("切换模型标识时清理旧模型的能力档位参数,回落全局配置", () => { + const node = imageNode({ + model: "default::gpt-image-2", + size: "1:1", + quality: "high", + count: 2, + transparentBackground: "true", + }); + const next = applyNodeConfigPatch(node, { model: "default::grok-imagine-video" }); + expect(next.metadata?.model).toBe("default::grok-imagine-video"); + expect(next.metadata?.size).toBeUndefined(); + expect(next.metadata?.quality).toBeUndefined(); + expect(next.metadata?.count).toBeUndefined(); + expect(next.metadata?.transparentBackground).toBeUndefined(); + }); + + test("视频参数随模型切换一并清理", () => { + const node = imageNode({ + model: "default::seedance-video", + seconds: "10", + vquality: "1080p", + generateAudio: "true", + watermark: "false", + }); + const next = applyNodeConfigPatch(node, { model: "default::veo-3" }); + expect(next.metadata?.seconds).toBeUndefined(); + expect(next.metadata?.vquality).toBeUndefined(); + expect(next.metadata?.generateAudio).toBeUndefined(); + expect(next.metadata?.watermark).toBeUndefined(); + }); + + test("同一模型标识的参数调整不受影响", () => { + const node = imageNode({ model: "default::gpt-image-2", size: "1:1", quality: "high" }); + const next = applyNodeConfigPatch(node, { model: "default::gpt-image-2", quality: "medium" }); + expect(next.metadata?.model).toBe("default::gpt-image-2"); + expect(next.metadata?.quality).toBe("medium"); + expect(next.metadata?.size).toBe("1:1"); + }); + + test("非模型 patch(仅调整参数)不触发清理", () => { + const node = imageNode({ model: "default::gpt-image-2", size: "1:1" }); + const next = applyNodeConfigPatch(node, { size: "16:9" }); + expect(next.metadata?.size).toBe("16:9"); + expect(next.metadata?.model).toBe("default::gpt-image-2"); + }); + + test("模型切换时同批显式参数仍优先于清理", () => { + const node = imageNode({ model: "default::gpt-image-2", size: "1:1", quality: "high" }); + const next = applyNodeConfigPatch(node, { model: "default::grok-imagine-video", size: "16:9" }); + expect(next.metadata?.model).toBe("default::grok-imagine-video"); + expect(next.metadata?.size).toBe("16:9"); + expect(next.metadata?.quality).toBeUndefined(); + }); + + test("内容型字段不随模型切换清理", () => { + const node = imageNode({ model: "default::gpt-image-2", prompt: "一只猫", composerContent: "生成一只猫" }); + const next = applyNodeConfigPatch(node, { model: "default::grok-imagine-video" }); + expect(next.metadata?.prompt).toBe("一只猫"); + expect(next.metadata?.composerContent).toBe("生成一只猫"); + }); +});