Files
ttstd-infinite-canvas/web/test/agnes-video.test.ts

193 lines
9.9 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { afterEach, describe, expect, test } from "bun:test";
import axios from "axios";
import { defaultModelCapabilityConfig } from "../src/lib/model-capabilities";
import { createVideoGenerationTask } from "../src/services/api/video";
import { agnesPollUrl, createAgnesVideoTask, pollAgnesVideoTask } from "../src/services/api/video-provider-agnes";
import type { VideoProviderDeps } from "../src/services/api/video-provider-deps";
import { videoResponseTools } from "../src/services/api/video-response";
import { buildApiUrl, createModelChannel, defaultConfig, normalizeConfigSnapshot, resolveModelRequestConfig, type AiConfig } from "../src/stores/use-config-store";
const originalAxiosRequest = axios.request;
afterEach(() => {
axios.request = originalAxiosRequest;
});
function configForAgnes(model = "agnes-video-2.5", input: Partial<AiConfig> = {}) {
const capabilityConfig = defaultModelCapabilityConfig("agnes-video", model);
const channel = createModelChannel({
id: "agnes",
name: "Agnes",
baseUrl: "https://apihub.agnes-ai.com/v1",
apiKey: "synthetic-test-key",
apiFormat: "openai",
interfaceType: "agnes-video",
models: [model],
modelCosts: [{ model, capability: "video", protocol: "agnes-video", billingMode: "per_second", unitPriceMicrocredits: 1, capabilityConfig }],
});
return normalizeConfigSnapshot({
config: {
...defaultConfig,
...input,
channels: [channel],
model: `agnes::${model}`,
videoModel: `agnes::${model}`,
videoSeconds: input.videoSeconds || "5",
size: input.size || "16:9",
vquality: input.vquality || "720",
},
}).config;
}
function providerDeps(config: ReturnType<typeof resolveModelRequestConfig>, input?: { created?: unknown; polled?: unknown }) {
const calls: Array<{ method: string; url: string; body?: unknown }> = [];
const deps = {
transport: {
apiUrl: (path: string) => buildApiUrl(config.baseUrl, path),
post: async (url: string, body: unknown) => {
calls.push({ method: "POST", url, body });
return input?.created || { video_id: "video-1", status: "queued" };
},
postForm: async () => {
throw new Error("Agnes must not use multipart");
},
get: async (url: string) => {
calls.push({ method: "GET", url });
return input?.polled || { video_id: "video-1", status: "in_progress" };
},
},
response: {
...videoResponseTools,
videoResultFromUrl: async (url: string) => ({ url, mimeType: "video/mp4" }),
},
} as unknown as VideoProviderDeps;
return { calls, deps };
}
describe("Agnes Video 2.5 request contract", () => {
test("video.ts dispatches Agnes through JSON instead of OpenAI multipart", async () => {
let requestBody: unknown;
let requestHeaders: Record<string, string> = {};
axios.request = (async (request) => {
requestBody = request.data;
requestHeaders = request.headers as Record<string, string>;
return { data: { id: "task-1", video_id: "video-1", status: "queued" } };
}) as typeof axios.request;
const task = await createVideoGenerationTask(
configForAgnes("agnes-video-2.5", { size: "1280x720" }),
"自我介绍图片1",
[{ id: "image-1", name: "reference.png", type: "image/png", dataUrl: "", url: "https://cdn.example.com/reference.png" }],
);
expect(task).toEqual({ id: "video-1", provider: "agnes", model: "agnes::agnes-video-2.5" });
expect(requestBody).not.toBeInstanceOf(FormData);
expect(Object.entries(requestHeaders).find(([name]) => name.toLowerCase() === "content-type")?.[1]).toBe("application/json");
expect(requestBody).toEqual({
model: "agnes-video-2.5",
prompt: "自我介绍图片1",
mode: "keyframe",
seconds: "5",
size: "720P",
aspect_ratio: "16:9",
n: 1,
first_frame: "https://cdn.example.com/reference.png",
});
expect(requestBody as Record<string, unknown>).not.toHaveProperty("input_reference");
expect(requestBody as Record<string, unknown>).not.toHaveProperty("resolution_name");
expect(requestBody as Record<string, unknown>).not.toHaveProperty("preset");
});
test("maps multimodal references to official reference fields", async () => {
const config = resolveModelRequestConfig(configForAgnes(), "agnes::agnes-video-2.5");
const { calls, deps } = providerDeps(config);
await createAgnesVideoTask(
deps,
config,
"agnes::agnes-video-2.5",
"参考素材生成",
[
{ id: "image-1", name: "1.png", type: "image/png", dataUrl: "", url: "https://cdn.example.com/1.png" },
{ id: "image-2", name: "2.png", type: "image/png", dataUrl: "", url: "https://cdn.example.com/2.png" },
{ id: "image-3", name: "3.png", type: "image/png", dataUrl: "", url: "https://cdn.example.com/3.png" },
],
[{ id: "video-1", name: "video.mp4", type: "video/mp4", url: "https://cdn.example.com/video.mp4" }],
[{ id: "audio-1", name: "audio.mp3", type: "audio/mpeg", url: "https://cdn.example.com/audio.mp3" }],
);
expect(calls[0]?.body).toMatchObject({
mode: "reference",
images: ["https://cdn.example.com/1.png", "https://cdn.example.com/2.png", "https://cdn.example.com/3.png"],
audios: ["https://cdn.example.com/audio.mp3"],
videos: [{ url: "https://cdn.example.com/video.mp4" }],
});
});
test("treats a single storyboard character image as a reference asset", async () => {
const config = resolveModelRequestConfig(configForAgnes(), "agnes::agnes-video-2.5");
const { calls, deps } = providerDeps(config);
await createAgnesVideoTask(
deps,
config,
"agnes::agnes-video-2.5",
"保持角色一致",
[{ id: "character-1", name: "character.png", type: "image/png", dataUrl: "", url: "https://cdn.example.com/character.png" }],
[],
[],
{ videoEditOperation: "reference_to_video" },
);
expect(calls[0]?.body).toMatchObject({
mode: "reference",
images: ["https://cdn.example.com/character.png"],
});
expect(calls[0]?.body as Record<string, unknown>).not.toHaveProperty("first_frame");
});
test("polls the Agnes host root and reads metadata.url", async () => {
const config = resolveModelRequestConfig(configForAgnes(), "agnes::agnes-video-2.5");
const { calls, deps } = providerDeps(config, { polled: { video_id: "video-1", status: "completed", metadata: { url: "https://cdn.example.com/result.mp4" } } });
const state = await pollAgnesVideoTask(deps, config, { id: "video-1", provider: "agnes", model: "agnes::agnes-video-2.5" });
expect(calls[0]?.url).toBe("https://apihub.agnes-ai.com/agnesapi?video_id=video-1&model_name=agnes-video-2.5");
expect(state).toEqual({ status: "completed", result: { url: "https://cdn.example.com/result.mp4", mimeType: "video/mp4" } });
});
test("accepts the top-level result url returned by Agnes Video V2.0", async () => {
const config = resolveModelRequestConfig(configForAgnes("agnes-video-v2.0"), "agnes::agnes-video-v2.0");
const { deps } = providerDeps(config, { polled: { id: "video-1", status: "completed", url: "https://cdn.example.com/v20-result.mp4" } });
const state = await pollAgnesVideoTask(deps, config, { id: "video-1", provider: "agnes", model: "agnes::agnes-video-v2.0" });
expect(state).toEqual({ status: "completed", result: { url: "https://cdn.example.com/v20-result.mp4", mimeType: "video/mp4" } });
});
test("keeps system-channel polls inside the proxy while preserving the root path", () => {
const config = { ...resolveModelRequestConfig(configForAgnes(), "agnes::agnes-video-2.5"), baseUrl: "/api/system-agnes" };
expect(agnesPollUrl(config, "video/1", "agnes-video-2.5")).toBe("/api/system-agnes/agnesapi?video_id=video%2F1&model_name=agnes-video-2.5");
});
test("rejects unsupported duration, Flash inputs and local-only media", async () => {
const longConfig = resolveModelRequestConfig(configForAgnes("agnes-video-2.5", { videoSeconds: "15" }), "agnes::agnes-video-2.5");
await expect(createAgnesVideoTask(providerDeps(longConfig).deps, longConfig, "agnes::agnes-video-2.5", "test", [], [], [])).rejects.toThrow("4–12");
const flashConfig = resolveModelRequestConfig(configForAgnes("agnes-video-2.5-flash", { vquality: "2K" }), "agnes::agnes-video-2.5-flash");
await expect(createAgnesVideoTask(providerDeps(flashConfig).deps, flashConfig, "agnes::agnes-video-2.5-flash", "test", [], [], [])).rejects.toThrow("仅支持 720P");
const config = resolveModelRequestConfig(configForAgnes(), "agnes::agnes-video-2.5");
await expect(createAgnesVideoTask(providerDeps(config).deps, config, "agnes::agnes-video-2.5", "test", [{ id: "image-1", name: "local.png", type: "image/png", dataUrl: "data:image/png;base64,AAAA" }], [], [])).rejects.toThrow("需要公网 URL");
});
test("publishes Agnes-specific capability limits", () => {
expect(defaultModelCapabilityConfig("agnes-video", "agnes-video-2.5").video).toMatchObject({
duration: { selection: "range", min: 4, max: 12, default: 5 },
resolutions: ["720P", "960P", "2K"],
defaultResolution: "720P",
});
expect(defaultModelCapabilityConfig("agnes-video", "agnes-video-2.5-flash").video).toMatchObject({
references: { maxImages: 5, maxVideos: 0 },
resolutions: ["720P"],
});
});
});