fix(canvas): 节点模型配置 - 切换模型时清理生成参数残留
修复 issue #254;集成 PR #265,保留原 PR 开发者为提交作者。
This commit is contained in:
@@ -126,14 +126,41 @@ export function storyboardRowsFromTask(task: GenerationTask) {
|
||||
}
|
||||
|
||||
|
||||
// 模型切换时必须清理的节点级生成参数:这些参数属于旧模型的能力档位(分辨率/宽高比/质量等),
|
||||
// 新模型不支持时若残留,会在 buildNodeConfig 的「节点优先、全局兜底」合并中反复叠加(issue #254)。
|
||||
const NODE_MODEL_GENERATION_PARAMS: ReadonlyArray<keyof CanvasNodeMetadata> = [
|
||||
"size",
|
||||
"quality",
|
||||
"transparentBackground",
|
||||
"count",
|
||||
"seconds",
|
||||
"vquality",
|
||||
"generateAudio",
|
||||
"watermark",
|
||||
];
|
||||
|
||||
export function applyNodeConfigPatch(node: CanvasNodeData, patch: Partial<CanvasNodeMetadata>) {
|
||||
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<CanvasNodeMetadata>): Partial<CanvasNodeMetadata> {
|
||||
if (typeof patch.model !== "string" || patch.model === node.metadata?.model) {
|
||||
return patch;
|
||||
}
|
||||
const reset: Partial<CanvasNodeMetadata> = {};
|
||||
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,
|
||||
|
||||
78
web/test/canvas-node-config-reset.test.ts
Normal file
78
web/test/canvas-node-config-reset.test.ts
Normal file
@@ -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<string, unknown> = {}): 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("生成一只猫");
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user