ai-chatbot/tests/routes/video-canvas-production.test.ts

164 lines
11 KiB
TypeScript
Raw Permalink 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 { expect, test } from "@playwright/test";
import { chooseStoryVideoSettings } from "@/lib/video-canvas/model-matching";
import { tvstarOutputRequirements } from "@/lib/video-canvas/tvstar-output";
import { tvstarTiming } from "@/lib/video-canvas/tvstar-timing";
import { assertCanvasProductionServices } from "@/lib/video-canvas/production-preflight";
import type { CanvasTaskRequest } from "@/lib/video-canvas/task-contract";
import { HighlightValidationError, validateSequenceHighlights } from "@/lib/video-canvas/smart-edit/highlight-contract";
import { buildEditPlans, smartEditRequestSchema } from "@/lib/video-canvas/smart-edit/contract";
import { mapVip98kVideoParameters } from "@/lib/ai/vip98k-video-parameters";
import { buildVip98kModelPresets } from "@/lib/ai/vip98k-models";
import { resolveGroupInputs } from "@/lib/video-canvas/group-execution";
import { tvstarShotComposition } from "@/lib/video-canvas/tvstar-shot-input";
const video = { id: "video", providerType: "atlascloud", modelId: "bytedance/seedance-2.0-mini/image-to-video",
capabilities: { kind: "video", supportedModes: ["first-frame"], durationOptions: [5, 10],
supportedRatios: ["16:9", "9:16"], supportedResolutions: ["720p", "1080p"], supportGenerateAudio: true } };
const request = (kind: CanvasTaskRequest["kind"], params: Record<string, unknown>) => ({ kind, params } as CanvasTaskRequest);
const h3Schema = { type: "object", properties: { prompt: { type: "string" }, file_urls: { type: "array" },
aspect_ratio: { enum: ["landscape", "portrait"] }, resolution: { enum: ["480p", "768p", "1080p"] },
duration_seconds: { minimum: 1, maximum: 15 } }, allOf: [
{ if: { properties: { resolution: { const: "1080p" } }, required: ["resolution"] },
then: { properties: { duration_seconds: { maximum: 10 } } } },
] };
test("published H3 contract overrides stale catalog specs and maps native portrait input", () => {
const preset = buildVip98kModelPresets([{ id: "minimax-h3", type: "video", status: "published", available: true,
supported_resolutions: ["720p"], durations: [4, 5], invocation: { kind: "task", path: "/extensions/v1/tasks",
request_example: { model: "minimax-h3", prompt: "test" }, input_schema: h3Schema } }])[0];
const model = { ...preset, id: "h3", providerType: "vip98k" };
const settings = chooseStoryVideoSettings([model], true, 3, "h3", { ratio: "9:16", resolution: "720p", minimumResolution: true,
minimumDuration: true, strictModel: true });
expect(settings).toMatchObject({ ratio: "9:16", resolution: "768p", duration: 3 });
const body = { aspect_ratio: settings.ratio, resolution: settings.resolution, duration_seconds: settings.duration };
mapVip98kVideoParameters(body, h3Schema);
expect(body).toEqual({ aspect_ratio: "portrait", resolution: "768p", duration_seconds: 3 });
expect(() => mapVip98kVideoParameters({ aspect_ratio: "9:16", resolution: "720p", duration_seconds: 4 }, h3Schema)).toThrow("清晰度");
expect(() => chooseStoryVideoSettings([model], true, 11, "h3", { ratio: "9:16", resolution: "1080p", minimumResolution: true,
minimumDuration: true, strictModel: true })).toThrow("时长");
});
test("TVstar carries portrait resolution and sound requirements through video settings", () => {
const output = tvstarOutputRequirements("制作 15 秒竖屏广告,1080P,配乐和音效,不要字幕");
expect(output).toEqual({ ratio: "9:16", resolution: "1080p", generateAudio: true });
expect(chooseStoryVideoSettings([video], true, 7, video.id, { ...output, minimumDuration: true, strictModel: true }))
.toMatchObject({ ratio: "9:16", resolution: "1080p", duration: 10, generateAudio: true });
});
test("TVstar applies explicit mute and the last format revision without downgrading 4K", () => {
expect(tvstarOutputRequirements("做一支 16:9 广告\n最新要求:改为 9:16、1080P、无声"))
.toEqual({ ratio: "9:16", resolution: "1080p", generateAudio: false });
expect(tvstarOutputRequirements("不要配乐,保留音效").generateAudio).toBe(true);
expect(tvstarOutputRequirements("不要静音,需要配乐").generateAudio).toBe(true);
expect(tvstarOutputRequirements("先制作无声版本\n最新要求:增加背景音乐").generateAudio).toBe(true);
expect(tvstarOutputRequirements("不要字幕").generateAudio).toBeUndefined();
expect(tvstarOutputRequirements("制作16:9广告\n修改:改成竖屏").ratio).toBe("9:16");
expect(() => tvstarOutputRequirements("交付 4K 成片")).toThrow("1080P");
});
test("silent delivery mutes source audio and removes music from the actual edit plan", () => {
const params = smartEditRequestSchema.parse({ operation: "plan", assets: [{ id: "a0", name: "带音轨素材", url: "https://media.example/a.mp4" }],
mode: "sequence", shotDurations: [2], muteAudio: true,
music: { url: "https://media.example/bgm.mp3", gain: 0.2, start: 0 } });
if (params.operation !== "plan") throw Error("wrong operation");
const assets = [{ ...params.assets[0], duration: 8_000_000, width: 1280, height: 720, hasAudio: true, hash: "a".repeat(64) }];
const plan = buildEditPlans(params, assets, [candidates[0]], [candidates[0].id], [], [])[0];
expect(plan.clips[0].muted).toBe(true);
expect(plan.music).toBeUndefined();
});
test("scaled storyboard duration is available in real generated footage before it is trimmed", () => {
const timing = tvstarTiming("制作 18 秒广告", [{ duration: "4s" }, { duration: "4s" }]);
expect(timing.shotDurations).toEqual([9, 9]);
for (const seconds of timing.shotDurations) {
expect(chooseStoryVideoSettings([video], true, seconds, video.id, { minimumDuration: true }).duration).toBe(10);
}
expect(() => chooseStoryVideoSettings([video], true, 11, video.id, { minimumDuration: true, strictModel: true })).toThrow("时长");
});
test("delivery timing preserves fractional requirements and rejects unachievable duration", () => {
const timing = tvstarTiming("制作 15.5 秒广告", [{ duration: "4s" }, { duration: "4s" }]);
expect(timing.targetDuration).toBe(15.5);
expect(timing.shotDurations.reduce((sum, duration) => sum + duration, 0)).toBeCloseTo(15.5);
expect(() => tvstarTiming("制作 10 分钟成片", [{ duration: "5s" }])).toThrow("300 秒");
expect(() => tvstarTiming("制作0.5秒片段", Array.from({ length: 4 }, () => ({ duration: "1s" })))).toThrow("不足");
expect(tvstarTiming("制作6秒广告,两个镜头各3秒", [{ duration: "3s" }, { duration: "3s" }])).toEqual({ targetDuration: 6, shotDurations: [3, 3] });
expect(tvstarTiming("Make a 6 second film, each shot 3 seconds", [{ duration: "3s" }, { duration: "3s" }]).targetDuration).toBe(6);
});
test("explicit video models cannot be replaced or used while ignoring unsupported sound", () => {
expect(() => chooseStoryVideoSettings([video], true, 5, "deleted-model", { strictModel: true })).toThrow("所选");
const silent = { ...video, id: "silent", capabilities: { ...video.capabilities, supportGenerateAudio: false } };
expect(() => chooseStoryVideoSettings([silent, video], true, 5, silent.id, { strictModel: true, generateAudio: true })).toThrow("声音");
expect(chooseStoryVideoSettings([silent, video], true, 5, silent.id, { generateAudio: true }).model).toBe(video.id);
});
test("a production group checks its final renderer before starting upstream paid work", async () => {
const batch = [request("image", {}), request("video", {}), request("smart-edit", { operation: "plan", semantic: true }),
request("smart-edit", { operation: "render" }), request("smart-edit", { operation: "render" })];
const calls: string[] = [];
await expect(assertCanvasProductionServices(batch, async service => { calls.push(service); return false; }))
.rejects.toMatchObject({ status: 503, code: "CANVAS_SERVICE_UNAVAILABLE" });
expect(calls).toEqual(["render"]);
});
test("audio and upscale outages are reported before task submission, while planning works without renderer", async () => {
await expect(assertCanvasProductionServices([request("smart-edit", { operation: "plan", semantic: true })], async () => {
throw Error("must not check renderer");
})).resolves.toBeUndefined();
await expect(assertCanvasProductionServices([request("video-upscale", {}), request("smart-edit", { operation: "transcribe" }),
request("smart-edit", { operation: "separate" })], async service => service === "asr"))
.rejects.toThrow("视频高清、人声分离");
});
const candidates = [0, 1].map(index => ({ id: `c${index}`, assetId: `a${index}`, start: 0, end: 8_000_000, text: "" }));
const highlights = () => ({ selections: candidates.map((candidate, index) => ({ id: candidate.id,
startSeconds: 1, transition: index === 0 ? "cut" : "fade", reason: "真实画面中有产品特写", matchScore: 85, visualIssues: [] as string[] })), summary: "产品展示连贯" });
test("a high self-rating cannot override observed split-screen violations", () => {
const result = highlights();
result.selections[0].matchScore = 98;
result.selections[0].visualIssues = ["近景与全景上下分屏,并非需求中的单一近景"];
expect(() => validateSequenceHighlights(result, candidates, [3, 3])).toThrow("镜头 1 存在画面违规");
});
test("TVstar keyframes consume their own shot rather than the full connected film script", () => {
const item = { projectId: "project", requestId: "request", kind: "image", params: { prompt: "毛笔近景" },
node: { id: "frame", title: "frame", kind: "media", inputType: "image", x: 0, y: 0, width: 300, height: 300,
draftValues: { source: "tvstar", tvstarShot: "01" } }, edges: [],
workflow: { runId: "run", groupId: "group", inputs: [{ node: { id: "script", kind: "text", inputType: "text",
draftValues: { content: "制作双镜头毛笔广告:近景;桌面全景", tvstarShots: [
{ shot: "01", sceneDescription: "毛笔笔尖与墨滴近景", imagePrompt: "单一近景" },
{ shot: "02", sceneDescription: "桌面全景", imagePrompt: "桌面全景" },
] } } }] } } as CanvasTaskRequest;
const resolved = resolveGroupInputs(item, []);
expect(resolved.prompt).toContain("笔尖与墨滴近景");
expect(resolved.prompt).not.toContain("桌面全景");
expect(resolved.prompt).not.toContain("双镜头");
item.node.draftValues!.source = "custom";
expect(resolveGroupInputs(item, []).prompt).toContain("桌面全景");
expect(tvstarShotComposition("不要分屏,两个镜头")).toContain("禁止上下或左右分屏");
expect(tvstarShotComposition("我要上下分屏画面")).toContain("指定的布局");
});
test("low match scores stop automatic delivery and identify the source shot for correction", () => {
const result = highlights();
result.selections[1].matchScore = 42;
expect(() => validateSequenceHighlights(result, candidates, [2, 4])).toThrow(HighlightValidationError);
expect(() => validateSequenceHighlights(result, candidates, [2, 4])).toThrow("镜头 2");
});
test("out-of-range AI highlights cannot be silently clamped into a different clip", () => {
const result = highlights();
result.selections[1].startSeconds = 6;
expect(() => validateSequenceHighlights(result, candidates, [2, 4])).toThrow("真实视频长度");
});
test("verified highlight order and fractional time ranges are preserved", () => {
const result = highlights();
result.selections[1].startSeconds = 2.125;
expect(validateSequenceHighlights(result, candidates, [2, 4])).toEqual(result);
result.selections.reverse();
expect(() => validateSequenceHighlights(result, candidates, [2, 4])).toThrow("分镜顺序");
});