feat(canvas): 画布媒体工作流 - 支持批量连线、失败重试与模型能力增强
合并 PR #230。保留当前主线样式与组件结构,吸收批量连线、失败批次重试、媒体模型能力和相关后端增强,并修复集成检查发现的分辨率选择器启用条件与测试稳定性问题。
This commit is contained in:
1 parent
b0c3159f8e
commit
0e251c3c8f
48 files changed
+1582
-83
No files matched your search
@@ -0,0 +1,115 @@
|
||||
import { describe, expect, it } from "bun:test";
|
||||
|
||||
import { buildBatchConnectionCreateRequest, hasBatchConnectionCandidate, planBatchConnections } from "@/lib/canvas/canvas-batch-connection";
|
||||
import { defaultConfig } from "@/stores/use-config-store";
|
||||
import { CanvasNodeType, type CanvasConnection, type CanvasNodeData } from "@/types/canvas";
|
||||
|
||||
const nodes: CanvasNodeData[] = [
|
||||
{ id: "text-a", type: CanvasNodeType.Text, title: "文本 A", position: { x: 0, y: 0 }, width: 320, height: 180 },
|
||||
{ id: "text-b", type: CanvasNodeType.Text, title: "文本 B", position: { x: 0, y: 220 }, width: 320, height: 180 },
|
||||
{ id: "image-a", type: CanvasNodeType.Image, title: "图片 A", position: { x: 0, y: 440 }, width: 320, height: 180 },
|
||||
{ id: "image-b", type: CanvasNodeType.Image, title: "图片 B", position: { x: 0, y: 660 }, width: 320, height: 180 },
|
||||
{ id: "script", type: CanvasNodeType.Script, title: "分镜脚本", position: { x: 520, y: 0 }, width: 640, height: 520, metadata: { storyboard: { rows: [{ id: "row-1", shotNumber: 1, durationSeconds: 6, plotDescription: "", dialogue: "", characters: [], narrativeIntent: "", viewerPOV: "", performanceBlocking: "", shotSize: "", emotion: "", lightingAndAtmosphere: "", audioEffects: "", camera: "", motion: "", timeBeats: "", imageGenerationPrompt: "", videoMotionPrompt: "", mustHave: [], optionalDetails: [], continuityOut: "", negativePrompt: "", referenceNodeIds: [], status: "idle" }] } } },
|
||||
{ id: "config", type: CanvasNodeType.Config, title: "图片配置", position: { x: 520, y: 560 }, width: 360, height: 420, metadata: { generationMode: "image" } },
|
||||
{ id: "frame", type: CanvasNodeType.Frame, title: "背板", position: { x: 0, y: 680 }, width: 500, height: 500 },
|
||||
];
|
||||
|
||||
const baseConfig = { ...defaultConfig };
|
||||
|
||||
describe("planBatchConnections", () => {
|
||||
it("plans all legal source nodes and preserves the target handle", () => {
|
||||
const result = planBatchConnections({
|
||||
sourceNodeIds: ["text-a", "text-b"],
|
||||
targetNodeId: "script",
|
||||
targetHandleId: "row:row-1",
|
||||
targetAnchorRatio: 0.4,
|
||||
nodes,
|
||||
connections: [],
|
||||
config: baseConfig,
|
||||
});
|
||||
|
||||
expect(result.connected).toEqual(["text-a", "text-b"]);
|
||||
expect(result.connections).toHaveLength(2);
|
||||
expect(result.connections.every((connection) => connection.toNodeId === "script" && connection.toHandleId === "row:row-1")).toBe(true);
|
||||
expect(result.skipped).toEqual([]);
|
||||
});
|
||||
|
||||
it("skips duplicate connections without creating another edge", () => {
|
||||
const existing: CanvasConnection[] = [{ id: "existing", fromNodeId: "text-a", toNodeId: "config" }];
|
||||
const result = planBatchConnections({
|
||||
sourceNodeIds: ["text-a", "text-b"],
|
||||
targetNodeId: "config",
|
||||
nodes,
|
||||
connections: existing,
|
||||
config: baseConfig,
|
||||
});
|
||||
|
||||
expect(result.connected).toEqual(["text-b"]);
|
||||
expect(result.duplicates).toEqual(["text-a"]);
|
||||
expect(result.connections).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("reports sources that cannot be used by the aggregate connector", () => {
|
||||
const result = planBatchConnections({
|
||||
sourceNodeIds: ["frame", "text-a"],
|
||||
targetNodeId: "config",
|
||||
nodes,
|
||||
connections: [],
|
||||
config: baseConfig,
|
||||
});
|
||||
|
||||
expect(result.connected).toEqual(["text-a"]);
|
||||
expect(result.skipped).toEqual([{ nodeId: "frame", reason: "背板不能作为连接源" }]);
|
||||
});
|
||||
|
||||
it("keeps every legal graph edge when a newly created node exceeds provider reference capacity", () => {
|
||||
const constrainedConfig = {
|
||||
...baseConfig,
|
||||
channels: [{
|
||||
id: "grok",
|
||||
name: "Grok",
|
||||
baseUrl: "https://example.com",
|
||||
apiKey: "test-key",
|
||||
apiFormat: "openai" as const,
|
||||
models: ["grok-imagine-image"],
|
||||
modelCosts: [{ model: "grok-imagine-image", capability: "image" as const, billingMode: "fixed_request" as const, unitPriceMicrocredits: 1, protocol: "grok-image" as const }],
|
||||
}],
|
||||
models: ["grok::grok-imagine-image"],
|
||||
imageModels: ["grok::grok-imagine-image"],
|
||||
imageModel: "grok::grok-imagine-image",
|
||||
model: "grok::grok-imagine-image",
|
||||
};
|
||||
const result = planBatchConnections({
|
||||
sourceNodeIds: ["image-a", "image-b"],
|
||||
targetNodeId: "new-image",
|
||||
nodes: [...nodes, { id: "new-image", type: CanvasNodeType.Image, title: "新图片", position: { x: 1200, y: 0 }, width: 320, height: 180 }],
|
||||
connections: [],
|
||||
config: constrainedConfig,
|
||||
allowCapacityOverflow: true,
|
||||
});
|
||||
expect(result.connected).toEqual(["image-a", "image-b"]);
|
||||
expect(result.connections).toHaveLength(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildBatchConnectionCreateRequest", () => {
|
||||
it("keeps unique eligible sources and uses the first one for the pending connection", () => {
|
||||
const request = buildBatchConnectionCreateRequest(["frame", "text-a", "text-a", "text-b"], nodes, { x: 900, y: 240 });
|
||||
|
||||
expect(request).toEqual({
|
||||
batchSourceNodeIds: ["text-a", "text-b"],
|
||||
connection: { nodeId: "text-a", handleType: "source" },
|
||||
position: { x: 900, y: 240 },
|
||||
});
|
||||
});
|
||||
|
||||
it("returns null when no selected node can be used as a batch source", () => {
|
||||
expect(buildBatchConnectionCreateRequest(["frame", "script", "config"], nodes, { x: 0, y: 0 })).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("hasBatchConnectionCandidate", () => {
|
||||
it("accepts a target when another selected source is legal even if the target itself is selected first", () => {
|
||||
expect(hasBatchConnectionCandidate(["config", "text-a"], "config", nodes)).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,45 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
|
||||
import { buildEmotionPrompt, emotionProviderMask, normalizeEmotionPromptForProvider, resolveEmotionEditPlan } from "../src/lib/canvas/canvas-emotion";
|
||||
|
||||
const faceBox = { id: "face-1", x: 120, y: 80, width: 160, height: 180, source: "detected" as const };
|
||||
const editRegion = { x: 40, y: 20, width: 320, height: 360 };
|
||||
|
||||
describe("emotion edit provider plan", () => {
|
||||
test("keeps provider mask when the selected image model supports it", () => {
|
||||
expect(resolveEmotionEditPlan(true)).toEqual({ mode: "provider-mask", includeMask: true });
|
||||
});
|
||||
|
||||
test("falls back visibly to local compositing when the provider does not support masks", () => {
|
||||
const plan = resolveEmotionEditPlan(false);
|
||||
expect(plan).toEqual({
|
||||
mode: "local-composite",
|
||||
includeMask: false,
|
||||
notice: "当前渠道不支持蒙版,使用脸部裁切与本地羽化融合",
|
||||
});
|
||||
expect(emotionProviderMask(plan, { dataUrl: "data:image/png;base64,bWFzaw==" })).toBeUndefined();
|
||||
});
|
||||
|
||||
test("does not promise a provider mask in the shared emotion prompt", () => {
|
||||
const prompt = buildEmotionPrompt(
|
||||
{ presetId: "emotion-0-0", intimacy: 0, arousal: 0, characterName: "角色1", faceBox },
|
||||
editRegion,
|
||||
);
|
||||
|
||||
expect(prompt).not.toContain("透明蒙版");
|
||||
expect(prompt).not.toContain("白色蒙版");
|
||||
expect(prompt).toContain("目标人脸框");
|
||||
});
|
||||
|
||||
test("removes legacy mask promises before retrying an existing emotion node", () => {
|
||||
const legacyPrompt = [
|
||||
"只允许修改目标框及其透明蒙版对应的这一张脸。",
|
||||
"输入图已裁切到目标人物头部区域;透明蒙版内允许编辑,白色蒙版区域必须保持不变,蒙版外绝对不要生成或修改任何内容。",
|
||||
].join("\n");
|
||||
|
||||
const prompt = normalizeEmotionPromptForProvider(legacyPrompt);
|
||||
expect(prompt).not.toContain("透明蒙版");
|
||||
expect(prompt).not.toContain("白色蒙版");
|
||||
expect(prompt).toContain("目标人脸框及其邻近表情区域");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,88 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
|
||||
import { failedImageBatchChildren, markImageBatchRetrying, reconcileImageBatchRoot, restoreUnsubmittedImageBatchChild } from "../src/lib/canvas/canvas-image-batch-retry";
|
||||
import { CanvasNodeType, type CanvasNodeData, type CanvasNodeStatus } from "../src/types/canvas";
|
||||
|
||||
function imageNode(id: string, status: CanvasNodeStatus, metadata: Partial<NonNullable<CanvasNodeData["metadata"]>> = {}): CanvasNodeData {
|
||||
return {
|
||||
id,
|
||||
type: CanvasNodeType.Image,
|
||||
title: id,
|
||||
position: { x: 0, y: 0 },
|
||||
width: 320,
|
||||
height: 240,
|
||||
metadata: { status, ...metadata },
|
||||
};
|
||||
}
|
||||
|
||||
describe("canvas image batch retry", () => {
|
||||
test("只按批次顺序返回属于当前根节点的失败图片", () => {
|
||||
const root = imageNode("root", "error", { isBatchRoot: true, batchChildIds: ["failed-2", "success", "failed-1", "loading", "foreign"] });
|
||||
const nodes = [
|
||||
root,
|
||||
imageNode("failed-1", "error", { batchRootId: root.id }),
|
||||
imageNode("failed-2", "error", { batchRootId: root.id }),
|
||||
imageNode("success", "success", { batchRootId: root.id, content: "image:success" }),
|
||||
imageNode("loading", "loading", { batchRootId: root.id }),
|
||||
imageNode("foreign", "error", { batchRootId: "another-root" }),
|
||||
];
|
||||
|
||||
expect(failedImageBatchChildren(root, nodes).map((node) => node.id)).toEqual(["failed-2", "failed-1"]);
|
||||
});
|
||||
|
||||
test("任一子图成功后恢复根节点主图,同时保留其他失败子图", () => {
|
||||
const root = imageNode("root", "loading", { isBatchRoot: true, batchChildIds: ["failed", "success"], errorDetails: "旧错误" });
|
||||
const failed = imageNode("failed", "error", { batchRootId: root.id, errorDetails: "上游失败" });
|
||||
const success = imageNode("success", "success", {
|
||||
batchRootId: root.id,
|
||||
content: "image:success",
|
||||
storageKey: "resource:success",
|
||||
mimeType: "image/png",
|
||||
naturalWidth: 1024,
|
||||
naturalHeight: 1024,
|
||||
});
|
||||
|
||||
const next = reconcileImageBatchRoot(root, [root, failed, success]);
|
||||
|
||||
expect(next.metadata).toMatchObject({ status: "success", content: "image:success", storageKey: "resource:success", primaryImageId: "success", batchFailedCount: 1 });
|
||||
expect(next.metadata.errorDetails).toBeUndefined();
|
||||
expect(failed.metadata.status).toBe("error");
|
||||
});
|
||||
|
||||
test("全部子图失败时把代表错误同步回根节点", () => {
|
||||
const root = imageNode("root", "loading", { isBatchRoot: true, batchChildIds: ["failed-1", "failed-2"] });
|
||||
const failed = imageNode("failed-1", "error", {
|
||||
batchRootId: root.id,
|
||||
errorDetails: "No available compatible accounts",
|
||||
generationErrorCode: "upstream_unavailable",
|
||||
});
|
||||
|
||||
const next = reconcileImageBatchRoot(root, [root, failed, imageNode("failed-2", "error", { batchRootId: root.id, errorDetails: "另一错误" })]);
|
||||
|
||||
expect(next.metadata).toMatchObject({ status: "error", errorDetails: "No available compatible accounts", generationErrorCode: "upstream_unavailable", batchFailedCount: 2 });
|
||||
expect(next.metadata.content).toBeUndefined();
|
||||
expect(next.metadata.primaryImageId).toBeUndefined();
|
||||
});
|
||||
|
||||
test("开始批量重试时根节点和全部失败子图同步进入生成中", () => {
|
||||
const root = imageNode("root", "error", { isBatchRoot: true, batchChildIds: ["failed-1", "success", "failed-2"], batchFailedCount: 2, errorDetails: "全部失败" });
|
||||
const failed1 = imageNode("failed-1", "error", { batchRootId: root.id, errorDetails: "失败 1" });
|
||||
const success = imageNode("success", "success", { batchRootId: root.id, content: "image:success" });
|
||||
const failed2 = imageNode("failed-2", "error", { batchRootId: root.id, errorDetails: "失败 2" });
|
||||
|
||||
const next = markImageBatchRetrying(root.id, [failed1.id, failed2.id], [root, failed1, success, failed2]);
|
||||
|
||||
expect(next.find((node) => node.id === root.id)?.metadata).toMatchObject({ status: "loading", batchFailedCount: 2 });
|
||||
expect(next.find((node) => node.id === failed1.id)?.metadata?.status).toBe("loading");
|
||||
expect(next.find((node) => node.id === failed2.id)?.metadata?.status).toBe("loading");
|
||||
expect(next.find((node) => node.id === success.id)?.metadata).toMatchObject({ status: "success", content: "image:success" });
|
||||
});
|
||||
|
||||
test("未提交请求的失败子图从预备加载状态恢复原错误", () => {
|
||||
const original = imageNode("failed", "error", { batchRootId: "root", errorDetails: "原始失败" });
|
||||
const loading = imageNode("failed", "loading", { batchRootId: "root" });
|
||||
|
||||
expect(restoreUnsubmittedImageBatchChild(loading, original).metadata).toMatchObject({ status: "error", errorDetails: "原始失败" });
|
||||
expect(restoreUnsubmittedImageBatchChild(imageNode("failed", "success", { content: "image:success" }), original).metadata?.status).toBe("success");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,22 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
|
||||
import { expandStoryboardTextMentions } from "../src/lib/canvas/canvas-project-domain";
|
||||
import type { CanvasResourceReference } from "../src/lib/canvas/canvas-resource-references";
|
||||
|
||||
describe("expandStoryboardTextMentions", () => {
|
||||
test("展开引用控件保存的 canonical node token", () => {
|
||||
const references: CanvasResourceReference[] = [{
|
||||
id: "script-node",
|
||||
nodeId: "script-node",
|
||||
kind: "text",
|
||||
label: "文本1",
|
||||
title: "完整剧本",
|
||||
text: "第一场:主角推开仓库大门。",
|
||||
active: true,
|
||||
}];
|
||||
|
||||
expect(expandStoryboardTextMentions("@[node:script-node] 拆成 8 个镜头", references)).toBe(
|
||||
"【项目设定:完整剧本】\n第一场:主角推开仓库大门。 拆成 8 个镜头",
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,23 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
|
||||
import { grokImagePromptLimitError } from "../src/lib/grok-image-prompt-limit";
|
||||
|
||||
describe("grokImagePromptLimitError", () => {
|
||||
test("按完整提示词的 UTF-8 字节数阻止超限请求且不改写原文", () => {
|
||||
const prompt = "中".repeat(4001);
|
||||
|
||||
const error = grokImagePromptLimitError(prompt, "grok-image", "grok-imagine-image-quality");
|
||||
|
||||
expect(error).toContain("12003 UTF-8 字节");
|
||||
expect(error).toContain("8000");
|
||||
expect(error).toContain("系统不会自动删改");
|
||||
expect(error).toContain("连线文本");
|
||||
expect(prompt).toBe("中".repeat(4001));
|
||||
});
|
||||
|
||||
test("不限制其他图片协议、Grok Lite 或未超限的 Quality 提示词", () => {
|
||||
expect(grokImagePromptLimitError("中".repeat(4001), "openai-image", "grok-imagine-image-quality")).toBeNull();
|
||||
expect(grokImagePromptLimitError("中".repeat(4001), "grok-image", "grok-imagine-image")).toBeNull();
|
||||
expect(grokImagePromptLimitError("中".repeat(2000), "grok-image", "grok-imagine-image-quality")).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,62 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
|
||||
import {
|
||||
buildImageResolutionOptions,
|
||||
formatImageResolutionSize,
|
||||
imageResolutionChoices,
|
||||
imageResolutionOption,
|
||||
imageSizeForResolution,
|
||||
supportsImageResolutionPresets,
|
||||
} from "../src/lib/image-resolution-tiers";
|
||||
import { defaultImageCapabilityConfig } from "../src/lib/model-capabilities";
|
||||
|
||||
const sizes = [
|
||||
"1024x1024", "1360x1024", "1024x1360", "1536x1024", "1024x1536", "1024x1280", "1280x1024", "2048x878", "1824x1024", "1024x1824",
|
||||
"2048x2048", "2304x1728", "1728x2304", "2496x1664", "1664x2496", "1792x2240", "2240x1792", "3136x1344", "2752x1536", "1536x2752",
|
||||
"2880x2880", "3264x2448", "2448x3264", "3504x2336", "2336x3504", "2560x3200", "3200x2560", "3808x1632", "3840x2160", "2160x3840",
|
||||
];
|
||||
|
||||
describe("image resolution tiers", () => {
|
||||
test("将 Xiaobaishu 的精确尺寸整理为 1K、2K、4K 各十种比例", () => {
|
||||
const options = buildImageResolutionOptions(sizes);
|
||||
|
||||
expect(options.filter((item) => item.tier === "1k")).toHaveLength(10);
|
||||
expect(options.filter((item) => item.tier === "2k")).toHaveLength(10);
|
||||
expect(options.filter((item) => item.tier === "4k")).toHaveLength(10);
|
||||
expect(imageResolutionOption(options, "1536x2752")).toMatchObject({ tier: "2k", ratio: "9:16" });
|
||||
});
|
||||
|
||||
test("切换档位时按相同比例还原精确像素并显示比例与档位", () => {
|
||||
const options = buildImageResolutionOptions(sizes);
|
||||
|
||||
expect(imageSizeForResolution(options, "4k", "9:16")).toBe("2160x3840");
|
||||
expect(formatImageResolutionSize("1536x2752", options)).toBe("9:16 · 2K");
|
||||
});
|
||||
|
||||
test("保留自动尺寸并在摘要中显示中文标签", () => {
|
||||
const values = ["auto", ...sizes];
|
||||
|
||||
expect(imageResolutionChoices(values)).toEqual(["auto", "1k", "2k", "4k"]);
|
||||
expect(buildImageResolutionOptions(values)).toHaveLength(30);
|
||||
expect(formatImageResolutionSize("auto", [])).toBe("自动");
|
||||
});
|
||||
|
||||
test("允许自定义尺寸时仍保留自动与分辨率预设", () => {
|
||||
expect(supportsImageResolutionPresets({ parameter: "size", values: ["auto", ...sizes], allowCustom: true })).toBe(true);
|
||||
});
|
||||
|
||||
test("默认图片能力为 OpenAI 兼容模型提供完整 1K、2K、4K 档位", () => {
|
||||
const profile = defaultImageCapabilityConfig("openai-image", "gpt-image-2-4K");
|
||||
|
||||
expect(supportsImageResolutionPresets(profile.size)).toBe(true);
|
||||
expect(imageResolutionChoices(profile.size.values)).toEqual(["auto", "1k", "2k", "4k"]);
|
||||
expect(buildImageResolutionOptions(profile.size.values)).toHaveLength(30);
|
||||
});
|
||||
|
||||
test("识别适合全景图的 2:1 自定义尺寸", () => {
|
||||
const options = buildImageResolutionOptions(["1440x720", "2048x1024", "3840x1920"]);
|
||||
|
||||
expect(options.map((item) => item.tier)).toEqual(["1k", "2k", "4k"]);
|
||||
expect(imageResolutionOption(options, "3840x1920")).toMatchObject({ tier: "4k", ratio: "2:1" });
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user