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("场景名称:内部描述,区域 0 0 200 500")).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("区域 100 200 800 900");
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, text: string, extra: Partial = {}): 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("");
});
});