feat(canvas): AI 拆图 - 支持图层规划编辑与素材复用

This commit is contained in:
ddcat666
2026-10-10 04:49:54 +08:00
57 changed files with 3559 additions and 166 deletions

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

View 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("不能粘贴前景");
});

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

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

View 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("区域无效");
});

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

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

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