feat(canvas): 图片转换插件 - 增加本地媒体转换节点与运行时
This commit is contained in:
87
web/test/media-conversion.test.ts
Normal file
87
web/test/media-conversion.test.ts
Normal file
@@ -0,0 +1,87 @@
|
||||
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();
|
||||
});
|
||||
});
|
||||
|
||||
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