若干更新迭代
This commit is contained in:
1 parent
deb9cc5bcf
commit
4ac786aafb
64 files changed
+3251
-291
No files matched your search
@@ -1,6 +1,7 @@
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import {
|
||||
bailianResultUrls,
|
||||
bailianOutputVideoSeconds,
|
||||
bailianStatus,
|
||||
buildBailianImagePayload,
|
||||
buildBailianVideoPayload,
|
||||
@@ -58,6 +59,19 @@ describe("Bailian client", () => {
|
||||
expect(payload.parameters).not.toHaveProperty("size");
|
||||
});
|
||||
|
||||
it("maps Wan 3.0 references while retaining optional first and last frames", () => {
|
||||
const materials = Array.from({ length: 10 }, (_, index) => ({ type: "image" as const, url: `/image-${index}.png` }));
|
||||
const reference = buildBailianVideoPayload({ model: "wan3.0-video", prompt: "combine", materials, origin: "https://app.test", settings: { duration: 30, resolution: "480P", ratio: "9:16", inputMode: "reference" } });
|
||||
expect(reference.input.media).toHaveLength(10);
|
||||
expect(reference.input.media?.every((item) => item.type === "reference_image")).toBe(true);
|
||||
expect(reference.parameters.ratio).toBe("9:16");
|
||||
const frames = buildBailianVideoPayload({ model: "wan3.0-video", prompt: "animate", materials: materials.slice(0, 2), origin: "https://app.test", settings: { inputMode: "frames" } });
|
||||
expect(frames.input.media?.map((item) => item.type)).toEqual(["first_frame", "last_frame"]);
|
||||
expect(buildBailianVideoPayload({ model: "wan3.0-video", prompt: "landscape", materials: [], origin: "https://app.test", settings: { inputMode: "reference" } }).input).toEqual({ prompt: "landscape" });
|
||||
expect(() => buildBailianVideoPayload({ model: "wan2.7-i2v-2026-04-25", prompt: "x", materials, origin: "https://app.test", settings: { inputMode: "reference" } })).toThrow("请选择 Wan 3.0");
|
||||
expect(bailianOutputVideoSeconds({ usage: { output_video_duration: 5.25, duration: 8 } })).toBe(5.25);
|
||||
});
|
||||
|
||||
it("normalizes task state and result URLs", () => {
|
||||
const response = { output: { task_status: "SUCCEEDED", results: [{ url: "https://example.com/result.png" }] } };
|
||||
expect(bailianStatus(response)).toBe("succeeded");
|
||||
|
||||
@@ -3,8 +3,9 @@ import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { DEFAULT_BILLING_PRICE_RULES, ensureDefaultBillingPriceRules } from "@/lib/server/billing-catalog";
|
||||
import { quoteGenerationCharge } from "@/lib/server/billing-service";
|
||||
import { quoteGenerationCharge, settleMinimaxGenerationCharge } from "@/lib/server/billing-service";
|
||||
import { createBillingPriceRule, getBillingPriceRule, updateBillingPriceTierMultiplier } from "@/lib/server/billing-store";
|
||||
import { createGenerationJob } from "@/lib/server/data-store";
|
||||
|
||||
let runtimeDir = "";
|
||||
|
||||
@@ -44,6 +45,50 @@ describe("default billing catalog refresh", () => {
|
||||
expect(tiers?.find((tier) => tier.value === "2K")?.markupMultiplier).toBe(1.2);
|
||||
});
|
||||
|
||||
it("quotes MiniMax nine references and keeps Wan 3.0 resolution pricing independent", async () => {
|
||||
const context = { source: "platform" as const, accountId: "user-1", displayName: "测试用户", organizationId: "org-1" };
|
||||
const minimax = await quoteGenerationCharge({
|
||||
provider: "minimax", capability: "video.generate", reqKey: "MiniMax-H3",
|
||||
requestPayload: {
|
||||
settings: { duration: 5, resolution: "2K" },
|
||||
materials: Array.from({ length: 9 }, (_, index) => ({ type: "image", url: `https://cdn.test/${index}.png` }))
|
||||
},
|
||||
usageContext: context
|
||||
});
|
||||
expect(minimax).toMatchObject({ amountFen: 576, reservedAmountFen: 576, settlementStatus: "pending" });
|
||||
expect(minimax?.parameters).toMatchObject({ inputImageCount: 9, inputImagePriceFen: 80 });
|
||||
|
||||
const wan3 = await quoteGenerationCharge({
|
||||
provider: "bailian", capability: "video.generate", reqKey: "wan3.0-video",
|
||||
requestPayload: { settings: { duration: 5, resolution: "1080P" } }, usageContext: context
|
||||
});
|
||||
expect(wan3).toMatchObject({
|
||||
priceRuleId: "base-bailian-wan30-video-1080p", amountFen: 720,
|
||||
reservedAmountFen: 720, settlementStatus: "pending"
|
||||
});
|
||||
});
|
||||
|
||||
it("retains quoted MiniMax image fees only when provider omits image usage", async () => {
|
||||
const materials = Array.from({ length: 9 }, (_, index) => ({ type: "image", url: `https://cdn.test/${index}.png` }));
|
||||
const makeJob = () => createGenerationJob({
|
||||
ownerId: "user-1", capability: "video.generate", provider: "minimax", reqKey: "MiniMax-H3", status: "succeeded",
|
||||
inputAssetIds: [], inputUrls: [], outputAssetIds: [], requestPayload: { materials },
|
||||
usageContext: { source: "platform", accountId: "user-1", displayName: "管理员", role: "super_admin" },
|
||||
billing: {
|
||||
priceRuleId: "base-minimax-h3-2k", provider: "minimax", capability: "video.generate", reqKey: "MiniMax-H3",
|
||||
unit: "video_second", quantity: 5, standardUnitPriceFen: 80, markupMultiplier: 1.2,
|
||||
amountFen: 576, reservedAmountFen: 576, currency: "CNY", status: "not_charged", quotaExempt: true,
|
||||
settlementStatus: "pending", parameters: { resolution: "2k", inputImageCount: 9 }
|
||||
}
|
||||
});
|
||||
|
||||
const missing = await settleMinimaxGenerationCharge(await makeJob(), { outputSeconds: 5, inputSeconds: 0 });
|
||||
expect(missing?.billing).toMatchObject({ amountFen: 576, settlementStatus: "settled", providerUsage: { inputImageCount: 9 } });
|
||||
|
||||
const explicitZero = await settleMinimaxGenerationCharge(await makeJob(), { outputSeconds: 5, inputSeconds: 0, inputImageCount: 0 });
|
||||
expect(explicitZero?.billing).toMatchObject({ amountFen: 480, settlementStatus: "settled", providerUsage: { inputImageCount: 0 } });
|
||||
});
|
||||
|
||||
it("takes current default definitions while retaining valid matching tier markups", async () => {
|
||||
const candidate = DEFAULT_BILLING_PRICE_RULES.find((rule) => rule.id === "base-seedream-5-0-pro")!;
|
||||
const size = candidate.parameterDimensions!.find((dimension) => dimension.key === "size")!;
|
||||
|
||||
@@ -11,6 +11,7 @@ describe("billing HTTP compatibility fixture", () => {
|
||||
});
|
||||
expect(fixture.routes).toEqual(expect.arrayContaining([
|
||||
expect.objectContaining({ method: "GET", path: "/api/billing", organizationSource: "refreshed_session" }),
|
||||
expect.objectContaining({ method: "GET", path: "/api/billing/balance", requirement: "app", organizationSource: "refreshed_session", unboundStatus: 422, cacheControl: "no-store", responseFields: ["organization", "wallet"] }),
|
||||
expect.objectContaining({ method: "PATCH", path: "/api/admin/billing/prices", requirement: "super_admin", allowedFields: ["ruleIds", "markupMultiplier"], atomic: true }),
|
||||
expect.objectContaining({ method: "PATCH", path: "/api/admin/billing/prices/{id}", requirement: "super_admin" })
|
||||
]));
|
||||
|
||||
@@ -46,13 +46,14 @@ describe("create studio image paste wiring", () => {
|
||||
.toBeLessThan(flow.indexOf("setMaterials(next)"));
|
||||
});
|
||||
|
||||
it("routes special modes through existing source limits and adds references only for basic image or video", async () => {
|
||||
it("validates video paste without replacing or truncating selected materials", async () => {
|
||||
const { flow } = await pasteFlowSource();
|
||||
expect(flow).toContain('imageCreationMode === "interactive" ? "append" : "single"');
|
||||
expect(flow).toContain("isSeedreamLayerInputFile(files[0])");
|
||||
expect(flow).toContain("if (selectedSeedreamSource && !window.confirm(");
|
||||
expect(flow).toContain("options.maxFiles = 1");
|
||||
expect(flow).toContain("options.replaceAll = true");
|
||||
expect(flow).toContain("videoMaterialError(videoSelection, [...materials");
|
||||
expect(flow).toContain("videoMaterialError(videoSelection, next)");
|
||||
expect(flow).not.toContain("options.replaceAll = true");
|
||||
expect(flow).toContain("options.maxFiles = remaining");
|
||||
expect(flow).toContain("insertReferences: !specializedImageMode");
|
||||
expect(flow).toContain('const labels = addedMaterials.map((material) => material.label || "");');
|
||||
|
||||
@@ -0,0 +1,99 @@
|
||||
import { createElement } from "react";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { GenerationError } from "@/components/generation-error";
|
||||
import { generationErrorMessage, providerErrorDiagnostic } from "@/lib/client/provider-error";
|
||||
import type { GenerationJob } from "@/lib/types";
|
||||
|
||||
function job(overrides: Partial<GenerationJob> = {}): GenerationJob {
|
||||
return {
|
||||
id: "job-test",
|
||||
ownerId: "user-test",
|
||||
capability: "video.generate",
|
||||
provider: "seedance",
|
||||
reqKey: "doubao-seedance-2-0-260128",
|
||||
status: "failed",
|
||||
inputAssetIds: [],
|
||||
inputUrls: [],
|
||||
outputAssetIds: [],
|
||||
requestPayload: {},
|
||||
error: { code: "InputImageSensitiveContentDetected.PrivacyInformation", message: "参考图片涉及真人肖像,未通过服务商审核。" },
|
||||
...overrides
|
||||
} as GenerationJob;
|
||||
}
|
||||
|
||||
describe("generation task error presentation", () => {
|
||||
it("shows the actionable reason without diagnostics to ordinary users", () => {
|
||||
const result = renderToStaticMarkup(createElement(GenerationError, {
|
||||
job: job({ responsePayload: { providerError: {
|
||||
status: 400,
|
||||
code: "InputImageSensitiveContentDetected.PrivacyInformation",
|
||||
requestId: "request-secret",
|
||||
detail: "provider-private-detail",
|
||||
phase: "submit"
|
||||
} } }),
|
||||
isAdmin: false,
|
||||
providerName: "Seedance"
|
||||
}));
|
||||
expect(result).toContain("失败原因");
|
||||
expect(result).toContain("参考图片涉及真人肖像");
|
||||
expect(result).not.toContain("错误详情");
|
||||
expect(result).not.toContain("request-secret");
|
||||
expect(result).not.toContain("provider-private-detail");
|
||||
});
|
||||
|
||||
it("shows only available diagnostic fields to an administrator", () => {
|
||||
const result = renderToStaticMarkup(createElement(GenerationError, {
|
||||
job: job({ responsePayload: { providerError: {
|
||||
status: 400,
|
||||
code: "InputImageSensitiveContentDetected.PrivacyInformation",
|
||||
requestId: "",
|
||||
detail: "已脱敏的服务商说明",
|
||||
phase: "submit"
|
||||
} } }),
|
||||
isAdmin: true,
|
||||
providerName: "Seedance"
|
||||
}));
|
||||
expect(result).toContain("错误详情");
|
||||
expect(result).toContain("HTTP 状态");
|
||||
expect(result).toContain("400");
|
||||
expect(result).toContain("错误码");
|
||||
expect(result).toContain("已脱敏的服务商说明");
|
||||
expect(result).not.toContain("请求 ID");
|
||||
});
|
||||
|
||||
it("does not describe a queued retry as a final failure", () => {
|
||||
const result = renderToStaticMarkup(createElement(GenerationError, {
|
||||
job: job({ status: "queued", error: { message: "服务商暂时繁忙,系统稍后重试。" } }),
|
||||
isAdmin: false,
|
||||
providerName: "Seedance"
|
||||
}));
|
||||
expect(result).toContain("任务提示");
|
||||
expect(result).not.toContain("失败原因");
|
||||
});
|
||||
|
||||
it("translates old unknown-submission records without falsely claiming rejection", () => {
|
||||
const legacy = job({ error: { message: "provider submission outcome is unknown; refusing duplicate submission" }, responsePayload: undefined });
|
||||
expect(generationErrorMessage(legacy)).toContain("平台已停止自动重试");
|
||||
expect(generationErrorMessage(legacy)).not.toContain("服务商拒绝");
|
||||
expect(generationErrorMessage(legacy)).not.toContain("稍后刷新");
|
||||
});
|
||||
|
||||
it("uses a known privacy code for an old task but ignores unrelated payload data", () => {
|
||||
const legacy = job({
|
||||
error: { code: "InputImageSensitiveContentDetected.PrivacyInformation", message: "provider submission outcome is unknown; refusing duplicate submission" },
|
||||
responsePayload: { raw: { accessToken: "must-not-appear" } }
|
||||
});
|
||||
expect(generationErrorMessage(legacy)).toContain("真人肖像");
|
||||
expect(providerErrorDiagnostic(legacy)).toEqual({ code: "InputImageSensitiveContentDetected.PrivacyInformation" });
|
||||
});
|
||||
|
||||
it("does not infer a privacy review from a code with an extra suffix", () => {
|
||||
const other = job({ error: {
|
||||
code: "InputImageSensitiveContentDetected.PrivacyInformation.Unknown",
|
||||
message: "provider submission outcome is unknown; refusing duplicate submission"
|
||||
} });
|
||||
expect(generationErrorMessage(other)).toContain("暂时无法确认服务商是否已接收");
|
||||
expect(generationErrorMessage(other)).not.toContain("真人肖像");
|
||||
});
|
||||
});
|
||||
@@ -27,6 +27,16 @@ describe("MiniMax H3 V2 client", () => {
|
||||
expect(() => buildMinimaxH3Payload({ prompt: "bad", materials: [{ type: "video", url: "/a.mp4" }], settings: {}, origin: "https://app.test" })).toThrow("只上传 1 张图片");
|
||||
});
|
||||
|
||||
it("sends every selected reference image and rejects overflow", () => {
|
||||
const materials = Array.from({ length: 9 }, (_, index) => ({ type: "image" as const, url: `/image-${index}.png` }));
|
||||
const payload = buildMinimaxH3Payload({ prompt: "combine", materials, settings: { inputMode: "reference", ratio: "adaptive" }, origin: "https://app.test" });
|
||||
expect(payload.content).toHaveLength(10);
|
||||
expect(payload.content.slice(1).every((item) => "role" in item && item.role === "reference_image")).toBe(true);
|
||||
expect(buildMinimaxH3Payload({ prompt: "text only", materials: [], settings: { inputMode: "reference", ratio: "16:9" }, origin: "https://app.test" }).content).toHaveLength(1);
|
||||
expect(() => buildMinimaxH3Payload({ prompt: "text only", materials: [], settings: { inputMode: "reference", ratio: "adaptive" }, origin: "https://app.test" })).toThrow("固定画面比例");
|
||||
expect(() => buildMinimaxH3Payload({ prompt: "x", materials: [...materials, materials[0]], settings: { inputMode: "reference" }, origin: "https://app.test" })).toThrow("最多支持 9 张图片");
|
||||
});
|
||||
|
||||
it("submits and queries the official asynchronous endpoints", async () => {
|
||||
process.env.MINIMAX_API_KEY = "test-key";
|
||||
process.env.MINIMAX_BASE_URL = "https://minimax.test";
|
||||
@@ -45,6 +55,8 @@ describe("MiniMax H3 V2 client", () => {
|
||||
|
||||
it("extracts billable usage", () => {
|
||||
expect(extractMinimaxH3Usage({ total_seconds: 8, input_seconds: 0, output_seconds: 8, input_image_count: 1 })).toEqual({ totalSeconds: 8, inputSeconds: 0, outputSeconds: 8, inputImageCount: 1 });
|
||||
expect(extractMinimaxH3Usage({ output_seconds: 8 })).not.toHaveProperty("inputImageCount");
|
||||
expect(extractMinimaxH3Usage({ output_seconds: 8, input_image_count: 0 })).toHaveProperty("inputImageCount", 0);
|
||||
expect(extractMinimaxH3Usage({ output_seconds: 0 })).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,52 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { fetchOrganizationBalance } from "@/lib/client/organization-balance";
|
||||
|
||||
const payload = (balanceFen = 12345) => ({
|
||||
organization: { id: "org-current", name: "当前组织" },
|
||||
wallet: { organizationId: "org-current", balanceFen, updatedAt: "2026-10-01T00:00:00Z" }
|
||||
});
|
||||
|
||||
afterEach(() => vi.unstubAllGlobals());
|
||||
|
||||
describe("creation organization balance", () => {
|
||||
it.each([12345, 0, -100])("preserves the available balance %i without substituting a fallback", async (amount) => {
|
||||
const fetchMock = vi.fn().mockResolvedValue(Response.json(payload(amount)));
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
const controller = new AbortController();
|
||||
expect((await fetchOrganizationBalance("org-current", controller.signal)).wallet.balanceFen).toBe(amount);
|
||||
expect(fetchMock).toHaveBeenCalledWith("/api/billing/balance", { cache: "no-store", signal: controller.signal });
|
||||
});
|
||||
|
||||
it.each([
|
||||
{ ...payload(), wallet: { ...payload().wallet, organizationId: "org-other" } },
|
||||
{ ...payload(), wallet: { ...payload().wallet, balanceFen: "12345" } },
|
||||
{ ...payload(), wallet: { ...payload().wallet, balanceFen: 1.5 } },
|
||||
{ organization: payload().organization },
|
||||
null
|
||||
])("rejects malformed or other-organization responses instead of showing a balance", async (value) => {
|
||||
vi.stubGlobal("fetch", vi.fn().mockResolvedValue(Response.json(value)));
|
||||
await expect(fetchOrganizationBalance("org-current")).rejects.toThrow("余额信息不完整");
|
||||
});
|
||||
|
||||
it("explains when the server has refreshed the user into a different organization", async () => {
|
||||
vi.stubGlobal("fetch", vi.fn().mockResolvedValue(Response.json({
|
||||
...payload(), organization: { id: "org-other", name: "新组织" }
|
||||
})));
|
||||
await expect(fetchOrganizationBalance("org-current")).rejects.toThrow("账号所属组织已变更,请刷新页面");
|
||||
});
|
||||
|
||||
it.each([401, 403, 422])("explains expired or changed access for HTTP %i", async (status) => {
|
||||
vi.stubGlobal("fetch", vi.fn().mockResolvedValue(Response.json({}, { status })));
|
||||
await expect(fetchOrganizationBalance("org-current")).rejects.toThrow("登录状态或组织信息已变更,请刷新页面");
|
||||
});
|
||||
|
||||
it("does not expose internal errors or turn failures into zero balance", async () => {
|
||||
vi.stubGlobal("fetch", vi.fn().mockResolvedValue(Response.json({ error: "internal database details" }, { status: 500 })));
|
||||
await expect(fetchOrganizationBalance("org-current")).rejects.toThrow("余额暂时无法读取,请重试。");
|
||||
});
|
||||
|
||||
it("handles a non-JSON proxy response as an error", async () => {
|
||||
vi.stubGlobal("fetch", vi.fn().mockResolvedValue(new Response("gateway unavailable", { status: 502 })));
|
||||
await expect(fetchOrganizationBalance("org-current")).rejects.toThrow("余额暂时无法读取,请重试。");
|
||||
});
|
||||
});
|
||||
@@ -17,7 +17,7 @@ describe("Go provider adapter contract", () => {
|
||||
submit: "/contents/generations/tasks",
|
||||
query: "/contents/generations/tasks/{id}",
|
||||
maxMaterials: {
|
||||
"doubao-seedance-2-0-260128": 4,
|
||||
"doubao-seedance-2-0-260128": 15,
|
||||
"doubao-seedance-2-5-260628": 50
|
||||
},
|
||||
maxDurationSeconds: {
|
||||
@@ -40,8 +40,8 @@ describe("Go provider adapter contract", () => {
|
||||
model: "MiniMax-H3",
|
||||
submit: "/v2/video_generation",
|
||||
query: "/v2/query/video_generation/{id}",
|
||||
modes: ["text-to-video", "first-frame-to-video"],
|
||||
maxMaterials: 1,
|
||||
modes: ["text-to-video", "first-frame-to-video", "reference-to-video"],
|
||||
maxMaterials: 9,
|
||||
durationSeconds: { min: 4, max: 15 },
|
||||
resolutions: ["768P", "2K"]
|
||||
});
|
||||
|
||||
@@ -88,21 +88,40 @@ describe("Seedance usage extraction", () => {
|
||||
expect(body?.content).toHaveLength(6);
|
||||
});
|
||||
|
||||
it("rejects more materials than the Seedance 2.0 content limit before calling the provider", async () => {
|
||||
it("accepts nine Seedance 2.0 reference images and rejects a tenth", async () => {
|
||||
process.env.SEEDANCE_API_KEY = "test-key";
|
||||
let body: Record<string, unknown> | undefined;
|
||||
const fetchMock = vi.fn(async (_input: RequestInfo | URL, init?: RequestInit) => {
|
||||
body = JSON.parse(String(init?.body));
|
||||
return new Response(JSON.stringify({ id: "task-20" }), { status: 200, headers: { "Content-Type": "application/json" } });
|
||||
});
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
const materials = Array.from({ length: 9 }, (_, index) => ({
|
||||
type: "image" as const,
|
||||
url: `/image-${index + 1}.png`,
|
||||
label: `@图片${index + 1}`
|
||||
}));
|
||||
await createSeedanceTask({
|
||||
prompt: "combine",
|
||||
settings: {},
|
||||
materials,
|
||||
origin: "https://app.test"
|
||||
});
|
||||
expect(body?.content).toHaveLength(10);
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
await expect(createSeedanceTask({
|
||||
prompt: "combine", settings: {}, materials: [...materials, { type: "image", url: "/image-10.png" }], origin: "https://app.test"
|
||||
})).rejects.toThrow("最多支持 9 张图片");
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("rejects Seedance 2.0 audio without an image or video", async () => {
|
||||
process.env.SEEDANCE_API_KEY = "test-key";
|
||||
const fetchMock = vi.fn();
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
|
||||
await expect(createSeedanceTask({
|
||||
prompt: "combine",
|
||||
settings: {},
|
||||
materials: Array.from({ length: 5 }, (_, index) => ({
|
||||
type: "image" as const,
|
||||
url: `/image-${index + 1}.png`,
|
||||
label: `@图片${index + 1}`
|
||||
})),
|
||||
origin: "https://app.test"
|
||||
})).rejects.toThrow("最多支持 4 个素材");
|
||||
prompt: "music", settings: {}, materials: [{ type: "audio", url: "/music.mp3" }], origin: "https://app.test"
|
||||
})).rejects.toThrow("音频不能单独使用");
|
||||
expect(fetchMock).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,34 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { BAILIAN_27_VIDEO_MODEL, BAILIAN_30_VIDEO_MODEL, videoImageFileError, videoMaterialError } from "@/lib/video-input";
|
||||
import { SEEDANCE_20_MODEL, SEEDANCE_25_MODEL } from "@/lib/video-settings";
|
||||
|
||||
const images = (count: number) => Array.from({ length: count }, () => ({ type: "image" as const }));
|
||||
|
||||
describe("video input mode limits", () => {
|
||||
it("preserves two-frame mode while allowing ten Wan 3.0 reference images", () => {
|
||||
expect(videoMaterialError({ engine: "bailian", model: BAILIAN_27_VIDEO_MODEL, inputMode: "frames" }, images(2))).toBeNull();
|
||||
expect(videoMaterialError({ engine: "bailian", model: BAILIAN_27_VIDEO_MODEL, inputMode: "frames" }, images(3))).toMatch(/Wan 3.0/);
|
||||
expect(videoMaterialError({ engine: "bailian", model: BAILIAN_30_VIDEO_MODEL, inputMode: "reference" }, images(10))).toBeNull();
|
||||
expect(videoMaterialError({ engine: "bailian", model: BAILIAN_30_VIDEO_MODEL, inputMode: "reference" }, images(11))).toMatch(/10 张/);
|
||||
expect(videoMaterialError({ engine: "bailian", model: BAILIAN_30_VIDEO_MODEL, inputMode: "frames" }, images(3))).toMatch(/首尾帧/);
|
||||
});
|
||||
|
||||
it("explains MiniMax first-frame and reference alternatives", () => {
|
||||
expect(videoMaterialError({ engine: "minimax", model: "MiniMax-H3", inputMode: "frames" }, images(2))).toMatch(/切换到「多图参考」/);
|
||||
expect(videoMaterialError({ engine: "minimax", model: "MiniMax-H3", inputMode: "reference" }, images(9))).toBeNull();
|
||||
expect(videoMaterialError({ engine: "minimax", model: "MiniMax-H3", inputMode: "reference" }, images(10))).toMatch(/9 张/);
|
||||
});
|
||||
|
||||
it("allows Seedance 2.0 category maxima and rejects audio alone", () => {
|
||||
const selection = { engine: "seedance" as const, model: SEEDANCE_20_MODEL, inputMode: "reference" as const };
|
||||
expect(videoMaterialError(selection, [...images(9), ...Array.from({ length: 3 }, () => ({ type: "video" as const })), ...Array.from({ length: 3 }, () => ({ type: "audio" as const }))])).toBeNull();
|
||||
expect(videoMaterialError(selection, [{ type: "audio" }])).toMatch(/不能单独/);
|
||||
expect(videoMaterialError({ ...selection, model: SEEDANCE_25_MODEL }, [{ type: "audio" }])).toBeNull();
|
||||
});
|
||||
|
||||
it("explains local reference-image format and size failures", () => {
|
||||
const selection = { engine: "bailian" as const, model: BAILIAN_30_VIDEO_MODEL, inputMode: "reference" as const };
|
||||
expect(videoImageFileError(selection, { name: "design.heic", size: 12, type: "image/heic" })).toMatch(/格式/);
|
||||
expect(videoImageFileError(selection, { name: "design.png", size: 21 * 1024 * 1024, type: "image/png" })).toMatch(/20 MB/);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user