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) => ({ 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("分镜顺序"); });