feat(canvas): 画布媒体工作流 - 支持批量连线、失败重试与模型能力增强

合并 PR #230。保留当前主线样式与组件结构,吸收批量连线、失败批次重试、媒体模型能力和相关后端增强,并修复集成检查发现的分辨率选择器启用条件与测试稳定性问题。
This commit is contained in:
WindC0X authored and GitHub committed 2026-08-15 15:48:16 +08:00
1 parent b0c3159f8e
commit 0e251c3c8f
48 files changed
+1582 -83

No files matched your search

+115
View File
@@ -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);
});
});
+45
View File
@@ -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("目标人脸框及其邻近表情区域");
});
});
+88
View File
@@ -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 个镜头",
);
});
});
+23
View File
@@ -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();
});
});
+62
View File
@@ -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" });
});
});