164 lines
11 KiB
TypeScript
164 lines
11 KiB
TypeScript
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("分镜顺序");
|
||
});
|