功能增加

This commit is contained in:
andy
2026-09-11 15:33:18 +08:00
parent b340fc7ad7
commit f1993eb388
69 changed files with 2243 additions and 162 deletions

15
tests/a4-billing.test.ts Normal file
View File

@@ -0,0 +1,15 @@
import { describe, expect, it } from "vitest";
import { normalizeBillingParameters } from "@/lib/server/billing-service";
import { DEFAULT_BILLING_PRICE_RULES } from "@/lib/server/billing-catalog";
describe("A4 billing metadata", () => {
it.each([[1680, 2376, "70:99"], [2376, 1680, "99:70"], [848, 1200, "70:99"], [1200, 848, "99:70"]])("recognizes %s × %s without changing the markup", (width, height, aspectRatio) => {
const dimensions = { width, height };
for (const payload of [dimensions, { input: dimensions }, { settings: dimensions }, { providerPayload: dimensions }]) {
expect(normalizeBillingParameters(payload)).toMatchObject({ aspectRatio, size: `${width}*${height}` });
}
const rule = DEFAULT_BILLING_PRICE_RULES.find((rule) => rule.id === "base-evolink-gpt-image-2")!;
const dimension = rule.parameterDimensions!.find((dimension) => dimension.key === "aspectRatio")!;
expect(dimension.tiers.find((tier) => tier.value === aspectRatio)).toMatchObject({ standardFactor: 1, markupMultiplier: 1.2, enabled: true });
});
});

View File

@@ -7,7 +7,9 @@ describe("create studio template interaction", () => {
it("offers the shared 3:4 portrait preset in creation and template settings", async () => {
const source = await readFile(createStudioUrl, "utf8");
expect(source).toContain('{ label: "3:4", width: 1728, height: 2304 }');
const presets = await readFile(new URL("../lib/image-size-presets.ts", import.meta.url), "utf8");
expect(presets).toContain('{ label: "3:4", width: 1728, height: 2304 }');
expect(source).toContain('from "@/lib/image-size-presets"');
expect(source.match(/imageSizePresets\.map/g)?.length || 0).toBeGreaterThanOrEqual(2);
});

View File

@@ -0,0 +1,70 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
const { queryDatabase } = vi.hoisted(() => ({ queryDatabase: vi.fn() }));
vi.mock("@/lib/server/database", () => ({
isPostgresBackend: () => true,
queryDatabase,
withDatabaseTransaction: vi.fn()
}));
import { getGenerationJob } from "@/lib/server/data-store";
describe("generation job MiniMax billing mapping", () => {
beforeEach(() => queryDatabase.mockReset());
it("retains MiniMax actual usage when a PostgreSQL job is read", async () => {
queryDatabase.mockResolvedValueOnce({
rows: [{
id: "job-h3",
owner_id: "account-1",
capability: "video.generate",
provider: "minimax",
req_key: "MiniMax-H3",
status: "succeeded",
input_asset_ids: [],
input_urls: [],
output_asset_ids: ["asset-1"],
request_payload: {},
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: 480,
currency: "CNY",
status: "charged",
settlementStatus: "settled",
providerUsage: {
model: "MiniMax-H3",
resolution: "2K",
outputSeconds: 5,
inputVideoSeconds: 0,
inputImageCount: 1,
videoPriceFenPerSecond: 80
}
},
created_at: new Date("2026-09-04T01:00:00.000Z"),
updated_at: new Date("2026-09-04T01:05:00.000Z")
}]
});
const job = await getGenerationJob("job-h3");
expect(job?.billing?.providerUsage).toEqual({
resolution: "2K",
model: "MiniMax-H3",
completionTokens: undefined,
inputVideo: undefined,
tokenPriceFenPerMillion: undefined,
outputSeconds: 5,
inputVideoSeconds: 0,
inputImageCount: 1,
videoPriceFenPerSecond: 80
});
});
});

View File

@@ -25,6 +25,9 @@ describe("PostgreSQL readiness contract", () => {
]) {
expect(source).toContain(`('${table}',`);
}
expect(source).toContain("billing_price_rules_provider_check");
expect(source).toContain("position('seedream'");
expect(source).toContain("position('minimax'");
expect(source).toContain("claim_generation_jobs(text,integer,integer)");
expect(source).toContain("billing_post_wallet_entry(text,text,text,text,text,bigint,text,text,text,jsonb)");
});

View File

@@ -0,0 +1,42 @@
import { describe, expect, it } from "vitest";
import { imageDimensionsForEngine, imageSizePresets, imageSizePresetFromDimensions } from "@/lib/image-size-presets";
import { buildEvolinkImagePayload } from "@/lib/evolink/image-client";
import { validateBailianImageSize } from "@/lib/bailian/client";
describe("A4 generation presets", () => {
it("retains the five existing presets unchanged", () => {
expect(imageSizePresets.slice(0, 5)).toEqual([
{ label: "1:1", width: 2048, height: 2048 },
{ label: "4:3", width: 2304, height: 1728 },
{ label: "3:4", width: 1728, height: 2304 },
{ label: "16:9", width: 2560, height: 1440 },
{ label: "9:16", width: 1440, height: 2560 }
]);
});
it.each(imageSizePresets.filter((size) => size.paper === "a4"))("round trips $label through template dimensions for every regular engine", (size) => {
expect(Math.min(size.width, size.height) / Math.max(size.width, size.height)).toBeCloseTo(210 / 297, 8);
for (const engine of ["jimeng", "bailian", "evolink"]) {
const dimensions = imageDimensionsForEngine(size, engine);
expect(imageSizePresetFromDimensions(dimensions.width, dimensions.height)).toEqual(size);
if (engine === "bailian") {
expect(() => validateBailianImageSize(dimensions.width, dimensions.height, true)).not.toThrow();
}
}
});
it.each(imageSizePresets.filter((size) => size.paper === "a4"))("keeps $label on EvoLink's 1K budget rather than sending 4MP custom pixels", (size) => {
const dimensions = imageDimensionsForEngine(size, "evolink");
expect(dimensions.width % 16).toBe(0);
expect(dimensions.height % 16).toBe(0);
expect(dimensions.width * dimensions.height).toBeGreaterThanOrEqual(655360);
expect(dimensions.width * dimensions.height).toBeLessThanOrEqual(1024 ** 2);
expect(Math.min(dimensions.width, dimensions.height) / Math.max(dimensions.width, dimensions.height)).toBeCloseTo(210 / 297, 3);
// Both a new frontend payload and an older template's canonical dimensions
// are adapted before sending to the upstream.
for (const input of [size, dimensions]) {
const payload = buildEvolinkImagePayload("image.generate", { prompt: "A4 行程单", ...input }, { baseUrl: "https://api.evolink.ai", model: "gpt-image-2" });
expect(payload).toMatchObject({ size: `${dimensions.width}x${dimensions.height}`, resolution: "1K" });
}
});
});

View File

@@ -0,0 +1,11 @@
import { describe, expect, it } from "vitest";
import { calculateMinimaxH3ActualAmountFen, minimaxH3VideoPriceFenPerSecond } from "@/lib/server/minimax-billing";
describe("MiniMax H3 billing", () => {
it("uses official 768P and 2K rates with the platform 1.2 markup", () => {
expect(minimaxH3VideoPriceFenPerSecond("768P")).toBe(50);
expect(minimaxH3VideoPriceFenPerSecond("2k")).toBe(80);
expect(calculateMinimaxH3ActualAmountFen({ resolution: "768P", outputSeconds: 5, inputImageCount: 1, markupMultiplier: 1.2 })).toBe(300);
expect(calculateMinimaxH3ActualAmountFen({ resolution: "2K", outputSeconds: 5, inputImageCount: 1, markupMultiplier: 1.2 })).toBe(480);
});
});

View File

@@ -0,0 +1,50 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { buildMinimaxH3Payload, createMinimaxH3Task, extractMinimaxH3Usage, queryMinimaxH3Task } from "@/lib/minimax/client";
describe("MiniMax H3 V2 client", () => {
afterEach(() => {
vi.unstubAllGlobals();
delete process.env.MINIMAX_API_KEY;
delete process.env.MINIMAX_BASE_URL;
});
it("builds text and single first-frame payloads with bounded parameters", () => {
expect(buildMinimaxH3Payload({ prompt: "move", materials: [], settings: { duration: 5, resolution: "2K", ratio: "3:4" }, origin: "https://app.test" })).toMatchObject({
model: "MiniMax-H3", duration: 5, resolution: "2K", ratio: "3:4", aigc_watermark: false,
content: [{ type: "text", text: "move" }]
});
const image = buildMinimaxH3Payload({
prompt: "animate", materials: [{ type: "image", url: "/asset.png", label: "@图片1" }],
settings: { duration: 8, resolution: "768p", ratio: "16:9" }, origin: "https://app.test"
});
expect(image).toMatchObject({
resolution: "768P", ratio: "adaptive",
content: [
{ type: "text", text: "animate" },
{ type: "image_url", image_url: { url: "https://app.test/asset.png" }, role: "first_frame" }
]
});
expect(() => buildMinimaxH3Payload({ prompt: "bad", materials: [{ type: "video", url: "/a.mp4" }], settings: {}, origin: "https://app.test" })).toThrow("只上传 1 张图片");
});
it("submits and queries the official asynchronous endpoints", async () => {
process.env.MINIMAX_API_KEY = "test-key";
process.env.MINIMAX_BASE_URL = "https://minimax.test";
const fetchMock = vi.fn()
.mockResolvedValueOnce(new Response(JSON.stringify({ task_id: "task-h3" }), { status: 200 }))
.mockResolvedValueOnce(new Response(JSON.stringify({ task: { id: "task-h3", status: "succeeded", content: { url: "https://cdn.test/video.mp4" }, usage: { total_seconds: 5, input_seconds: 0, output_seconds: 5, input_image_count: 1 } } }), { status: 200 }));
vi.stubGlobal("fetch", fetchMock);
const payload = buildMinimaxH3Payload({ prompt: "move", materials: [], settings: {}, origin: "https://app.test" });
await expect(createMinimaxH3Task(payload)).resolves.toMatchObject({ providerTaskId: "task-h3" });
await expect(queryMinimaxH3Task("task-h3")).resolves.toMatchObject({
status: "succeeded", resultUrl: "https://cdn.test/video.mp4", usage: { outputSeconds: 5, inputImageCount: 1 }
});
expect(String(fetchMock.mock.calls[0][0])).toBe("https://minimax.test/v2/video_generation");
expect(String(fetchMock.mock.calls[1][0])).toBe("https://minimax.test/v2/query/video_generation/task-h3");
});
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: 0 })).toBeUndefined();
});
});

View File

@@ -4,7 +4,7 @@ import { describe, expect, it } from "vitest";
describe("Go provider adapter contract", () => {
it("freezes bounded adapters and safe errors", async () => {
const contract = JSON.parse(await readFile(new URL("../contracts/providers/http-v1.json", import.meta.url), "utf8"));
expect(Object.keys(contract.providers)).toEqual(["volcengine-visual", "evolink", "bailian", "seedance", "seedream"]);
expect(Object.keys(contract.providers)).toEqual(["volcengine-visual", "evolink", "bailian", "seedance", "seedream", "minimax"]);
expect(contract.providers["volcengine-visual"]).toMatchObject({
model: "jimeng_seedream46_cvtob",
submitAction: "JimengSeedream46CVToBSubmitTask",
@@ -36,6 +36,15 @@ describe("Go provider adapter contract", () => {
outputFormats: ["png", "jpeg"],
optimizeModes: ["standard", "fast"]
});
expect(contract.providers.minimax).toMatchObject({
model: "MiniMax-H3",
submit: "/v2/video_generation",
query: "/v2/query/video_generation/{id}",
modes: ["text-to-video", "first-frame-to-video"],
maxMaterials: 1,
durationSeconds: { min: 4, max: 15 },
resolutions: ["768P", "2K"]
});
expect(contract.errors).toEqual({ generic: true, secretSafe: true });
expect(contract.liveCallsInTests).toBe(false);
});

View File

@@ -0,0 +1,49 @@
import { describe, expect, it } from "vitest";
import { canvasAnnotationPoint, constrainCanvasView, fitCanvasImage, FIT_CANVAS_VIEW, zoomCanvasAt } from "@/lib/client/seedream-viewport";
import { buildSeedreamInteractivePrompt } from "@/lib/seedream/creation";
describe("Seedream canvas view geometry", () => {
const viewport = { width: 800, height: 500 };
it.each([{ width: 1000, height: 2000 }, { width: 2000, height: 1000 }])("fits a $width × $height image without stretching or inner scrollbars", (image) => {
const fitted = fitCanvasImage(image, viewport);
expect(fitted.width).toBeLessThanOrEqual(780);
expect(fitted.height).toBeLessThanOrEqual(480);
expect(fitted.width / fitted.height).toBeCloseTo(image.width / image.height);
});
it("keeps the cursor over the same image point during zoom", () => {
const fitted = fitCanvasImage({ width: 1000, height: 1000 }, viewport);
const before = { zoom: 2, x: 30, y: -20 };
const anchor = { x: 100, y: 80 };
const after = zoomCanvasAt(before, 3, anchor, fitted, viewport);
expect((anchor.x - after.x) / after.zoom).toBeCloseTo((anchor.x - before.x) / before.zoom);
expect((anchor.y - after.y) / after.zoom).toBeCloseTo((anchor.y - before.y) / before.zoom);
});
it("constrains pan at image edges and restores a centered fit", () => {
const fitted = { width: 240, height: 480 };
expect(constrainCanvasView({ zoom: 3, x: 900, y: -9999 }, fitted, viewport)).toEqual({ zoom: 3, x: 0, y: -470 });
expect(constrainCanvasView(FIT_CANVAS_VIEW, fitted, viewport)).toEqual(FIT_CANVAS_VIEW);
expect(constrainCanvasView({ zoom: 99, x: 0, y: 0 }, fitted, viewport).zoom).toBe(8);
expect(constrainCanvasView({ zoom: 0.01, x: 0, y: 0 }, fitted, viewport).zoom).toBe(0.25);
});
it("maps zoomed and panned box, point and brush coordinates back to the original image", () => {
const normalized = [{ x: 100, y: 200 }, { x: 456, y: 321 }, { x: 700, y: 800 }];
for (const bounds of [
{ left: 100, top: 50, width: 400, height: 600 },
{ left: -720, top: -380, width: 1600, height: 2400 },
{ left: 320, top: 120, width: 800, height: 1200 }
]) {
const points = normalized.map((point) => canvasAnnotationPoint({ x: bounds.left + bounds.width * point.x / 1000, y: bounds.top + bounds.height * point.y / 1000 }, bounds));
expect(points).toEqual(normalized);
expect(buildSeedreamInteractivePrompt("修改 @标注1", [{ id: "point", index: 1, kind: "point", point: points[1]! }])).toBe("修改 图1<point>456 321</point>");
}
});
it("clamps drawing outside the image and ignores an unloaded image", () => {
expect(canvasAnnotationPoint({ x: -1, y: 999 }, { left: 0, top: 0, width: 100, height: 100 })).toEqual({ x: 0, y: 1000 });
expect(canvasAnnotationPoint({ x: 0, y: 0 }, { left: 0, top: 0, width: 0, height: 0 })).toBeNull();
});
});

View File

@@ -36,7 +36,25 @@ describe("Seedream workspace layout contract", () => {
expect(viewport).toMatch(/--seedream-canvas-height\s*:\s*clamp\(280px,\s*38vh,\s*460px\)/);
expect(viewport).toMatch(/height\s*:\s*var\(--seedream-canvas-height\)/);
expect(viewport).toMatch(/flex\s*:\s*0\s+0\s+auto/);
expect(image).toMatch(/max-height\s*:\s*calc\(var\(--seedream-canvas-height\)\s*-\s*22px\)/);
expect(image).toMatch(/width\s*:\s*100%/);
expect(image).toMatch(/height\s*:\s*100%/);
expect(workspaceSource).toContain("fitCanvasImage");
expect(workspaceSource).toContain("width: fittedImage.width, height: fittedImage.height");
});
it("uses the same editor in native fullscreen and keeps panning inside a clipped viewport", () => {
expect(workspaceSource).toContain("workspaceRef.current.requestFullscreen()");
expect(workspaceSource).not.toContain("createPortal");
expect(cssRule(".seedream-workspace:fullscreen")).toMatch(/overflow\s*:\s*hidden/);
expect(cssRule(".seedream-workspace:fullscreen .seedream-canvas-viewport")).toMatch(/flex\s*:\s*1\s+1\s+auto/);
expect(workspaceSource).toContain('addEventListener("wheel", handleWheel, { passive: false })');
expect(workspaceSource).toContain('removeEventListener("wheel", handleWheel)');
});
it("measures cached images that finish loading before hydration", () => {
expect(workspaceSource).toContain("image?.complete && image.naturalWidth > 0");
expect(workspaceSource).toContain("ref={imageRef}");
expect(workspaceSource).toContain("key: sourceKey, width: image.naturalWidth, height: image.naturalHeight");
});
it("does not render the redundant source picker for single-image layer decomposition", () => {