feat(canvas): 画布节点 - 恢复批量创作表与转换能力

This commit is contained in:
ddCat
2026-09-15 20:56:19 +08:00
parent f59b59b3e6
commit c57f1b36ae
36 changed files with 3341 additions and 34 deletions

View File

@@ -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", () => {

View 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"));
});
});

View File

@@ -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", () => {

View 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" });
});
});