feat(canvas): 画布节点 - 恢复批量创作表与转换能力
This commit is contained in:
@@ -1,6 +1,7 @@
|
||||
import { describe, expect, it } from "bun:test";
|
||||
|
||||
import { buildBatchConnectionCreateRequest, hasBatchConnectionCandidate, planBatchConnections } from "@/lib/canvas/canvas-batch-connection";
|
||||
import { canvasConnectionError } from "@/lib/canvas/canvas-connection-policy";
|
||||
import { canvasConnectionPath } from "@/components/canvas/canvas-connections";
|
||||
import { defaultConfig } from "@/stores/use-config-store";
|
||||
import { CanvasNodeType, type CanvasConnection, type CanvasNodeData } from "@/types/canvas";
|
||||
@@ -13,11 +14,17 @@ const nodes: CanvasNodeData[] = [
|
||||
{ id: "script", type: CanvasNodeType.Script, title: "分镜脚本", position: { x: 520, y: 0 }, width: 640, height: 520, metadata: { storyboard: { rows: [{ id: "row-1", shotNumber: 1, durationSeconds: 6, plotDescription: "", dialogue: "", characters: [], narrativeIntent: "", viewerPOV: "", performanceBlocking: "", shotSize: "", emotion: "", lightingAndAtmosphere: "", audioEffects: "", camera: "", motion: "", timeBeats: "", imageGenerationPrompt: "", videoMotionPrompt: "", mustHave: [], optionalDetails: [], continuityOut: "", negativePrompt: "", assetBindings: [], status: "idle" }] } } },
|
||||
{ id: "config", type: CanvasNodeType.Config, title: "图片配置", position: { x: 520, y: 560 }, width: 360, height: 420, metadata: { generationMode: "image" } },
|
||||
{ id: "frame", type: CanvasNodeType.Frame, title: "背板", position: { x: 0, y: 680 }, width: 500, height: 500 },
|
||||
{ id: "batch-table", type: CanvasNodeType.BatchTable, title: "批量创作表", position: { x: 1000, y: 0 }, width: 900, height: 520 },
|
||||
];
|
||||
|
||||
const baseConfig = { ...defaultConfig };
|
||||
|
||||
describe("planBatchConnections", () => {
|
||||
it("only accepts image inputs for the batch creation table", () => {
|
||||
expect(canvasConnectionError(baseConfig, nodes, [], { fromNodeId: "image-a", toNodeId: "batch-table" })).toBe("");
|
||||
expect(canvasConnectionError(baseConfig, nodes, [], { fromNodeId: "text-a", toNodeId: "batch-table" })).toContain("批量创作表节点只接受图片输入");
|
||||
});
|
||||
|
||||
it("plans all legal source nodes and preserves the target handle", () => {
|
||||
const result = planBatchConnections({
|
||||
sourceNodeIds: ["text-a", "text-b"],
|
||||
@@ -91,6 +98,35 @@ describe("planBatchConnections", () => {
|
||||
expect(result.connected).toEqual(["image-a", "image-b"]);
|
||||
expect(result.connections).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("connects every selected image to a newly created batch table", () => {
|
||||
const result = planBatchConnections({
|
||||
sourceNodeIds: ["image-a", "image-b"],
|
||||
targetNodeId: "batch-table",
|
||||
nodes,
|
||||
connections: [],
|
||||
config: baseConfig,
|
||||
allowCapacityOverflow: true,
|
||||
});
|
||||
|
||||
expect(result.connected).toEqual(["image-a", "image-b"]);
|
||||
expect(result.connections.every((connection) => connection.toNodeId === "batch-table")).toBe(true);
|
||||
expect(result.skipped).toEqual([]);
|
||||
});
|
||||
|
||||
it("preserves a batch table reference-column target handle", () => {
|
||||
const result = planBatchConnections({
|
||||
sourceNodeIds: ["image-a"],
|
||||
targetNodeId: "batch-table",
|
||||
targetHandleId: "batch-reference:reference-2",
|
||||
nodes,
|
||||
connections: [],
|
||||
config: baseConfig,
|
||||
allowCapacityOverflow: true,
|
||||
});
|
||||
|
||||
expect(result.connections[0]?.toHandleId).toBe("batch-reference:reference-2");
|
||||
});
|
||||
});
|
||||
|
||||
describe("canvas connection anchors", () => {
|
||||
|
||||
88
web/test/canvas-batch-table.test.ts
Normal file
88
web/test/canvas-batch-table.test.ts
Normal file
@@ -0,0 +1,88 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
|
||||
import {
|
||||
batchReferenceHandleAtY,
|
||||
batchReferenceHandleId,
|
||||
batchReferenceMentionToken,
|
||||
createBatchRowsFromColumns,
|
||||
createBatchRowsFromInputs,
|
||||
createInheritedBatchRow,
|
||||
TRY_ON_BATCH_PROMPT,
|
||||
} from "@/lib/canvas/canvas-batch-table";
|
||||
import { createCanvasNode } from "@/lib/canvas/canvas-project-domain";
|
||||
import { CanvasNodeType } from "@/types/canvas";
|
||||
|
||||
describe("batch creation table", () => {
|
||||
test("creates one try-on row per person with the final image as the shared garment", () => {
|
||||
const rows = createBatchRowsFromInputs("try_on", ["person-1", "person-2", "person-3", "garment"]);
|
||||
|
||||
expect(rows.map((row) => row.inputNodeIds)).toEqual([
|
||||
["person-1", "garment"],
|
||||
["person-2", "garment"],
|
||||
["person-3", "garment"],
|
||||
]);
|
||||
expect(rows.every((row) => row.prompt === TRY_ON_BATCH_PROMPT)).toBe(true);
|
||||
});
|
||||
|
||||
test("creates one creative row per connected image", () => {
|
||||
expect(createBatchRowsFromInputs("creative", ["a", "b"]).map((row) => row.inputNodeIds)).toEqual([["a"], ["b"]]);
|
||||
});
|
||||
|
||||
test("broadcasts a singleton reference column across rows", () => {
|
||||
expect(createBatchRowsFromColumns("try_on", [["person-1", "person-2"], ["garment"]]).map((row) => row.inputNodeIds)).toEqual([
|
||||
["person-1", "garment"],
|
||||
["person-2", "garment"],
|
||||
]);
|
||||
});
|
||||
|
||||
test("inherits the previous row references when adding a manual task", () => {
|
||||
const row = createInheritedBatchRow("try_on", [{ id: "row-1", enabled: true, inputNodeIds: ["person-1", "garment"], prompt: "自定义提示词" }]);
|
||||
|
||||
expect(row.inputNodeIds).toEqual(["person-1", "garment"]);
|
||||
expect(row.inputNodeIds).not.toBe(createInheritedBatchRow("try_on", [{ id: "row-1", enabled: true, inputNodeIds: ["person-1", "garment"], prompt: "自定义提示词" }]).inputNodeIds);
|
||||
expect(row.prompt).toBe(TRY_ON_BATCH_PROMPT);
|
||||
});
|
||||
|
||||
test("syncing connections preserves unmatched manual rows", () => {
|
||||
const manual = { id: "manual-row", enabled: false, inputNodeIds: ["manual-image"], prompt: "保留手工任务" };
|
||||
const rows = createBatchRowsFromColumns("try_on", [["person-1"], ["garment"]], [manual]);
|
||||
|
||||
expect(rows).toHaveLength(2);
|
||||
expect(rows[1]).toEqual(manual);
|
||||
});
|
||||
|
||||
test("syncing unchanged references keeps the row identity and linked output", () => {
|
||||
const existing = { id: "row-1", enabled: true, inputNodeIds: ["person-1", "garment"], prompt: "保留提示词", outputNodeId: "output-1" };
|
||||
const [row] = createBatchRowsFromColumns("try_on", [["person-1"], ["garment"]], [existing]);
|
||||
|
||||
expect(row).toEqual(existing);
|
||||
});
|
||||
|
||||
test("uses stable prompt mention tokens for positional references", () => {
|
||||
expect(batchReferenceMentionToken(0)).toBe("@参考图1");
|
||||
expect(batchReferenceMentionToken(1)).toBe("@参考图2");
|
||||
});
|
||||
|
||||
test("new canvas node has durable batch defaults", () => {
|
||||
const node = createCanvasNode(CanvasNodeType.BatchTable, { x: 500, y: 300 });
|
||||
|
||||
expect(node.title).toBe("批量创作表");
|
||||
expect(node.metadata?.batchTable).toEqual({ operation: "try_on", concurrency: 10, referenceColumns: [{ id: "reference-1", label: "参考图 1" }, { id: "reference-2", label: "参考图 2" }, { id: "reference-3", label: "参考图 3" }], rows: [] });
|
||||
});
|
||||
test("keeps the second reference handle addressable", () => {
|
||||
const node = createCanvasNode(CanvasNodeType.BatchTable, { x: 500, y: 300 });
|
||||
const referenceColumns = [
|
||||
{ id: "reference-1", label: "参考图 1" },
|
||||
{ id: "reference-2", label: "参考图 2" },
|
||||
];
|
||||
node.metadata = { ...node.metadata, batchTable: { operation: "try_on", concurrency: 10, referenceColumns, rows: [] } };
|
||||
|
||||
expect(batchReferenceHandleAtY(node, node.position.y + 112 + 38)).toBe(batchReferenceHandleId("reference-2"));
|
||||
});
|
||||
|
||||
test("chooses the nearest reference handle when magnetic hit areas overlap", () => {
|
||||
const node = createCanvasNode(CanvasNodeType.BatchTable, { x: 500, y: 300 });
|
||||
|
||||
expect(batchReferenceHandleAtY(node, node.position.y + 112 + 38, 90)).toBe(batchReferenceHandleId("reference-2"));
|
||||
});
|
||||
});
|
||||
@@ -10,8 +10,10 @@ test("connection menu omits the header and provides descriptions for every optio
|
||||
expect(component).not.toContain("ChevronRight");
|
||||
expect(component).not.toContain("absolute inset-x-8 top-0 h-px");
|
||||
const options = component.match(/<ConnectionCreateOption [^\n]+/g) || [];
|
||||
expect(options).toHaveLength(8);
|
||||
expect(options).toHaveLength(9);
|
||||
for (const option of options) expect(option).toMatch(/description="[^"]+"/);
|
||||
expect(component).toContain('title="批量创作表"');
|
||||
expect(component).toContain("onCreate(CanvasNodeType.BatchTable)");
|
||||
});
|
||||
|
||||
test("add-node submenu uses a four-column medium-size grid", () => {
|
||||
|
||||
108
web/test/media-conversion.test.ts
Normal file
108
web/test/media-conversion.test.ts
Normal file
@@ -0,0 +1,108 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
|
||||
import { canvasConnectionError } from "../src/lib/canvas/canvas-connection-policy";
|
||||
import { findPendingMediaConversionInput } from "../src/components/canvas/canvas-node-generation";
|
||||
import { getNodeAcceptedInputKinds, getNodeMaxInputCount, getNodeResourceKind } from "../src/lib/canvas/node-registry";
|
||||
import { mediaConversionSourceFingerprint } from "../src/lib/media-conversion/contracts";
|
||||
import { convertImageLocally, LocalImageConversionError, transformPixels } from "../src/lib/media-conversion/local-converter";
|
||||
import { CanvasNodeType, type CanvasConnection, type CanvasNodeData } from "../src/types/canvas";
|
||||
import type { AiConfig } from "../src/stores/use-config-store";
|
||||
|
||||
function node(type: CanvasNodeType, id: string, metadata: CanvasNodeData["metadata"] = {}): CanvasNodeData {
|
||||
return { id, type, title: id, position: { x: 0, y: 0 }, width: 380, height: 440, metadata };
|
||||
}
|
||||
|
||||
const config = {} as AiConfig;
|
||||
|
||||
describe("本地转换节点注册", () => {
|
||||
test("只允许一个图片或视频输入,完成后才作为图片素材", () => {
|
||||
expect(getNodeAcceptedInputKinds(CanvasNodeType.MediaConversion)).toEqual(["image", "video"]);
|
||||
expect(getNodeMaxInputCount(CanvasNodeType.MediaConversion)).toBe(1);
|
||||
expect(getNodeResourceKind(node(CanvasNodeType.MediaConversion, "conversion"))).toBeNull();
|
||||
expect(getNodeResourceKind(node(CanvasNodeType.MediaConversion, "conversion", {
|
||||
mediaConversion: { schemaVersion: 1, operation: "grayscale", status: "completed", resultStorageKey: "image:scope:result" },
|
||||
}))).toBe("image");
|
||||
expect(getNodeResourceKind(node(CanvasNodeType.MediaConversion, "conversion", {
|
||||
mediaConversion: { schemaVersion: 1, operation: "pose", status: "skipped", detectedPeople: 0 },
|
||||
}))).toBeNull();
|
||||
});
|
||||
|
||||
test("连接规则接受图片和视频,拒绝没有可读媒体的节点及第二个输入", () => {
|
||||
const image = node(CanvasNodeType.Image, "image");
|
||||
const video = node(CanvasNodeType.Video, "video");
|
||||
const text = node(CanvasNodeType.Text, "text");
|
||||
const conversion = node(CanvasNodeType.MediaConversion, "conversion");
|
||||
const nodes = [image, video, text, conversion];
|
||||
const candidate = (fromNodeId: string): CanvasConnection => ({ id: `candidate-${fromNodeId}`, fromNodeId, toNodeId: conversion.id });
|
||||
|
||||
expect(canvasConnectionError(config, nodes, [], candidate(image.id))).toBe("");
|
||||
expect(canvasConnectionError(config, nodes, [], candidate(video.id))).toBe("");
|
||||
expect(canvasConnectionError(config, nodes, [], candidate(text.id))).toContain("转换节点只接受图片或视频输入");
|
||||
expect(canvasConnectionError(config, nodes, [{ id: "existing", fromNodeId: image.id, toNodeId: conversion.id }], candidate(video.id))).toContain("转换节点最多连接 1 个输入");
|
||||
});
|
||||
|
||||
test("下游生成会阻止未完成或待更新的转换结果", () => {
|
||||
const conversion = node(CanvasNodeType.MediaConversion, "conversion", {
|
||||
mediaConversion: { schemaVersion: 1, operation: "depth", status: "unavailable" },
|
||||
});
|
||||
const input = node(CanvasNodeType.Image, "input", { content: "data:image/png;base64,source" });
|
||||
const target = node(CanvasNodeType.Image, "target");
|
||||
const connections: CanvasConnection[] = [
|
||||
{ id: "input-conversion", fromNodeId: input.id, toNodeId: conversion.id },
|
||||
{ id: "conversion-target", fromNodeId: conversion.id, toNodeId: target.id },
|
||||
];
|
||||
expect(findPendingMediaConversionInput(target.id, [input, conversion, target], connections)?.id).toBe(conversion.id);
|
||||
conversion.metadata = { mediaConversion: { schemaVersion: 1, operation: "grayscale", status: "completed", sourceNodeId: input.id, sourceFingerprint: "v1-unknown", resultStorageKey: "image:scope:result" } };
|
||||
expect(findPendingMediaConversionInput(target.id, [input, conversion, target], connections)?.id).toBe(conversion.id);
|
||||
conversion.metadata.mediaConversion.sourceFingerprint = mediaConversionSourceFingerprint(input);
|
||||
expect(findPendingMediaConversionInput(target.id, [input, conversion, target], connections)).toBeNull();
|
||||
});
|
||||
|
||||
test("完成后的转换结果可作为生成参考图", async () => {
|
||||
const { nodeReferenceImage } = await import("../src/lib/canvas/canvas-project-generation");
|
||||
expect(nodeReferenceImage(node(CanvasNodeType.MediaConversion, "conversion", {
|
||||
storageKey: "image:scope:result",
|
||||
mimeType: "image/png",
|
||||
mediaConversion: {
|
||||
schemaVersion: 1,
|
||||
operation: "grayscale",
|
||||
status: "completed",
|
||||
resultStorageKey: "image:scope:result",
|
||||
},
|
||||
}))).toMatchObject({
|
||||
id: "conversion",
|
||||
storageKey: "image:scope:result",
|
||||
type: "image/png",
|
||||
});
|
||||
expect(nodeReferenceImage(node(CanvasNodeType.MediaConversion, "conversion", {
|
||||
mediaConversion: { schemaVersion: 1, operation: "grayscale", status: "processing" },
|
||||
}))).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("图片本地转换算法", () => {
|
||||
test("灰度转换保留 alpha 并使用感知亮度", () => {
|
||||
const pixels = new Uint8ClampedArray([255, 0, 0, 200, 0, 255, 0, 100]);
|
||||
const output = transformPixels(pixels, 2, 1, "grayscale");
|
||||
expect(Array.from(output)).toEqual([76, 76, 76, 200, 150, 150, 150, 100]);
|
||||
});
|
||||
|
||||
test("Canny 转换输出黑白边缘图并保留 alpha", () => {
|
||||
const pixels = new Uint8ClampedArray([
|
||||
0, 0, 0, 255, 0, 0, 0, 255, 255, 255, 255, 255,
|
||||
0, 0, 0, 255, 0, 0, 0, 255, 255, 255, 255, 255,
|
||||
0, 0, 0, 255, 0, 0, 0, 255, 255, 255, 255, 255,
|
||||
]);
|
||||
const output = transformPixels(pixels, 3, 3, "edge-canny");
|
||||
for (let index = 0; index < output.length; index += 4) {
|
||||
expect([0, 255]).toContain(output[index]);
|
||||
expect(output[index]).toBe(output[index + 1]);
|
||||
expect(output[index]).toBe(output[index + 2]);
|
||||
expect(output[index + 3]).toBe(255);
|
||||
}
|
||||
});
|
||||
|
||||
test("浏览器算法转换把模型操作交给本机 Runtime", async () => {
|
||||
await expect(convertImageLocally("", "depth")).rejects.toMatchObject<LocalImageConversionError>({ code: "model_missing" });
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user