287 lines
18 KiB
TypeScript
287 lines
18 KiB
TypeScript
import { describe, expect, test } from "bun:test";
|
||
|
||
import { buildNodeGenerationContext } from "../src/components/canvas/canvas-node-generation";
|
||
import { buildCanvasResourceReferences, getGenerationResourceNodes, normalizeCanvasNodeMentionTokens } from "../src/lib/canvas/canvas-resource-references";
|
||
import { CanvasNodeType, type CanvasConnection, type CanvasNodeData } from "../src/types/canvas";
|
||
|
||
function node(id: string, type: CanvasNodeType, content: string): CanvasNodeData {
|
||
return {
|
||
id,
|
||
type,
|
||
title: id,
|
||
position: { x: 0, y: 0 },
|
||
width: 100,
|
||
height: 100,
|
||
metadata: { content },
|
||
};
|
||
}
|
||
|
||
function targetNode(): CanvasNodeData {
|
||
return {
|
||
id: "target",
|
||
type: CanvasNodeType.Video,
|
||
title: "target",
|
||
position: { x: 0, y: 0 },
|
||
width: 100,
|
||
height: 100,
|
||
metadata: { composerContent: "让 @图片1 进入画面" },
|
||
};
|
||
}
|
||
|
||
function connection(fromNodeId: string, toNodeId = "target"): CanvasConnection {
|
||
return { id: `connection-${fromNodeId}`, fromNodeId, toNodeId };
|
||
}
|
||
|
||
describe("canvas node generation position mentions", () => {
|
||
test("角色卡三视图可解析图片别名,编辑器与生成统一显示角色引用", () => {
|
||
const target = targetNode();
|
||
const character = node("character", CanvasNodeType.Text, "");
|
||
character.metadata = { workflowKind: "character", characterAssetId: "character-asset", characterVersionPolicy: "current" };
|
||
const nodes = [target, character];
|
||
const connections = [connection(character.id)];
|
||
const references = buildCanvasResourceReferences(nodes, connections, target.id);
|
||
const prompt = "严格参考 @图片1,保持服装一致。";
|
||
expect(normalizeCanvasNodeMentionTokens(prompt, references)).toBe("严格参考 @角色1,保持服装一致。");
|
||
const context = buildNodeGenerationContext(target.id, nodes, connections, prompt, []);
|
||
expect(context.prompt).toBe("严格参考 @图片1,保持服装一致。");
|
||
expect(context.characterReferences).toEqual([{ nodeId: character.id, assetId: "character-asset", requestedVersionId: undefined }]);
|
||
expect(() => buildNodeGenerationContext(target.id, nodes, connections, "参考 @图片2", [])).toThrow("@图片2");
|
||
});
|
||
|
||
test("角色卡与普通图片混合时共享图片序号,旧独立编号拒绝猜测迁移", () => {
|
||
const target = targetNode();
|
||
const character = node("character", CanvasNodeType.Text, "");
|
||
character.metadata = { workflowKind: "character", characterAssetId: "character-asset" };
|
||
const image = node("scene", CanvasNodeType.Image, "data:image/png;base64,AA==");
|
||
const nodes = [target, character, image];
|
||
const connections = [connection(character.id), connection(image.id)];
|
||
const prompt = "@角色1站在@图片2场景内。";
|
||
const references = buildCanvasResourceReferences(nodes, connections, target.id);
|
||
expect(normalizeCanvasNodeMentionTokens(prompt, references)).toBe(prompt);
|
||
const context = buildNodeGenerationContext(target.id, nodes, connections, prompt, []);
|
||
expect(context.prompt).toBe("@图片1站在@图片2场景内。");
|
||
expect(context.referenceImages.map((item) => item.id)).toEqual([image.id]);
|
||
expect(context.characterReferences.map((item) => item.nodeId)).toEqual([character.id]);
|
||
expect(() => buildNodeGenerationContext(target.id, nodes, connections, "@角色1站在@图片1场景内", [])).toThrow("@图片1");
|
||
});
|
||
|
||
test("混合角色、绘图和图片按连接位置编译,逆序提及不会重排资源", () => {
|
||
const character = node("character", CanvasNodeType.Text, "");
|
||
character.metadata = { workflowKind: "character", characterAssetId: "character-asset" };
|
||
const drawing = node("drawing", CanvasNodeType.Drawing, "");
|
||
drawing.metadata = { drawingId: "drawing-document" };
|
||
const image = node("scene", CanvasNodeType.Image, "data:image/png;base64,AA==");
|
||
const target = targetNode();
|
||
const nodes = [target, image, drawing, character];
|
||
const connections = [character, drawing, image].map((source) => connection(source.id));
|
||
const references = buildCanvasResourceReferences(nodes, connections, target.id).filter((reference) => reference.active);
|
||
expect(Object.fromEntries(references.map((reference) => [reference.nodeId, reference.label]))).toEqual({ character: "角色1", drawing: "绘图2", scene: "图片3" });
|
||
const context = buildNodeGenerationContext(target.id, nodes, connections, "@图片3背景,@绘图2@角色1保持一致", [], true);
|
||
expect(context.prompt).toBe("@图片3背景,@图片2@图片1保持一致");
|
||
expect(context.referenceImageSlots.map((slot) => slot.nodeId)).toEqual([character.id, drawing.id, image.id]);
|
||
expect(context.referenceImages.map((item) => item.id)).toEqual([drawing.id, image.id]);
|
||
expect(() => buildNodeGenerationContext(target.id, nodes, connections, "@角色10保持一致", [], true)).toThrow("@角色10");
|
||
});
|
||
|
||
test("父图无预览时仍可继承输入,显式子图输入优先且去重", () => {
|
||
const a = node("a", CanvasNodeType.Image, "");
|
||
a.metadata = { storageKey: "resource:a" };
|
||
const b = node("b", CanvasNodeType.Image, "b");
|
||
const root = node("root", CanvasNodeType.Image, "");
|
||
root.metadata = { isBatchRoot: true };
|
||
const child = node("child", CanvasNodeType.Image, "");
|
||
child.metadata = { batchRootId: root.id };
|
||
const nodes = [a, b, root, child];
|
||
const connections = [{ id: "a-root", fromNodeId: a.id, toNodeId: root.id }, { id: "group", fromNodeId: root.id, toNodeId: child.id }];
|
||
expect(getGenerationResourceNodes(child.id, nodes, connections).map((item) => item.id)).toEqual(["a"]);
|
||
expect(getGenerationResourceNodes(child.id, nodes, [...connections, { id: "b-child", fromNodeId: b.id, toNodeId: child.id }, { id: "b-child-duplicate", fromNodeId: b.id, toNodeId: child.id }]).map((item) => item.id)).toEqual(["b"]);
|
||
root.metadata = { isBatchRoot: true, batchRootId: child.id };
|
||
child.metadata = { isBatchRoot: true, batchRootId: root.id };
|
||
expect(getGenerationResourceNodes(child.id, nodes, [])).toEqual([]);
|
||
});
|
||
test("批次子图沿父批次继承原始参考图,不把结果分组边当作参考图", () => {
|
||
const a = node("a", CanvasNodeType.Image, "data:image/png;base64,a");
|
||
const b = node("b", CanvasNodeType.Image, "data:image/png;base64,b");
|
||
const root = node("root", CanvasNodeType.Image, "data:image/png;base64,result");
|
||
root.metadata = { ...root.metadata, isBatchRoot: true, batchChildIds: ["child"] };
|
||
const child = node("child", CanvasNodeType.Image, "");
|
||
child.metadata = { batchRootId: root.id, composerContent: "结合 @图片1 和 @图片2" };
|
||
const nodes = [a, b, root, child];
|
||
const connections = [
|
||
{ id: "a-root", fromNodeId: a.id, toNodeId: root.id },
|
||
{ id: "b-root", fromNodeId: b.id, toNodeId: root.id },
|
||
{ id: "group", fromNodeId: root.id, toNodeId: child.id },
|
||
];
|
||
const context = buildNodeGenerationContext(child.id, nodes, connections, child.metadata.composerContent!, []);
|
||
expect(context.referenceImages.map((image) => image.id)).toEqual([a.id, b.id]);
|
||
expect(buildCanvasResourceReferences(nodes, connections, child.id).filter((ref) => ref.active).map((ref) => ref.nodeId)).toEqual([a.id, b.id]);
|
||
});
|
||
|
||
test("Agent 保存的素材引用块在编辑器和再次提交时保持相同编号", () => {
|
||
const target = targetNode();
|
||
const character = node("character", CanvasNodeType.Image, "data:image/png;base64,a");
|
||
const wig = node("wig", CanvasNodeType.Image, "data:image/png;base64,b");
|
||
const voice = node("voice", CanvasNodeType.Audio, "data:audio/mpeg;base64,c");
|
||
const note = node("note", CanvasNodeType.Markdown, "导演方案");
|
||
const prompt = "一镜到底\n\n【资产参考】\n人物:@图片1\n假发:@图片2\n声音:@音频1";
|
||
target.metadata = { composerContent: prompt, prompt, referenceNodeIds: [character.id, voice.id, wig.id] };
|
||
const nodes = [target, wig, voice, note, character];
|
||
const connections = [character, voice, wig, note].map((source) => connection(source.id));
|
||
const references = buildCanvasResourceReferences(nodes, connections, target.id).filter((reference) => reference.active);
|
||
|
||
expect(Object.fromEntries(references.map((reference) => [reference.nodeId, reference.label]))).toEqual({
|
||
character: "图片1", wig: "图片2", voice: "音频1", note: "文本1",
|
||
});
|
||
const context = buildNodeGenerationContext(target.id, nodes, connections, prompt, [], true);
|
||
expect(context.referenceImages.map((reference) => reference.id)).toEqual([character.id, wig.id]);
|
||
expect(context.referenceAudios.map((reference) => reference.id)).toEqual([voice.id]);
|
||
expect(context.prompt).toBe(prompt);
|
||
expect(context.textCount).toBe(0);
|
||
});
|
||
|
||
test("已有图片节点显式引用自身时作为图生图参考图提交", () => {
|
||
const source = node("image-self", CanvasNodeType.Image, "data:image/png;base64,a");
|
||
source.metadata.composerContent = "将 @图片1 图片变清晰";
|
||
|
||
const context = buildNodeGenerationContext(source.id, [source], [], source.metadata.composerContent, []);
|
||
|
||
expect(context.referenceImages.map((image) => image.id)).toEqual([source.id]);
|
||
expect(context.imageCount).toBe(1);
|
||
expect(context.prompt).toBe("将 @图片1 图片变清晰");
|
||
});
|
||
|
||
test("已有图片节点未显式引用自身时不自动退化为图生图", () => {
|
||
const source = node("image-self", CanvasNodeType.Image, "data:image/png;base64,a");
|
||
const context = buildNodeGenerationContext(source.id, [source], [], "生成一个新的构图", []);
|
||
|
||
expect(context.referenceImages).toEqual([]);
|
||
expect(context.imageCount).toBe(0);
|
||
expect(context.prompt).toBe("生成一个新的构图");
|
||
});
|
||
|
||
test("无法解析的画布引用会阻止静默降级为文生图", () => {
|
||
const target = targetNode();
|
||
expect(() => buildNodeGenerationContext(target.id, [target], [], "将 @图片1 图片变清晰", [])).toThrow("@图片1 没有对应的画布资源");
|
||
});
|
||
|
||
test("同一个 @图片1 在换线后自动指向新的第一张图片", () => {
|
||
const target = targetNode();
|
||
const imageA = node("image-a", CanvasNodeType.Image, "data:image/png;base64,a");
|
||
const imageB = node("image-b", CanvasNodeType.Image, "data:image/png;base64,b");
|
||
|
||
const before = buildNodeGenerationContext(target.id, [imageA, target], [connection(imageA.id)], "让 @图片1 进入画面", []);
|
||
const after = buildNodeGenerationContext(target.id, [imageB, target], [connection(imageB.id)], "让 @图片1 进入画面", []);
|
||
|
||
expect(before.referenceImages.map((image) => image.id)).toEqual(["image-a"]);
|
||
expect(after.referenceImages.map((image) => image.id)).toEqual(["image-b"]);
|
||
expect(before.prompt).toBe("让 @图片1 进入画面");
|
||
expect(after.prompt).toBe("让 @图片1 进入画面");
|
||
});
|
||
|
||
test("按类型位置选择资源,提示词出现顺序不会改变槽位含义", () => {
|
||
const target = targetNode();
|
||
const imageA = node("image-a", CanvasNodeType.Image, "data:image/png;base64,a");
|
||
const audioA = node("audio-a", CanvasNodeType.Audio, "data:audio/mpeg;base64,a");
|
||
const imageB = node("image-b", CanvasNodeType.Image, "data:image/png;base64,b");
|
||
const connections = [connection(imageA.id), connection(audioA.id), connection(imageB.id)];
|
||
const context = buildNodeGenerationContext(target.id, [imageA, audioA, imageB, target], connections, "让 @图片2 配合 @音频1", []);
|
||
|
||
expect(context.referenceImages.map((image) => image.id)).toEqual(["image-b"]);
|
||
expect(context.referenceAudios.map((audio) => audio.id)).toEqual(["audio-a"]);
|
||
expect(context.prompt).toBe("让 @图片1 配合 @音频1");
|
||
});
|
||
|
||
test("音频节点即使提示词只 @ 了文本,也会带上已连接的参考音频", () => {
|
||
const target: CanvasNodeData = {
|
||
id: "audio-target",
|
||
type: CanvasNodeType.Audio,
|
||
title: "audio-target",
|
||
position: { x: 0, y: 0 },
|
||
width: 220,
|
||
height: 160,
|
||
metadata: { composerContent: "参考 @文本1" },
|
||
};
|
||
const source = node("audio-source", CanvasNodeType.Audio, "data:audio/mpeg;base64,a");
|
||
const note = node("note", CanvasNodeType.Text, "旁白:你好");
|
||
const context = buildNodeGenerationContext(target.id, [source, note, target], [connection(source.id, target.id), connection(note.id, target.id)], "参考 @文本1", []);
|
||
|
||
expect(context.referenceAudios.map((audio) => audio.id)).toEqual(["audio-source"]);
|
||
expect(context.prompt).toContain("旁白:你好");
|
||
});
|
||
|
||
test("只有 storageKey 的已生成音频也可以作为音频节点参考输入", () => {
|
||
const target: CanvasNodeData = {
|
||
id: "audio-target",
|
||
type: CanvasNodeType.Audio,
|
||
title: "audio-target",
|
||
position: { x: 0, y: 0 },
|
||
width: 220,
|
||
height: 160,
|
||
metadata: {},
|
||
};
|
||
const source = node("audio-source", CanvasNodeType.Audio, "");
|
||
source.metadata = { storageKey: "resource:audio-source", mimeType: "audio/wav", durationMs: 2400 };
|
||
const context = buildNodeGenerationContext(target.id, [source, target], [connection(source.id, target.id)], "你好", []);
|
||
|
||
expect(context.referenceAudios).toHaveLength(1);
|
||
expect(context.referenceAudios[0]).toMatchObject({ id: source.id, storageKey: "resource:audio-source", type: "audio/wav" });
|
||
});
|
||
|
||
test("旧节点 token 只做读取迁移,不再进入生成提示词", () => {
|
||
const target = targetNode();
|
||
const image = node("image-a", CanvasNodeType.Image, "data:image/png;base64,a");
|
||
const context = buildNodeGenerationContext(target.id, [image, target], [connection(image.id)], "让 @[node:image-a] 进入画面", []);
|
||
|
||
expect(context.referenceImages.map((item) => item.id)).toEqual(["image-a"]);
|
||
expect(context.prompt).toBe("让 @图片1 进入画面");
|
||
expect(context.prompt).not.toContain("@[node:");
|
||
});
|
||
|
||
test("取消引用后的提示词不再因为悬挂 @图片1 阻断生成", () => {
|
||
const target = targetNode();
|
||
const image = node("image-a", CanvasNodeType.Image, "data:image/png;base64,a");
|
||
const { applyCanvasConnectionPromptSync } = require("../src/lib/canvas/canvas-resource-references") as typeof import("../src/lib/canvas/canvas-resource-references");
|
||
const [nextTarget] = applyCanvasConnectionPromptSync([image, target], [connection(image.id)], [image, target], []).filter((item: typeof target) => item.id === target.id);
|
||
const context = buildNodeGenerationContext(nextTarget.id, [image, nextTarget], [], nextTarget.metadata?.composerContent || "", []);
|
||
|
||
expect(nextTarget.metadata?.composerContent).toBe("让 进入画面");
|
||
expect(context.referenceImages).toEqual([]);
|
||
expect(context.prompt).toBe("让 进入画面");
|
||
});
|
||
});
|
||
|
||
describe("canvas node generation video text references", () => {
|
||
test("视频 promptOnly 下显式 @文本 引用仍内联真实内容,图片保持结构化引用", () => {
|
||
const target = targetNode();
|
||
const image = node("image-a", CanvasNodeType.Image, "data:image/png;base64,a");
|
||
const note = node("note", CanvasNodeType.Text, "角色设定:禾禾\n光影氛围:暗调");
|
||
const context = buildNodeGenerationContext(target.id, [image, note, target], [connection(image.id), connection(note.id)], "参考图:@图片1\n任务要求:@文本1", [], true);
|
||
|
||
expect(context.prompt).toBe("参考图:@图片1\n任务要求:【文本1】\n\n【文本1】\n角色设定:禾禾\n光影氛围:暗调");
|
||
expect(context.referenceImages.map((item) => item.id)).toEqual(["image-a"]);
|
||
expect(context.textCount).toBe(1);
|
||
expect(context.imageCount).toBe(1);
|
||
});
|
||
|
||
test("视频 promptOnly 下未显式引用的连线文本不会自动拼进提示词", () => {
|
||
const target = targetNode();
|
||
const image = node("image-a", CanvasNodeType.Image, "data:image/png;base64,a");
|
||
const note = node("note", CanvasNodeType.Text, "角色设定:禾禾");
|
||
const context = buildNodeGenerationContext(target.id, [image, note, target], [connection(image.id), connection(note.id)], "生成一段暗调舞蹈视频", [], true);
|
||
|
||
expect(context.prompt).toBe("生成一段暗调舞蹈视频");
|
||
expect(context.referenceImages.map((item) => item.id)).toEqual(["image-a"]);
|
||
expect(context.textCount).toBe(0);
|
||
});
|
||
|
||
test("视频 promptOnly 非显式引用路径仍丢弃上游文本,关闭后恢复自动拼接", () => {
|
||
const target = targetNode();
|
||
const note = node("note", CanvasNodeType.Text, "角色设定:禾禾");
|
||
const nodes = [note, target];
|
||
const connections = [connection(note.id)];
|
||
|
||
expect(buildNodeGenerationContext(target.id, nodes, connections, "跳舞", [], true).prompt).toBe("跳舞");
|
||
expect(buildNodeGenerationContext(target.id, nodes, connections, "跳舞", [], false).prompt).toBe("跳舞\n\n角色设定:禾禾");
|
||
});
|
||
});
|