feat(canvas): AI 拆图 - 支持图层规划编辑与素材复用
This commit is contained in:
33
web/test/canvas-derived-assets.test.ts
Normal file
33
web/test/canvas-derived-assets.test.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
import { expect, test } from "bun:test";
|
||||
import { canvasNodeToAsset, findCanvasNodeAsset, requiresMaterializedCanvasAsset } from "@/lib/canvas/canvas-node-asset";
|
||||
import { CanvasNodeType, type CanvasNodeData } from "@/types/canvas";
|
||||
import type { Asset } from "@/stores/use-asset-store";
|
||||
|
||||
const original: CanvasNodeData = {
|
||||
id: "layer",
|
||||
type: CanvasNodeType.Image,
|
||||
title: "前景",
|
||||
position: { x: 0, y: 0 },
|
||||
width: 400,
|
||||
height: 300,
|
||||
metadata: { taskId: "generation", content: "/original.png", storageKey: "resource:original", layerExtraction: { sourceNodeId: "source", groupId: "group", index: 1, phase: "complete" } },
|
||||
};
|
||||
|
||||
test("原生生成图层仍须复用服务端素材,不能因缓存缺失重复登记", () => {
|
||||
expect(requiresMaterializedCanvasAsset(original)).toBe(true);
|
||||
expect(requiresMaterializedCanvasAsset({ ...original, metadata: { ...original.metadata, layerExtraction: { ...original.metadata!.layerExtraction!, sourceResultStorageKey: "resource:original" } } })).toBe(true);
|
||||
});
|
||||
|
||||
test("归一化或修补后的新资源可独立保存且拒绝沿用原任务素材", () => {
|
||||
const derived = { ...original, metadata: { ...original.metadata, storageKey: "resource:normalized", layerExtraction: { ...original.metadata!.layerExtraction!, sourceResultStorageKey: "resource:original" } } };
|
||||
const asset = { ...canvasNodeToAsset(original, { canvasId: "canvas", source: "canvas-generation" }), id: "original-asset", createdAt: "", updatedAt: "" } as Asset;
|
||||
expect(requiresMaterializedCanvasAsset(derived)).toBe(false);
|
||||
expect(findCanvasNodeAsset([asset], original, "canvas")?.id).toBe("original-asset");
|
||||
expect(findCanvasNodeAsset([asset], derived, "canvas")).toBeUndefined();
|
||||
});
|
||||
|
||||
test("本地图层合成和手工上传可登记各自资源", () => {
|
||||
const composite = { ...original, metadata: { ...original.metadata, storageKey: "resource:composite", imageLayerGroup: { width: 400, height: 300, layers: [], compositeStatus: "ready" as const } } };
|
||||
expect(requiresMaterializedCanvasAsset(composite)).toBe(false);
|
||||
expect(requiresMaterializedCanvasAsset({ ...original, metadata: { content: "/upload.png", storageKey: "resource:upload" } })).toBe(false);
|
||||
});
|
||||
58
web/test/canvas-image-layer-generation.test.ts
Normal file
58
web/test/canvas-image-layer-generation.test.ts
Normal file
@@ -0,0 +1,58 @@
|
||||
import { expect, test } from "bun:test";
|
||||
import { experimentalLayerPrompt, imageLayerGenerationConfig } from "@/lib/canvas/canvas-image-layers";
|
||||
import { imageLayerPlanningPrompt, parseImageLayerPlan } from "@/lib/canvas/canvas-image-layer-plan";
|
||||
import { defaultConfig } from "@/stores/use-config-store";
|
||||
|
||||
const plan = {
|
||||
canPlan: true,
|
||||
layers: [
|
||||
{ name: "背景", description: "背景场景描述标记", kind: "background", generation: { prompt: "移除蓝色绒毯,补全其后地毯与沙发;保留书架和窗户", background: "opaque" } },
|
||||
{ name: "绒毯", description: "保留完整主体与透明背景的前景描述标记", kind: "object", generation: { prompt: "仅保留蓝色花朵绒毯,保持轮廓、纹理和原始位置", background: "transparent" }, bbox: [169, 38, 969, 955] },
|
||||
],
|
||||
};
|
||||
|
||||
test("背景只收到本层规划,不混入对象描述或前景生成指令", () => {
|
||||
const parsed = parseImageLayerPlan(JSON.stringify(plan));
|
||||
const background = experimentalLayerPrompt(parsed.layers[0].generation);
|
||||
const foreground = experimentalLayerPrompt(parsed.layers[1].generation, parsed.layers[1].bbox);
|
||||
expect(background).toContain("移除蓝色绒毯");
|
||||
expect(background).toContain("alpha=255");
|
||||
expect(background).not.toContain(plan.layers[1].description);
|
||||
expect(background).not.toContain(plan.layers[1].generation.prompt);
|
||||
expect(background).not.toContain("alpha=0");
|
||||
expect(foreground).toContain("仅保留蓝色花朵绒毯");
|
||||
expect(foreground).toContain("<bbox>169 38 969 955</bbox>");
|
||||
expect(foreground).toContain("alpha=0");
|
||||
expect(foreground).not.toContain(plan.layers[0].generation.prompt);
|
||||
});
|
||||
|
||||
test("按规划的本层透明度生成,保持模型、源图尺寸及默认质量", () => {
|
||||
const parsed = parseImageLayerPlan(JSON.stringify(plan));
|
||||
const config = { ...defaultConfig, model: "chosen", size: "1672x941", quality: "medium", count: "4" };
|
||||
expect(imageLayerGenerationConfig(config, parsed.layers[0].generation, 0)).toMatchObject({ model: "chosen", size: "1672x941", quality: "medium", count: "1", transparentBackground: "false" });
|
||||
expect(imageLayerGenerationConfig(config, parsed.layers[1].generation, 1).transparentBackground).toBe("true");
|
||||
expect(config.count).toBe("4");
|
||||
});
|
||||
|
||||
test("缺失、空白、过长、角色冲突或额外参数的规划在图像调用前停止", () => {
|
||||
for (const generation of [
|
||||
undefined,
|
||||
{},
|
||||
{ prompt: " ", background: "opaque" },
|
||||
{ prompt: "x".repeat(2401), background: "opaque" },
|
||||
{ prompt: "错误背景", background: "transparent" },
|
||||
{ prompt: "合法提示", background: "opaque", model: "other" },
|
||||
{ prompt: "合法提示", background: "opaque", size: "1:1" },
|
||||
]) {
|
||||
const value = { ...plan, layers: [{ ...plan.layers[0], generation }, plan.layers[1]] };
|
||||
expect(() => parseImageLayerPlan(JSON.stringify(value))).toThrow("未提交拆图任务");
|
||||
}
|
||||
expect(() => parseImageLayerPlan(JSON.stringify({ ...plan, layers: [plan.layers[0], { ...plan.layers[1], generation: { prompt: "对象", background: "opaque" } }] }))).toThrow("图层角色冲突");
|
||||
});
|
||||
|
||||
test("规划合同要求每张图有独立参数且不粘贴跨层指令", () => {
|
||||
const prompt = imageLayerPlanningPrompt("拆分背景和主体");
|
||||
expect(prompt).toContain("每层 generation 必填");
|
||||
expect(prompt).toContain("不会收到其他层描述或用户总体要求");
|
||||
expect(prompt).toContain("不能粘贴前景");
|
||||
});
|
||||
332
web/test/canvas-image-layer-plan.test.ts
Normal file
332
web/test/canvas-image-layer-plan.test.ts
Normal file
@@ -0,0 +1,332 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import { defaultModelCapabilityConfig } from "@/lib/model-capabilities";
|
||||
import { resolveImageRequestSize, validateImageSize } from "@/services/api/image-validation";
|
||||
import { applyImageLayerPlanningPurpose, imageLayerOutputSize, imageLayerPlannerError, imageLayerPlanningPrompt, imageLayerPlanTargets, imageLayerTargetName, latestImageLayerPlan, parseImageLayerPlan } from "@/lib/canvas/canvas-image-layer-plan";
|
||||
import type { GenerationTask } from "@/services/api/task-center";
|
||||
import { supportsExperimentalLayerExtraction } from "@/lib/canvas/canvas-image-layers";
|
||||
import { defaultConfig, createModelChannel, selectableModelsByCapability } from "@/stores/use-config-store";
|
||||
|
||||
const vision = defaultModelCapabilityConfig();
|
||||
vision.text!.references.maxImages = 2;
|
||||
vision.text!.references.maxImageBytes = 30 * 1024 * 1024;
|
||||
const blind = defaultModelCapabilityConfig();
|
||||
const image = defaultModelCapabilityConfig("km-kemei-seedream", "doubao-seedream-5-0-pro-260628");
|
||||
const channel = createModelChannel({
|
||||
id: "models",
|
||||
models: ["visual", "blind", "editor"],
|
||||
modelCosts: [
|
||||
{ model: "visual", capability: "text", billingMode: "token", unitPriceMicrocredits: 1, capabilityConfig: vision },
|
||||
{ model: "blind", capability: "text", billingMode: "token", unitPriceMicrocredits: 1, capabilityConfig: blind },
|
||||
{ model: "editor", capability: "image", billingMode: "fixed_request", unitPriceMicrocredits: 1, capabilityConfig: image },
|
||||
],
|
||||
});
|
||||
const config = { ...defaultConfig, channels: [channel] };
|
||||
const backgroundGeneration = { prompt: "保留环境,移除已拆对象并补全遮挡", background: "opaque" };
|
||||
const objectGeneration = { prompt: "只保留本层对象的原始轮廓、位置和细节", background: "transparent" };
|
||||
|
||||
test("默认背景前景拆分将旧副本规划转为修补背景,保留完整场景的原像素提取", () => {
|
||||
const oldCopy = parseImageLayerPlan(
|
||||
JSON.stringify({
|
||||
canPlan: true,
|
||||
layout: "composition",
|
||||
layers: [
|
||||
{ name: "原图底板", description: "保留原图", kind: "background", generation: backgroundGeneration, extraction: { method: "source" } },
|
||||
{ name: "场景", description: "保留内部背景", kind: "object", generation: objectGeneration, editUnit: "scene", removeFromBackground: false, bbox: [100, 200, 600, 800], extraction: { method: "source-region", shape: "rect" } },
|
||||
{ name: "标题", description: "标题文字", kind: "object", generation: objectGeneration, editUnit: "text", removeFromBackground: false, extraction: { method: "generate" } },
|
||||
],
|
||||
}),
|
||||
);
|
||||
const split = applyImageLayerPlanningPurpose(oldCopy, "recompose");
|
||||
expect(split.layers.map((layer) => layer.removeFromBackground)).toEqual([undefined, true, true]);
|
||||
expect(split.layers.map((layer) => layer.extraction?.method)).toEqual(["generate", "source-region", "generate"]);
|
||||
expect(split.layers[1].extraction?.region?.bbox).toEqual([100, 200, 600, 800]);
|
||||
expect(oldCopy.layers[0].extraction?.method).toBe("source");
|
||||
expect(oldCopy.layers[1].removeFromBackground).toBe(false);
|
||||
expect(imageLayerPlanningPrompt("自动拆分")).toContain("不是原图副本");
|
||||
expect(imageLayerPlanningPrompt("自动拆分", undefined, "materials")).toContain("无需重画底板");
|
||||
});
|
||||
test("规划依据与编辑单元可检查;无效可选字段不破坏已有计划", () => {
|
||||
const layers = [
|
||||
{ name: "底图", description: "保留原图", kind: "background", generation: backgroundGeneration, extraction: { method: "source" } },
|
||||
{ name: "面板", description: "完整内部场景", kind: "object", generation: objectGeneration, editUnit: "scene", removeFromBackground: false, extraction: { method: "source-region", shape: "rect" } },
|
||||
];
|
||||
const reasoning = { structure: "两个独立容器", editingGoal: "复用完整组件", strategy: "复制场景原像素,边界由用户框选" };
|
||||
const plan = parseImageLayerPlan(JSON.stringify({ canPlan: true, layers, reasoning }));
|
||||
expect(plan.reasoning).toEqual(reasoning);
|
||||
expect(plan.layers[1].editUnit).toBe("scene");
|
||||
expect(plan.layers[1].extraction?.method).toBe("source-region");
|
||||
expect(plan.layers[1].extraction?.region).toBeUndefined();
|
||||
const invalid = parseImageLayerPlan(JSON.stringify({ canPlan: true, layers: [layers[0], { ...layers[1], editUnit: "anything" }], reasoning: { structure: 8 } }));
|
||||
expect(invalid.layers).toHaveLength(2);
|
||||
expect(invalid.reasoning).toBeUndefined();
|
||||
expect(invalid.layers[1].editUnit).toBeUndefined();
|
||||
expect(invalid.warnings?.length).toBeGreaterThan(1);
|
||||
});
|
||||
function response(names = ["背景", "人物", "花束", "标题文字"]) {
|
||||
return {
|
||||
canPlan: true,
|
||||
layers: names.map((name, index) => ({
|
||||
name,
|
||||
description: `只保留${name},排除其他层`,
|
||||
kind: index === 0 ? "background" : "object",
|
||||
generation: index === 0 ? backgroundGeneration : objectGeneration,
|
||||
...(index ? { bbox: [100, 200, 800, 900] } : {}),
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
describe("通用图层规划", () => {
|
||||
test("连续场景局部裁块不自动作为素材输出,不把可疑裁图改成收费重画", () => {
|
||||
const layers = [
|
||||
{ name: "底图", description: "原图", kind: "background", generation: backgroundGeneration, extraction: { method: "source" } },
|
||||
{ name: "主要主体", description: "完整轮廓", kind: "object", generation: objectGeneration, editUnit: "object", extraction: { method: "generate" } },
|
||||
{ name: "背景局部", description: "画幅内一角", kind: "object", generation: objectGeneration, editUnit: "group", bbox: [100, 100, 400, 300], extraction: { method: "source-region", shape: "rect" } },
|
||||
];
|
||||
const raw = parseImageLayerPlan(JSON.stringify({ canPlan: true, layout: "continuous", layers }));
|
||||
const next = applyImageLayerPlanningPurpose(raw, "materials");
|
||||
expect(next.layers.map((layer) => layer.name)).toEqual(["原图底板", "主要主体"]);
|
||||
expect(next.warnings?.join(" ")).toContain("未自动裁成素材");
|
||||
expect(applyImageLayerPlanningPurpose({ ...raw, layout: "composition" }, "materials").layers).toHaveLength(3);
|
||||
expect(applyImageLayerPlanningPurpose({ ...raw, layout: "mixed" }, "materials").layers).toHaveLength(3);
|
||||
expect(() => applyImageLayerPlanningPurpose({ ...raw, layers: [raw.layers[0], raw.layers[2]] }, "materials")).toThrow("没有可靠的独立素材");
|
||||
});
|
||||
test("素材目的纠正模型重画底板和移除项,保留区域与主体各自的提取方式", () => {
|
||||
const raw = parseImageLayerPlan(
|
||||
JSON.stringify({
|
||||
canPlan: true,
|
||||
layers: [
|
||||
{ name: "底板", description: "清空内容", kind: "background", generation: backgroundGeneration, extraction: { method: "generate" } },
|
||||
{
|
||||
name: "完整插图",
|
||||
description: "完整内部环境",
|
||||
kind: "object",
|
||||
generation: objectGeneration,
|
||||
editUnit: "scene",
|
||||
removeFromBackground: true,
|
||||
bbox: [50, 150, 480, 540],
|
||||
extraction: { method: "source-region", shape: "rounded-rect", radiusRatio: 0.06 },
|
||||
},
|
||||
{ name: "不规则主体", description: "主体轮廓", kind: "object", generation: objectGeneration, editUnit: "object", removeFromBackground: true, extraction: { method: "generate" } },
|
||||
],
|
||||
}),
|
||||
);
|
||||
const plan = applyImageLayerPlanningPurpose(raw, "materials");
|
||||
expect(plan.layers[0].extraction).toEqual({ method: "source" });
|
||||
expect(plan.layers[1].extraction).toEqual(raw.layers[1].extraction);
|
||||
expect(plan.layers[2].extraction).toEqual({ method: "generate" });
|
||||
expect(plan.layers.slice(1).every((layer) => layer.removeFromBackground === false)).toBe(true);
|
||||
expect(raw.layers[0].extraction?.method).toBe("generate");
|
||||
const recomposed = applyImageLayerPlanningPurpose(raw, "recompose");
|
||||
expect(recomposed.layers[0].extraction?.method).toBe("generate");
|
||||
expect(recomposed.layers[1].removeFromBackground).toBe(true);
|
||||
});
|
||||
test("完整场景分类与付费重画冲突时等待框选,不猜形状、不静默生成", () => {
|
||||
for (const extraction of [undefined, { method: "generate" }]) {
|
||||
const raw = parseImageLayerPlan(
|
||||
JSON.stringify({
|
||||
canPlan: true,
|
||||
layers: [
|
||||
{ name: "底图", description: "保留", kind: "background", generation: backgroundGeneration, extraction: { method: "source" } },
|
||||
{ name: "完整场景", description: "照片", kind: "object", generation: objectGeneration, editUnit: "scene", bbox: [100, 100, 500, 700], extraction },
|
||||
],
|
||||
}),
|
||||
);
|
||||
const planned = applyImageLayerPlanningPurpose(raw, "materials");
|
||||
expect(planned.layers[1].extraction).toEqual({ method: "source-region" });
|
||||
expect(planned.warnings?.join(" ")).toContain("请框选完整边界");
|
||||
}
|
||||
});
|
||||
test("重组目的存在移除项时不能原样复制底图,未知分类不冒充完整场景", () => {
|
||||
const plan = parseImageLayerPlan(
|
||||
JSON.stringify({
|
||||
canPlan: true,
|
||||
layers: [
|
||||
{ name: "底图", description: "保留", kind: "background", generation: backgroundGeneration, extraction: { method: "source" } },
|
||||
{ name: "复杂组合", description: "重叠轮廓", kind: "object", generation: objectGeneration, editUnit: "group", removeFromBackground: true, extraction: { method: "generate" } },
|
||||
],
|
||||
}),
|
||||
);
|
||||
const next = applyImageLayerPlanningPurpose(plan, "recompose");
|
||||
expect(next.layers[0].extraction?.method).toBe("generate");
|
||||
expect(next.layers[1].extraction?.method).toBe("generate");
|
||||
expect(imageLayerTargetName("场景名称:内部描述,区域 <bbox>0 0 200 500</bbox>")).toBe("场景名称");
|
||||
});
|
||||
test("识图模型的 extraction 内坐标仍按显式单位校验,冲突坐标不得静默选择", () => {
|
||||
const size = { width: 1000, height: 500 };
|
||||
const layer = {
|
||||
name: "场景",
|
||||
description: "完整场景与内部背景",
|
||||
kind: "object",
|
||||
generation: objectGeneration,
|
||||
editUnit: "scene",
|
||||
removeFromBackground: false,
|
||||
extraction: { method: "source-region", shape: "rounded-rect", radiusRatio: 0.04, bbox: [50, 100, 450, 400] },
|
||||
};
|
||||
const value = { canPlan: true, coordinateSpace: "pixels", imageSize: size, layers: [{ name: "底图", description: "保留原图", kind: "background", generation: backgroundGeneration, extraction: { method: "source" } }, layer] };
|
||||
const plan = parseImageLayerPlan(JSON.stringify(value), size);
|
||||
expect(plan.layers[1].extraction?.region?.bbox).toEqual([50, 200, 450, 800]);
|
||||
expect(plan.layers[1].extraction?.region?.shape).toBe("rounded-rect");
|
||||
expect(parseImageLayerPlan(JSON.stringify(value)).layers[1].extraction?.region).toBeUndefined();
|
||||
expect(parseImageLayerPlan(JSON.stringify(value), { width: 500, height: 250 }).layers[1].extraction?.region).toBeUndefined();
|
||||
const conflict = parseImageLayerPlan(JSON.stringify({ ...value, layers: [value.layers[0], { ...layer, bbox: [60, 100, 450, 400] }] }), size);
|
||||
expect(conflict.layers[1].extraction?.region).toBeUndefined();
|
||||
expect(conflict.warnings?.join(" ")).toContain("冲突");
|
||||
expect(parseImageLayerPlan(JSON.stringify({ ...value, layers: [value.layers[0], { ...layer, bbox: layer.extraction.bbox }] }), size).layers[1].bbox).toEqual([50, 200, 450, 800]);
|
||||
});
|
||||
test("原图比例在持久化任务前转为协议像素尺寸,提取与去背景都满足后端尺寸校验", () => {
|
||||
const profile = defaultModelCapabilityConfig("openai", "image-2.5-flare").image!;
|
||||
profile.size = { ...profile.size, parameter: "size", values: ["auto"], allowCustom: true };
|
||||
for (const source of [
|
||||
{ width: 731, height: 412 },
|
||||
{ width: 600, height: 900 },
|
||||
]) {
|
||||
const request = resolveImageRequestSize(profile, undefined, `${source.width}:${source.height}`)!;
|
||||
expect(request.parameter).toBe("size");
|
||||
expect(request.value).toMatch(/^\d+x\d+$/);
|
||||
const [width, height] = request.value.split("x").map(Number);
|
||||
expect(() => validateImageSize(width, height)).not.toThrow();
|
||||
expect(Math.abs(width / height / (source.width / source.height) - 1)).toBeLessThan(0.01);
|
||||
}
|
||||
});
|
||||
test("文本模型保持可选,只有选中的无识图能力模型报错,不猜模型名", () => {
|
||||
expect(selectableModelsByCapability(config, "text")).toContain("models::visual");
|
||||
expect(selectableModelsByCapability(config, "text")).toContain("models::blind");
|
||||
expect(imageLayerPlannerError(config, "models::visual")).toBe("");
|
||||
expect(imageLayerPlannerError(config, "models::blind")).toContain("未配置识图");
|
||||
expect(imageLayerPlannerError(config, "models::editor")).toContain("文本模型");
|
||||
expect(imageLayerPlannerError(config, "")).toContain("请选择");
|
||||
});
|
||||
test("普通拆层不局限 OpenAI 协议,必须是接受参考图的图片模型", () => {
|
||||
expect(supportsExperimentalLayerExtraction(config, "models::editor")).toBe(true);
|
||||
expect(supportsExperimentalLayerExtraction(config, "models::visual")).toBe(false);
|
||||
const noReferences = { ...image, image: { ...image.image!, references: { ...image.image!.references, maxImages: 0 } } };
|
||||
const altered = { ...config, channels: [{ ...channel, modelCosts: channel.modelCosts!.map((cost) => (cost.model === "editor" ? { ...cost, capabilityConfig: noReferences } : cost)) }] };
|
||||
expect(supportsExperimentalLayerExtraction(altered, "models::editor")).toBe(false);
|
||||
});
|
||||
test("规划层数和题材来自模型真实结果,允许二层和多层", () => {
|
||||
const plan = parseImageLayerPlan(JSON.stringify(response()));
|
||||
expect(plan.layers.map((layer) => layer.name)).toEqual(["背景", "人物", "花束", "标题文字"]);
|
||||
expect(imageLayerPlanTargets(plan)[1]).toContain("区域 <bbox>100 200 800 900</bbox>");
|
||||
expect(parseImageLayerPlan("```json\n" + JSON.stringify(response(["背景", "产品"])) + "\n```").layers).toHaveLength(2);
|
||||
});
|
||||
test("未声明移除时保留原内容;模型显式选项必须是布尔值", () => {
|
||||
const planned = response();
|
||||
expect(parseImageLayerPlan(JSON.stringify(planned)).layers.map((layer) => layer.removeFromBackground)).toEqual([undefined, false, false, false]);
|
||||
const details = { ...planned, layers: planned.layers.map((layer, i) => ({ ...layer, ...(i ? { removeFromBackground: i === 2 } : {}) })) };
|
||||
expect(parseImageLayerPlan(JSON.stringify(details)).layers.map((layer) => layer.removeFromBackground)).toEqual([undefined, false, true, false]);
|
||||
expect(() => parseImageLayerPlan(JSON.stringify({ ...details, layers: details.layers.map((layer) => ({ ...layer, removeFromBackground: "true" })) }))).toThrow("移除选项");
|
||||
});
|
||||
test("不能识图、空结果或非 JSON 不作为计划继续执行", () => {
|
||||
expect(() => parseImageLayerPlan('{"canPlan":false,"reason":"无法读取图片"}')).toThrow("无法读取图片");
|
||||
expect(() => parseImageLayerPlan("我建议分三层")).toThrow("JSON");
|
||||
expect(() => parseImageLayerPlan(JSON.stringify(response(["只有一层"])))).toThrow("2–8");
|
||||
expect(() => parseImageLayerPlan(JSON.stringify(response(Array.from({ length: 9 }, (_, index) => `层${index}`))))).toThrow("2–8");
|
||||
});
|
||||
test("拒绝无效语义,丢弃无效可选坐标而保留已识别图层", () => {
|
||||
expect(() => parseImageLayerPlan(JSON.stringify(response(["背景", "重复", "重复"])))).toThrow("重复");
|
||||
const wrong = response();
|
||||
wrong.layers[1].kind = "background";
|
||||
expect(() => parseImageLayerPlan(JSON.stringify(wrong))).toThrow("独立对象");
|
||||
for (const bbox of [
|
||||
[800, 200, 100, 900],
|
||||
[0, 0, 1001, 900],
|
||||
[0, 0, 1, 1],
|
||||
]) {
|
||||
const bad = response();
|
||||
bad.layers[1].bbox = bbox;
|
||||
const plan = parseImageLayerPlan(JSON.stringify(bad));
|
||||
expect(plan.layers).toHaveLength(4);
|
||||
expect(plan.layers[1]).not.toHaveProperty("bbox");
|
||||
expect(plan.warnings?.[0]).toContain("坐标");
|
||||
}
|
||||
expect(parseImageLayerPlan(JSON.stringify({ ...response(), code: "ignored", model: "untrusted" }))).not.toHaveProperty("model");
|
||||
});
|
||||
test("四格海报部分旧坐标超过1000时,七层语义仍可用且不猜坐标单位", () => {
|
||||
const names = ["底图背景", "主标题文字", "副标题文字", "左上照片", "右上照片", "左下照片", "右下照片"];
|
||||
const value = response(names);
|
||||
value.layers[1].bbox = [65, 30, 1010, 85];
|
||||
value.layers[4].bbox = [547, 167, 1017, 547];
|
||||
const plan = parseImageLayerPlan(JSON.stringify(value));
|
||||
expect(plan.layers.map((layer) => layer.name)).toEqual(names);
|
||||
expect(plan.warnings).toHaveLength(2);
|
||||
expect(imageLayerPlanTargets(plan)[1]).not.toContain("bbox");
|
||||
expect(plan.layers[2].bbox).toEqual([100, 200, 800, 900]);
|
||||
});
|
||||
test("显式单位与已知实际输入尺寸才转换像素坐标,保持归一化提示合同", () => {
|
||||
const size = { width: 1600, height: 800 };
|
||||
const value = response();
|
||||
value.layers[1].bbox = [160, 160, 1280, 720];
|
||||
const text = JSON.stringify({ ...value, coordinateSpace: "pixels", imageSize: size });
|
||||
expect(parseImageLayerPlan(text, size).layers[1].bbox).toEqual([100, 200, 800, 900]);
|
||||
expect(parseImageLayerPlan(text).layers[1]).not.toHaveProperty("bbox");
|
||||
expect(parseImageLayerPlan(text, { width: 1000, height: 500 }).layers[1]).not.toHaveProperty("bbox");
|
||||
value.layers[1].bbox = [0.1, 0.2, 0.8, 0.9];
|
||||
expect(parseImageLayerPlan(JSON.stringify({ ...value, coordinateSpace: "relative" })).layers[1].bbox).toEqual([100, 200, 800, 900]);
|
||||
expect(parseImageLayerPlan(JSON.stringify({ ...value, coordinateSpace: "unknown" })).layers[1]).not.toHaveProperty("bbox");
|
||||
for (const bbox of [
|
||||
[1, 2, 3],
|
||||
[0, 0, "50", 60],
|
||||
[null, 0, 1000, 1000],
|
||||
]) {
|
||||
expect(parseImageLayerPlan(JSON.stringify({ ...value, layers: value.layers.map((layer, index) => (index === 1 ? { ...layer, bbox } : layer)) })).layers[1]).not.toHaveProperty("bbox");
|
||||
}
|
||||
});
|
||||
test("历史恢复跳过识图失败,只允许同画布同源图匹配资源", () => {
|
||||
const task = (id: string, metadata: Record<string, unknown>, text: string, extra: Partial<GenerationTask> = {}): GenerationTask => ({
|
||||
id,
|
||||
projectId: "project",
|
||||
type: "canvas_text",
|
||||
status: "succeeded",
|
||||
model: "vision",
|
||||
createdAt: id,
|
||||
updatedAt: id,
|
||||
prompt: "plan",
|
||||
attempts: 1,
|
||||
inputJson: JSON.stringify({ metadata }),
|
||||
resultJson: JSON.stringify({ text }),
|
||||
...extra,
|
||||
});
|
||||
const metadata = { edit: "layer-planning", sourceNodeId: "source", sourceStorageKey: "image-key" };
|
||||
const tasks = [
|
||||
task("1", metadata, JSON.stringify(response())),
|
||||
task("2", metadata, '{"canPlan":false,"reason":"无法读图"}'),
|
||||
task("3", metadata, "broken"),
|
||||
task("4", metadata, JSON.stringify(response()), { projectId: "other" }),
|
||||
task("5", { ...metadata, sourceNodeId: "other" }, JSON.stringify(response())),
|
||||
task("6", { ...metadata, sourceStorageKey: "replaced-key" }, JSON.stringify(response())),
|
||||
task("7", metadata, JSON.stringify(response()), { type: "canvas_image" }),
|
||||
task("8", metadata, JSON.stringify(response()), { status: "running" }),
|
||||
];
|
||||
expect(latestImageLayerPlan(tasks, "project", "source", "image-key")?.taskId).toBe("1");
|
||||
expect(latestImageLayerPlan(tasks, "none", "source", "image-key")).toBeUndefined();
|
||||
const legacy = latestImageLayerPlan([task("1", { edit: "layer-planning", sourceNodeId: "source" }, JSON.stringify(response()))], "project", "source", "image-key");
|
||||
expect(legacy?.warnings?.join(" ")).toContain("旧记录");
|
||||
});
|
||||
test("海报整张照片内部场景保留,坐标可省略且以实际参考图为准", () => {
|
||||
const prompt = imageLayerPlanningPrompt("自动拆分", { width: 1000, height: 1000 });
|
||||
expect(prompt).toContain("完整 scene 禁止重画或内部去背景");
|
||||
expect(prompt).toContain('coordinateSpace="pixels"');
|
||||
expect(prompt).toContain("1000×1000");
|
||||
});
|
||||
test("输出比例优先匹配源图配置合同,不把任意原像素尺寸发送给模型", () => {
|
||||
const profile = { ...image, image: { ...image.image!, size: { parameter: "size" as const, values: ["1:1", "16:9", "auto"], default: "1:1", allowCustom: false } } };
|
||||
const outputConfig = { ...config, channels: [{ ...channel, modelCosts: channel.modelCosts!.map((cost) => (cost.model === "editor" ? { ...cost, capabilityConfig: profile } : cost)) }] };
|
||||
expect(imageLayerOutputSize(outputConfig, "models::editor", { width: 731, height: 412 })).toBe("16:9");
|
||||
expect(imageLayerOutputSize(outputConfig, "models::editor", { width: 997, height: 700 })).toBe("auto");
|
||||
const fixed = {
|
||||
...outputConfig,
|
||||
channels: [
|
||||
{ ...outputConfig.channels[0], modelCosts: outputConfig.channels[0].modelCosts!.map((cost) => ({ ...cost, capabilityConfig: { ...profile, image: { ...profile.image, size: { ...profile.image.size, values: ["1:1", "16:9"] } } } })) },
|
||||
],
|
||||
};
|
||||
expect(imageLayerOutputSize(fixed, "models::editor", { width: 997, height: 700 })).toBe("");
|
||||
expect(imageLayerOutputSize(outputConfig, "models::editor")).toBe("");
|
||||
const custom = {
|
||||
...outputConfig,
|
||||
channels: [{ ...outputConfig.channels[0], modelCosts: outputConfig.channels[0].modelCosts!.map((cost) => ({ ...cost, capabilityConfig: { ...profile, image: { ...profile.image, size: { ...profile.image.size, allowCustom: true } } } })) }],
|
||||
};
|
||||
expect(imageLayerOutputSize(custom, "models::editor", { width: 731, height: 412 })).toBe("731:412");
|
||||
expect(imageLayerOutputSize(custom, "models::editor", { width: 600, height: 900 })).toBe("600:900");
|
||||
expect(imageLayerOutputSize(custom, "models::editor", { width: 0, height: 900 })).toBe("");
|
||||
});
|
||||
});
|
||||
50
web/test/canvas-image-layer-region.test.ts
Normal file
50
web/test/canvas-image-layer-region.test.ts
Normal file
@@ -0,0 +1,50 @@
|
||||
import { expect, test } from "bun:test";
|
||||
import { imageLayerAlphaBounds, suggestImageLayerRegion } from "../src/lib/canvas/canvas-image-layer-region";
|
||||
import { ensureMediaNodeMinimumSize, fitImageMaterialNodeSize } from "../src/lib/canvas/canvas-node-size";
|
||||
import { CanvasNodeType } from "../src/types/canvas";
|
||||
|
||||
test("extreme material ratios use bounded preview frames and remain stable after reload", () => {
|
||||
for (const [width, height] of [
|
||||
[1408, 79],
|
||||
[12, 1400],
|
||||
[701, 626],
|
||||
[10, 8],
|
||||
]) {
|
||||
const size = fitImageMaterialNodeSize(width, height);
|
||||
expect(size.width).toBeLessThanOrEqual(720);
|
||||
expect(size.height).toBeLessThanOrEqual(520);
|
||||
const node = { id: "material", type: CanvasNodeType.Image, title: "素材", position: { x: 10, y: 20 }, ...size, metadata: { content: "/material.png", naturalWidth: width, naturalHeight: height } };
|
||||
expect(ensureMediaNodeMinimumSize(node)).toEqual(node);
|
||||
}
|
||||
});
|
||||
|
||||
test("alpha bounds keep soft edges and reject empty/invalid rasters", () => {
|
||||
const pixels = new Uint8ClampedArray(12 * 8 * 4);
|
||||
pixels[(2 * 12 + 3) * 4 + 3] = 1;
|
||||
pixels[(5 * 12 + 8) * 4 + 3] = 255;
|
||||
expect(imageLayerAlphaBounds(12, 8, pixels)).toEqual({ left: 3, top: 2, width: 6, height: 4 });
|
||||
expect(() => imageLayerAlphaBounds(12, 8, new Uint8ClampedArray(pixels.length))).toThrow("完全透明");
|
||||
expect(() => imageLayerAlphaBounds(12, 9, pixels)).toThrow("尺寸无效");
|
||||
});
|
||||
|
||||
for (const [width, height, background, foreground] of [
|
||||
[400, 260, [20, 150, 220], [230, 80, 40]],
|
||||
[260, 400, [220, 210, 190], [30, 70, 100]],
|
||||
[500, 200, [40, 30, 30], [130, 200, 90]],
|
||||
] as const) {
|
||||
test(`regular panel correction uses pixels, independent of subject/ratio/colors ${width}×${height}`, () => {
|
||||
const pixels = new Uint8ClampedArray(width * height * 4);
|
||||
const bounds = [Math.round(width * 0.2), Math.round(height * 0.2), Math.round(width * 0.8), Math.round(height * 0.8)];
|
||||
for (let y = 0; y < height; y++) for (let x = 0; x < width; x++) pixels.set([...(x >= bounds[0] && x < bounds[2] && y >= bounds[1] && y < bounds[3] ? foreground : background), 255], (y * width + x) * 4);
|
||||
const suggestion = suggestImageLayerRegion(width, height, pixels, { bbox: [210, 210, 790, 740], shape: "rect" });
|
||||
expect(suggestion?.bbox).toEqual([200, 200, 800, 800]);
|
||||
});
|
||||
}
|
||||
|
||||
test("ambiguous uniform image, transparent exterior and ellipse keep the original region", () => {
|
||||
const pixels = new Uint8ClampedArray(100 * 100 * 4).fill(255);
|
||||
const region = { bbox: [200, 200, 800, 800] as [number, number, number, number], shape: "rect" as const };
|
||||
expect(suggestImageLayerRegion(100, 100, pixels, region)).toBeUndefined();
|
||||
expect(suggestImageLayerRegion(100, 100, pixels.fill(0), region)).toBeUndefined();
|
||||
expect(suggestImageLayerRegion(100, 100, pixels, { ...region, shape: "ellipse" })).toBeUndefined();
|
||||
});
|
||||
87
web/test/canvas-image-layer-strategy.test.ts
Normal file
87
web/test/canvas-image-layer-strategy.test.ts
Normal file
@@ -0,0 +1,87 @@
|
||||
import { expect, test } from "bun:test";
|
||||
import { imageLayerBackgroundPatch, imageLayerExtractionTargets, imageLayerModelCalls, resolveImageLayerExtractions, validateImageLayerRegion, type ImageLayerExtraction } from "@/lib/canvas/canvas-image-layer-strategy";
|
||||
import { parseImageLayerPlan } from "@/lib/canvas/canvas-image-layer-plan";
|
||||
|
||||
const region = { bbox: [100, 200, 600, 800] as [number, number, number, number], shape: "rect" as const };
|
||||
const methods: ImageLayerExtraction[] = [{ method: "source" }, { method: "source-region", region }];
|
||||
|
||||
test("原图与模型混合按结构处理,原图全链路零图片调用", () => {
|
||||
expect(resolveImageLayerExtractions(2, methods, [false, false])).toEqual(methods);
|
||||
expect(imageLayerModelCalls(methods, true)).toBe(0);
|
||||
expect(imageLayerModelCalls([{ method: "generate" }, methods[1], { method: "generate" }], true)).toBe(3);
|
||||
expect(resolveImageLayerExtractions(2)).toEqual([{ method: "generate" }, { method: "generate" }]);
|
||||
});
|
||||
|
||||
test("区域、角色、数量不合法时拒绝,显式缺失策略不能偷偷改为付费生成", () => {
|
||||
expect(() => resolveImageLayerExtractions(2, methods, [false, true])).toThrow("不能同时移除");
|
||||
expect(() => resolveImageLayerExtractions(2, [methods[1], methods[0]])).toThrow("第 1 层");
|
||||
expect(() => resolveImageLayerExtractions(2, [methods[0], undefined as any])).toThrow("缺失");
|
||||
expect(() => resolveImageLayerExtractions(2, [methods[0]])).toThrow("数量");
|
||||
for (const bbox of [
|
||||
[0, 0, 1001, 1000],
|
||||
[400, 0, 300, 500],
|
||||
[0, 0, NaN, 500],
|
||||
[0, 0, 1, 100],
|
||||
])
|
||||
expect(() => validateImageLayerRegion({ ...region, bbox: bbox as any })).toThrow("区域无效");
|
||||
expect(() => validateImageLayerRegion({ ...region, shape: "rounded-rect", radiusRatio: 1 })).toThrow("圆角");
|
||||
});
|
||||
|
||||
test("只有所有待移除层均有原图区域时才限制背景生成范围", () => {
|
||||
const generated: ImageLayerExtraction[] = [{ method: "generate" }, methods[1], { method: "generate" }];
|
||||
expect(imageLayerBackgroundPatch({ dataUrl: "original" }, generated, [false, true, false])?.regions).toEqual([region]);
|
||||
expect(imageLayerBackgroundPatch({ dataUrl: "original" }, generated, [false, true, true])).toBeUndefined();
|
||||
expect(imageLayerBackgroundPatch({ dataUrl: "original" }, methods, [false, false])).toBeUndefined();
|
||||
});
|
||||
|
||||
test("编辑区域后背景提示与本地实际提取使用同一坐标", () => {
|
||||
const targets = imageLayerExtractionTargets(["底图", "面板:全部内容,区域 <bbox>0 0 100 100</bbox>"], methods);
|
||||
expect(targets[1]).toBe("面板:全部内容,区域 <bbox>100 200 600 800</bbox>");
|
||||
expect(targets[1].match(/<bbox>/g)).toHaveLength(1);
|
||||
});
|
||||
|
||||
test("规划声明结构化原图策略,像素坐标按实际参考尺寸换算", () => {
|
||||
const plan = parseImageLayerPlan(
|
||||
JSON.stringify({
|
||||
canPlan: true,
|
||||
coordinateSpace: "pixels",
|
||||
imageSize: { width: 1000, height: 500 },
|
||||
layers: [
|
||||
{ name: "完整底图", description: "保留全部内容", kind: "background", generation: { prompt: "保留完整原图", background: "opaque" }, extraction: { method: "source" } },
|
||||
{
|
||||
name: "规则区域",
|
||||
description: "完整独立区域",
|
||||
kind: "object",
|
||||
generation: { prompt: "复制完整规则区域", background: "transparent" },
|
||||
removeFromBackground: false,
|
||||
bbox: [100, 100, 600, 400],
|
||||
extraction: { method: "source-region", shape: "rounded-rect", radiusRatio: 0.05 },
|
||||
},
|
||||
],
|
||||
}),
|
||||
{ width: 1000, height: 500 },
|
||||
);
|
||||
expect(plan.layers[1].extraction?.region).toEqual({ bbox: [100, 200, 600, 800], shape: "rounded-rect", radiusRatio: 0.05 });
|
||||
expect(
|
||||
resolveImageLayerExtractions(
|
||||
2,
|
||||
plan.layers.map((layer) => layer.extraction!),
|
||||
[false, false],
|
||||
),
|
||||
).toHaveLength(2);
|
||||
});
|
||||
|
||||
test("无效可选坐标保留语义,但显式原图策略阻断付费回退", () => {
|
||||
const plan = parseImageLayerPlan(
|
||||
JSON.stringify({
|
||||
canPlan: true,
|
||||
layers: [
|
||||
{ name: "底图", description: "完整底图", kind: "background", generation: { prompt: "保留环境并修补遮挡", background: "opaque" } },
|
||||
{ name: "图形", description: "独立图形", kind: "object", generation: { prompt: "复制独立图形", background: "transparent" }, bbox: [0, 0, 1001, 500], extraction: { method: "source-region", shape: "rect" } },
|
||||
],
|
||||
}),
|
||||
);
|
||||
expect(plan.layers).toHaveLength(2);
|
||||
expect(plan.layers[1].extraction?.method).toBe("source-region");
|
||||
expect(() => resolveImageLayerExtractions(2, [{ method: "generate" }, plan.layers[1].extraction!])).toThrow("区域无效");
|
||||
});
|
||||
29
web/test/canvas-image-layer-task-state.test.ts
Normal file
29
web/test/canvas-image-layer-task-state.test.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
import { expect, test } from "bun:test";
|
||||
import { canvasTaskBindingStatus } from "@/lib/canvas/canvas-task-state";
|
||||
import { CanvasNodeType, type CanvasNodeData } from "@/types/canvas";
|
||||
const node: CanvasNodeData = {
|
||||
id: "layer",
|
||||
type: CanvasNodeType.Image,
|
||||
position: { x: 0, y: 0 },
|
||||
width: 20,
|
||||
height: 20,
|
||||
metadata: { taskId: "extract", content: "/opaque.png", status: "idle", layerExtraction: { sourceNodeId: "source", groupId: "group", index: 1, phase: "background-removal-required", extractionTaskId: "extract" } },
|
||||
};
|
||||
test("待去背景的原始提取成功仅为中间结果", () => {
|
||||
expect(canvasTaskBindingStatus(node, { id: "extract", status: "succeeded" })).toBe("idle");
|
||||
});
|
||||
test("去背景任务上游成功而未验收时,旧图不能充当新结果", () => {
|
||||
const candidate = { ...node, metadata: { ...node.metadata, taskId: "remove", layerExtraction: { ...node.metadata!.layerExtraction!, phase: "remove-background" as const } } };
|
||||
expect(canvasTaskBindingStatus(candidate, { id: "remove", status: "succeeded" })).toBe("loading");
|
||||
});
|
||||
test("被本地透明度验收拒绝的成功任务不能抹掉失败状态", () => {
|
||||
const candidate = { ...node, metadata: { ...node.metadata, status: "error" as const, layerExtraction: { ...node.metadata!.layerExtraction!, rejectedTaskId: "remove" } } };
|
||||
expect(canvasTaskBindingStatus(candidate, { id: "remove", status: "succeeded" })).toBe("error");
|
||||
});
|
||||
test("验收完成须与同一任务绑定,普通节点维持已有规则", () => {
|
||||
const validated = { ...node, metadata: { ...node.metadata, taskId: "remove", layerExtraction: { ...node.metadata!.layerExtraction!, phase: "complete" as const } } };
|
||||
expect(canvasTaskBindingStatus(validated, { id: "remove", status: "succeeded" })).toBe("success");
|
||||
expect(canvasTaskBindingStatus(validated, { id: "older", status: "succeeded" })).toBe("loading");
|
||||
expect(canvasTaskBindingStatus({ ...node, metadata: { content: "/ordinary.png" } }, { id: "ordinary", status: "succeeded" })).toBe("success");
|
||||
expect(canvasTaskBindingStatus(validated, { id: "remove", status: "failed" })).toBe("error");
|
||||
});
|
||||
231
web/test/canvas-image-layers.test.ts
Normal file
231
web/test/canvas-image-layers.test.ts
Normal file
@@ -0,0 +1,231 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import {
|
||||
inspectLayerAlpha,
|
||||
validateLayerRasters,
|
||||
imageLayerCompositeSignature,
|
||||
copyImageLayerSources,
|
||||
supportsLayerDecomposition,
|
||||
supportsExperimentalLayerExtraction,
|
||||
parseExperimentalLayerTargets,
|
||||
experimentalLayerPrompt,
|
||||
experimentalLayerSignature,
|
||||
layerDecompositionConfig,
|
||||
imageLayerProviderMetadata,
|
||||
validateImageLayerRole,
|
||||
validateImageLayerOutput,
|
||||
hasUsableLayerTransparency,
|
||||
MAX_IMAGE_LAYERS,
|
||||
MAX_LAYER_PIXELS,
|
||||
} from "@/lib/canvas/canvas-image-layers";
|
||||
import { isolateCopiedNodeMetadata } from "@/lib/canvas/canvas-node-copy";
|
||||
import { applyBatchPrimaryImage, removeCanvasNodes, isHiddenBatchChild } from "@/lib/canvas/canvas-project-domain";
|
||||
import { failedImageBatchChildren, reconcileImageBatchRoot } from "@/lib/canvas/canvas-image-batch-retry";
|
||||
import { buildImageLayerTaskResult } from "@/lib/canvas/canvas-generation-task-sync";
|
||||
import { canvasNodeToAsset, findCanvasNodeAsset } from "@/lib/canvas/canvas-node-asset";
|
||||
import type { Asset } from "@/stores/use-asset-store";
|
||||
import { defaultConfig, createModelChannel } from "@/stores/use-config-store";
|
||||
import { CanvasNodeType, type CanvasNodeData } from "@/types/canvas";
|
||||
|
||||
const opaque = inspectLayerAlpha(2, 1, new Uint8ClampedArray([20, 30, 40, 255, 20, 30, 40, 255]));
|
||||
const transparent = inspectLayerAlpha(2, 1, new Uint8ClampedArray([200, 0, 0, 128, 0, 0, 0, 0]));
|
||||
function sample() {
|
||||
const children: CanvasNodeData[] = [0, 1].map((index) => ({
|
||||
id: `layer-${index}`,
|
||||
type: CanvasNodeType.Image,
|
||||
title: `图层 ${index}`,
|
||||
position: { x: 600, y: 240 * index },
|
||||
width: 320,
|
||||
height: 200,
|
||||
metadata: { storageKey: `resource:layer-${index}`, content: `/layer-${index}.png`, naturalWidth: 2, naturalHeight: 1, imageLayer: { groupId: "root", outputIndex: index }, batchRootId: "root" },
|
||||
}));
|
||||
const root: CanvasNodeData = {
|
||||
id: "root",
|
||||
type: CanvasNodeType.Image,
|
||||
title: "合成图",
|
||||
position: { x: 0, y: 0 },
|
||||
width: 320,
|
||||
height: 200,
|
||||
metadata: {
|
||||
content: "/composite.png",
|
||||
storageKey: "resource:composite",
|
||||
status: "success",
|
||||
taskId: "task",
|
||||
generationOutputCount: 2,
|
||||
isBatchRoot: true,
|
||||
batchChildIds: children.map((child) => child.id),
|
||||
imageLayerGroup: { width: 2, height: 1, compositeStatus: "ready", layers: children.map((child) => ({ nodeId: child.id, x: 0, y: 0, visible: true })) },
|
||||
},
|
||||
};
|
||||
return { root, children, nodes: [root, ...children] };
|
||||
}
|
||||
|
||||
describe("独立透明图层验收", () => {
|
||||
test("原图底板输入仍要求不透明,去背景触发仍不能用单个透明像素冒充", () => {
|
||||
expect(() => validateImageLayerRole(opaque, 0)).not.toThrow();
|
||||
expect(() => validateImageLayerRole(transparent, 0)).toThrow("背景底图");
|
||||
const rgba = new Uint8ClampedArray(100 * 100 * 4).fill(255);
|
||||
rgba[3] = 254;
|
||||
expect(() => validateImageLayerRole(inspectLayerAlpha(100, 100, rgba), 0)).toThrow("半透明");
|
||||
rgba[3] = 0;
|
||||
const puncture = inspectLayerAlpha(100, 100, rgba);
|
||||
expect(puncture.transparent).toBe(true);
|
||||
expect(hasUsableLayerTransparency(puncture)).toBe(false);
|
||||
expect(() => validateImageLayerRole(puncture, 1)).toThrow("零散");
|
||||
expect(() => validateImageLayerRole(transparent, 1)).not.toThrow();
|
||||
});
|
||||
test("最终显示接受半透明背景与不透明前景,仍拒绝完全透明空图", () => {
|
||||
for (const info of [opaque, transparent, { ...opaque, opaque: false }]) {
|
||||
expect(() => validateImageLayerOutput(info)).not.toThrow();
|
||||
}
|
||||
expect(() => validateImageLayerOutput({ ...transparent, nonempty: false })).toThrow("空图层");
|
||||
expect(hasUsableLayerTransparency(opaque)).toBe(false);
|
||||
});
|
||||
test("完整场景只移除勾选对象,细节允许重复;单层提示不混入其他层排除指令", () => {
|
||||
const targets = ["房间:保留窗户、书架、地毯", "猫:保留轮廓", "毛线球:保留纹理"];
|
||||
const background = experimentalLayerPrompt({ prompt: "移除猫并补全遮挡;保留窗户、书架、地毯与毛线球", background: "opaque" });
|
||||
expect(background).toContain("移除猫并补全遮挡");
|
||||
expect(background).toContain("保留窗户、书架、地毯与毛线球");
|
||||
expect(background).toContain("alpha=255");
|
||||
expect(background).toContain("0–1000 相对坐标,不是像素");
|
||||
expect(background).toContain("补全为原有底色或环境");
|
||||
expect(background).not.toContain("不要清空底图");
|
||||
const foreground = experimentalLayerPrompt({ prompt: targets[1], background: "transparent" });
|
||||
expect(foreground).not.toContain("房间:保留");
|
||||
expect(foreground).not.toContain("毛线球:保留");
|
||||
expect(foreground).toContain("不做面板内部主体抠图");
|
||||
expect(background).not.toContain("保留自然边缘与接地阴影");
|
||||
const model = "gpt-image-2.5-flare";
|
||||
const channel = createModelChannel({ id: "backup", models: [model], interfaceType: "openai-image" });
|
||||
const config = { ...defaultConfig, model: `backup::${model}`, channels: [channel] };
|
||||
expect(imageLayerProviderMetadata(config, 0)).toEqual({ providerOptions: { "openai-image": { background: "opaque" } } });
|
||||
expect(imageLayerProviderMetadata(config, 1)).toEqual({ providerOptions: { "openai-image": { background: "transparent" } } });
|
||||
});
|
||||
test("实验提取明确协议、每层目标和透明背景;限制调用层数", () => {
|
||||
const model = "gpt-image-2.5-flare";
|
||||
const channel = createModelChannel({ id: "backup", models: [model], interfaceType: "openai-image" });
|
||||
expect(supportsExperimentalLayerExtraction({ ...defaultConfig, channels: [channel] }, `backup::${model}`)).toBe(true);
|
||||
expect(supportsLayerDecomposition({ ...defaultConfig, channels: [channel] }, `backup::${model}`)).toBe(false);
|
||||
const targets = parseExperimentalLayerTargets("背景\n汽车\n前景");
|
||||
expect(targets).toEqual(["背景", "汽车", "前景"]);
|
||||
expect(experimentalLayerPrompt({ prompt: "补全被遮挡背景", background: "opaque" })).toContain("补全被遮挡背景");
|
||||
expect(experimentalLayerPrompt({ prompt: "目标:汽车", background: "transparent" })).toContain("alpha=0");
|
||||
expect(experimentalLayerPrompt({ prompt: "目标:汽车", background: "transparent" })).toContain("目标:汽车");
|
||||
expect(() => parseExperimentalLayerTargets("汽车")).toThrow("2–8");
|
||||
expect(() => parseExperimentalLayerTargets("汽车\n汽车")).toThrow("重复");
|
||||
expect(() => parseExperimentalLayerTargets(Array.from({ length: 9 }, (_, i) => String(i)).join("\n"))).toThrow("2–8");
|
||||
});
|
||||
test("未完成实验组删除清理逐层节点,复制不继承付费提取计划", () => {
|
||||
const { root, children, nodes } = sample();
|
||||
const staged = { ...root, metadata: { experimentalLayerPlan: { sourceNodeId: "source", requests: children.map((child) => ({ nodeId: child.id, target: child.id })) } } };
|
||||
expect(removeCanvasNodes([staged, ...children], new Set([root.id])).nodes).toHaveLength(0);
|
||||
expect(isolateCopiedNodeMetadata(staged, new Map([[root.id, "copy"]])).experimentalLayerPlan).toBeUndefined();
|
||||
expect(experimentalLayerSignature(staged, nodes)).not.toBe(experimentalLayerSignature(staged, [root, ...children.map((child) => ({ ...child, metadata: { ...child.metadata, storageKey: "edited" } }))]));
|
||||
});
|
||||
test("读取真实 alpha,保留半透明边缘和重叠;唯一底图置底", () => {
|
||||
expect(opaque).toMatchObject({ transparent: false, nonempty: true });
|
||||
expect(transparent).toMatchObject({ transparent: true, nonempty: true });
|
||||
expect(validateLayerRasters([transparent, opaque, transparent])).toEqual([1, 0, 2]);
|
||||
expect(validateLayerRasters([transparent, transparent])).toEqual([0, 1]);
|
||||
});
|
||||
test("多张不透明图保留返回顺序;仍拒绝单张拼版、空图层、不同尺寸及超限输出", () => {
|
||||
expect(() => validateLayerRasters([opaque])).toThrow("至少需要两张");
|
||||
expect(validateLayerRasters([opaque, opaque])).toEqual([0, 1]);
|
||||
expect(validateLayerRasters([transparent, opaque, opaque])).toEqual([0, 1, 2]);
|
||||
expect(() => validateLayerRasters([opaque, { ...transparent, nonempty: false }])).toThrow("空图层");
|
||||
expect(() => validateLayerRasters([opaque, { ...transparent, width: 3 }])).toThrow("尺寸不一致");
|
||||
expect(() => validateLayerRasters(Array(MAX_IMAGE_LAYERS + 1).fill(transparent))).toThrow("数量");
|
||||
expect(() => inspectLayerAlpha(MAX_LAYER_PIXELS + 1, 1, new Uint8ClampedArray())).toThrow("尺寸");
|
||||
expect(() => inspectLayerAlpha(2, 1, new Uint8ClampedArray(4))).toThrow("尺寸");
|
||||
});
|
||||
test("模型名不能代替专用协议能力", () => {
|
||||
const model = "bytedance/seedream-v5.0-pro/layer-decomposition";
|
||||
const channel = createModelChannel({ id: "test", models: [model], interfaceType: "image-tools-layer-decomposition" });
|
||||
expect(supportsLayerDecomposition({ ...defaultConfig, channels: [channel] }, `test::${model}`)).toBe(true);
|
||||
expect(supportsLayerDecomposition({ ...defaultConfig, channels: [{ ...channel, interfaceType: undefined }] }, `test::${model}`)).toBe(false);
|
||||
});
|
||||
test("来源工作流和多图数量不能把拆层改路由或提交多次任务", () => {
|
||||
const config = layerDecompositionConfig({ ...defaultConfig, taskWorkflowProvider: "runninghub", count: "4" }, "dedicated", { quality: "1k" });
|
||||
expect(config).toMatchObject({ model: "dedicated", imageModel: "dedicated", count: "1", taskWorkflowProvider: "model", quality: "1k" });
|
||||
});
|
||||
});
|
||||
|
||||
describe("图层组状态与画布操作", () => {
|
||||
test("进行中或失败的拆层计划也收纳在组内,不把底图设为成功主图或批量收费重试", () => {
|
||||
const { root, children, nodes } = sample();
|
||||
const pending = {
|
||||
...root,
|
||||
metadata: {
|
||||
isBatchRoot: true,
|
||||
batchChildIds: children.map((child) => child.id),
|
||||
imageBatchExpanded: false,
|
||||
status: "error" as const,
|
||||
experimentalLayerPlan: { sourceNodeId: "source", requests: children.map((child) => ({ nodeId: child.id, target: child.title })) },
|
||||
},
|
||||
};
|
||||
expect(isHiddenBatchChild(children[0], [pending, ...children])).toBe(true);
|
||||
expect(applyBatchPrimaryImage(pending, children[0])).toBe(pending);
|
||||
expect(reconcileImageBatchRoot(pending, nodes)).toBe(pending);
|
||||
expect(
|
||||
failedImageBatchChildren(
|
||||
pending,
|
||||
children.map((child) => ({ ...child, metadata: { ...child.metadata, status: "error" as const } })),
|
||||
),
|
||||
).toHaveLength(0);
|
||||
});
|
||||
test("合成资源更新后不沿用同一原任务的旧素材绑定", () => {
|
||||
const { root } = sample();
|
||||
const old = { ...canvasNodeToAsset(root, { canvasId: "canvas", source: "canvas-generation", taskId: "task" }), id: "old", createdAt: "", updatedAt: "" } as Asset;
|
||||
const updated = { ...root, metadata: { ...root.metadata, storageKey: "resource:new-composite", content: "/new.png" } };
|
||||
expect(findCanvasNodeAsset([old], root, "canvas", "task")?.id).toBe("old");
|
||||
expect(findCanvasNodeAsset([old], updated, "canvas", "task")).toBeUndefined();
|
||||
});
|
||||
test("画布移动、节点名称、展开与收起不影响合成;顺序/可见性/资源改变会更新", () => {
|
||||
const { root, children, nodes } = sample();
|
||||
const group = root.metadata!.imageLayerGroup!;
|
||||
const signature = imageLayerCompositeSignature(group, nodes);
|
||||
expect(
|
||||
imageLayerCompositeSignature(
|
||||
group,
|
||||
nodes.map((node) => ({ ...node, title: "改名", position: { x: 5000, y: 5000 }, metadata: { ...node.metadata, imageBatchExpanded: true } })),
|
||||
),
|
||||
).toBe(signature);
|
||||
expect(imageLayerCompositeSignature({ ...group, layers: [...group.layers].reverse() }, nodes)).not.toBe(signature);
|
||||
expect(imageLayerCompositeSignature({ ...group, layers: group.layers.map((layer) => ({ ...layer, visible: false })) }, nodes)).not.toBe(signature);
|
||||
expect(imageLayerCompositeSignature(group, [root, { ...children[0], metadata: { ...children[0].metadata, storageKey: "new" } }, children[1]])).not.toBe(signature);
|
||||
expect(isHiddenBatchChild(children[0], [root])).toBe(true);
|
||||
expect(isHiddenBatchChild(children[0], [{ ...root, metadata: { ...root.metadata, imageBatchExpanded: true } }])).toBe(false);
|
||||
});
|
||||
test("删除子层不替换合成图,删除组清理全部子层,MJ 主图逻辑不介入", () => {
|
||||
const { root, children, nodes } = sample();
|
||||
expect(applyBatchPrimaryImage(root, children[0])).toBe(root);
|
||||
expect(reconcileImageBatchRoot(root, nodes)).toBe(root);
|
||||
const next = removeCanvasNodes(nodes, new Set([children[0].id])).nodes;
|
||||
expect(next[0].metadata?.content).toBe("/composite.png");
|
||||
expect(next[0].metadata?.imageLayerGroup?.layers.map((layer) => layer.nodeId)).toEqual([children[1].id]);
|
||||
expect(next[0].metadata?.imageLayerGroup?.compositeStatus).toBe("updating");
|
||||
expect(removeCanvasNodes(nodes, new Set([root.id])).nodes).toHaveLength(0);
|
||||
});
|
||||
test("复制整组重映射内部身份,单独复制子层脱离原组和任务", () => {
|
||||
const { root, children, nodes } = sample();
|
||||
expect(copyImageLayerSources(root, nodes)).toEqual(nodes);
|
||||
const mapping = new Map(nodes.map((node) => [node.id, `copy-${node.id}`]));
|
||||
const copy = isolateCopiedNodeMetadata(root, mapping);
|
||||
expect(copy.taskId).toBeUndefined();
|
||||
expect(copy.batchChildIds).toEqual(["copy-layer-0", "copy-layer-1"]);
|
||||
expect(copy.imageLayerGroup?.layers[0].nodeId).toBe("copy-layer-0");
|
||||
expect(isolateCopiedNodeMetadata(children[0], mapping).imageLayer?.groupId).toBe("copy-root");
|
||||
const detached = isolateCopiedNodeMetadata(children[0], new Map([[children[0].id, "single"]]));
|
||||
expect(detached.imageLayer).toBeUndefined();
|
||||
expect(detached.batchRootId).toBeUndefined();
|
||||
});
|
||||
test("已消费任务重放不重建图层、不复活已删除子层、不覆盖编辑", async () => {
|
||||
const { root, nodes } = sample();
|
||||
const task = { id: "task", type: "canvas_image", status: "succeeded" as const, prompt: "", attempts: 1, createdAt: "", updatedAt: "", resultJson: JSON.stringify({ images: [{ dataUrl: "/a.png" }, { dataUrl: "/b.png" }] }) };
|
||||
const result = await buildImageLayerTaskResult(root, task, nodes.slice(0, 2));
|
||||
expect(result.node).toBe(root);
|
||||
expect(result.additionalNodes).toHaveLength(0);
|
||||
const restored = await buildImageLayerTaskResult({ ...root, metadata: { ...root.metadata, status: "error" } }, task, nodes.slice(0, 2));
|
||||
expect(restored.node.metadata?.status).toBe("success");
|
||||
expect(restored.additionalNodes).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
15
web/test/image-tools-plugin.test.ts
Normal file
15
web/test/image-tools-plugin.test.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
import { expect, test } from "bun:test";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { unzipSync, strFromU8 } from "fflate";
|
||||
|
||||
test("发布的拆层协议包与源码一致且请求 PNG,不丢失多图输出列表", () => {
|
||||
const source = JSON.parse(readFileSync(new URL("../../plugin-packages/image-tools/manifest.json", import.meta.url), "utf8"));
|
||||
const archive = unzipSync(readFileSync(new URL("../../plugin-packages/image-tools.yingce-plugin", import.meta.url)));
|
||||
const packaged = JSON.parse(strFromU8(archive["manifest.json"]));
|
||||
expect(packaged).toEqual(source);
|
||||
const provider = packaged.contributes.providers.find((item: { id: string }) => item.id === "image-tools-layer-decomposition");
|
||||
expect(provider.create.body.output_format).toBe("png");
|
||||
expect(provider.create.path).toBe("/bytedance/seedream-v5.0-pro/layer-decomposition");
|
||||
expect(provider.response.images).toEqual({ $ref: "response.data.outputs" });
|
||||
expect(packaged.version).toBe("1.0.1");
|
||||
});
|
||||
Reference in New Issue
Block a user