93 lines
6.9 KiB
TypeScript
93 lines
6.9 KiB
TypeScript
import { processVideoFile } from "@/lib/video-canvas/media-processing";
|
|
import { test, expect } from "@playwright/test";
|
|
import { promptToolSchema, selectPromptToolModel, promptToolErrorMessage } from "@/lib/video-canvas/prompt-tools";
|
|
import { toVideoTimeRange, videoProcessSchema } from "@/lib/video-canvas/media-contract";
|
|
import legacyGridPrompts from "../fixtures/video-canvas-grid-legacy-prompts.json";
|
|
import { normalizeVideoCanvasFunctionConfig } from "@/lib/video-canvas/config";
|
|
|
|
test("prompt tools reject empty and oversized input before calling a model", () => {
|
|
expect(promptToolSchema.safeParse({ mode: "translate", prompt: " " }).success).toBe(false);
|
|
expect(promptToolSchema.safeParse({ mode: "optimize", prompt: "a".repeat(12001) }).success).toBe(false);
|
|
expect(promptToolSchema.parse({ mode: "translate", prompt: " @角色1 保持 8 秒 " }).prompt).toBe("@角色1 保持 8 秒");
|
|
});
|
|
|
|
test("prompt tools exclude media models and never silently replace an explicit model", () => {
|
|
const image = { id: "image", modelId: "gpt-image-2", supportsImageGen: true };
|
|
const video = { id: "video", modelId: "veo-3.1-fast", capabilities: { kind: "video" } };
|
|
const text = { id: "text", modelId: "qwen3.8-max", isEnabled: true };
|
|
expect(selectPromptToolModel([image, video, text], "auto")).toEqual(text);
|
|
expect(selectPromptToolModel([text], "qwen3.8-max")).toEqual(text);
|
|
expect(() => selectPromptToolModel([image, video, text], "image")).toThrow("文字模型");
|
|
expect(() => selectPromptToolModel([{ ...text, isEnabled: false }], "text")).toThrow();
|
|
expect(() => selectPromptToolModel([text], "missing")).toThrow();
|
|
});
|
|
|
|
test("prompt tools preserve administrator model and prompt overrides", () => {
|
|
const config = normalizeVideoCanvasFunctionConfig({ defaultMultimodalModel: "qwen38", functions: {
|
|
"video-prompt-translate": { enabled: false, model: "explicit-model", prompt: "Translate exactly." },
|
|
} });
|
|
expect(config.functions["video-prompt-optimize"].route).toBe("canvas-prompt-tools");
|
|
expect(config.functions["video-prompt-translate"]).toMatchObject({ enabled: false, model: "explicit-model", prompt: "Translate exactly." });
|
|
expect(config.defaultMultimodalModel).toBe("qwen38");
|
|
});
|
|
|
|
test("upstream errors give an actionable message without returning provider internals", () => {
|
|
expect(promptToolErrorMessage(new Error("Access denied, account in good standing overdue-payment"))).toContain("账户");
|
|
expect(promptToolErrorMessage(new Error("timed out"))).toContain("超时");
|
|
expect(promptToolErrorMessage(new Error("private upstream URL"))).not.toContain("private");
|
|
});
|
|
|
|
test("trim uses source duration rather than assuming eight seconds", () => {
|
|
expect(toVideoTimeRange({ start: 0.25, end: 0.75 }, 8)).toEqual({ start: 2, end: 6, duration: 4 });
|
|
expect(toVideoTimeRange({ start: 0.1, end: 0.5 }, 20)).toEqual({ start: 2, end: 10, duration: 8 });
|
|
});
|
|
|
|
test("invalid trim and crop requests are rejected before downloading", () => {
|
|
const url = "https://example.com/video.mp4";
|
|
for (const range of [{ start: 0.5, end: 0.5 }, { start: -0.1, end: 1 }, { start: 0, end: 2 }]) {
|
|
expect(videoProcessSchema.safeParse({ operation: "trim", url, range }).success).toBe(false);
|
|
}
|
|
expect(videoProcessSchema.safeParse({ operation: "trim", url }).success).toBe(false);
|
|
expect(videoProcessSchema.safeParse({ operation: "crop", url, rect: { x: 0.8, y: 0, width: 0.8, height: 1 } }).success).toBe(false);
|
|
expect(() => toVideoTimeRange({ start: 0, end: 0.001 }, 8)).toThrow("0.1 秒");
|
|
expect(() => toVideoTimeRange({ start: 0, end: 1 }, NaN)).toThrow();
|
|
expect(videoProcessSchema.safeParse({ operation: "audio-video", url }).success).toBe(true);
|
|
});
|
|
|
|
test('frame and slow-motion inputs reject invalid timestamps and unbounded multipliers',()=>{
|
|
expect(videoProcessSchema.safeParse({operation:'frame',url:'https://example.com/a.mp4',kind:'current',time:-1}).success).toBe(false);
|
|
expect(videoProcessSchema.safeParse({operation:'frame',url:'https://example.com/a.mp4',kind:'last'}).success).toBe(true);
|
|
for(const factor of [0,1,4,100,Infinity]) expect(videoProcessSchema.safeParse({operation:'slow',url:'https://example.com/a.mp4',factor}).success).toBe(false);
|
|
for(const factor of [2,3,5]) expect(videoProcessSchema.safeParse({operation:'slow',url:'https://example.com/a.mp4',factor}).success).toBe(true);
|
|
});
|
|
|
|
test('real FFmpeg frames differ by time and slow motion keeps synchronized audio',async()=>{
|
|
const {execFileSync}=await import('node:child_process');
|
|
try{execFileSync('ffmpeg',['-version'],{stdio:'ignore'});execFileSync('ffprobe',['-version'],{stdio:'ignore'});}catch{test.skip(true,'FFmpeg and ffprobe required for media integration');return;}
|
|
const {mkdtemp,readFile,rm}=await import('node:fs/promises');const {tmpdir}=await import('node:os');const {join}=await import('node:path');
|
|
const dir=await mkdtemp(join(tmpdir(),'canvas-media-test-'));
|
|
try{
|
|
const source=join(dir,'source.mp4');execFileSync('ffmpeg',['-y','-v','error','-f','lavfi','-i','testsrc2=size=320x180:rate=24:duration=2','-f','lavfi','-i','sine=frequency=440:duration=2','-c:v','libx264','-pix_fmt','yuv420p','-c:a','aac',source]);
|
|
const frames=[];for(const kind of ['first','current','last'] as const){const file=join(dir,`${kind}.png`);const result=await processVideoFile(source,file,'frame',undefined,undefined,{kind,time:1});expect(result.width).toBe(320);expect(result.height).toBe(180);frames.push((await readFile(file)).toString('base64'));}
|
|
expect(new Set(frames).size).toBe(3);
|
|
for(const factor of [2,3,5]){const file=join(dir,`slow${factor}.mp4`);await processVideoFile(source,file,'slow',undefined,undefined,{factor});const info=JSON.parse(execFileSync('ffprobe',['-v','error','-show_format','-show_streams','-of','json',file],{encoding:'utf8'}));expect(Math.abs(Number(info.format.duration)-2*factor)).toBeLessThan(0.2);expect(info.streams.some((s:{codec_type:string})=>s.codec_type==='audio')).toBe(true);}
|
|
}finally{await rm(dir,{recursive:true,force:true});}
|
|
});
|
|
|
|
|
|
test("grid presets migrate sample-specific defaults while preserving deliberate overrides", () => {
|
|
const config = normalizeVideoCanvasFunctionConfig({ functions: Object.fromEntries(
|
|
Object.entries(legacyGridPrompts).map(([key, prompt]) => [key, { prompt }]),
|
|
) });
|
|
for (const key of Object.keys(legacyGridPrompts)) {
|
|
const prompt = config.functions[key].prompt;
|
|
expect(prompt).not.toMatch(/绿色服装|柔和森林逆光|森林自然光线统一/);
|
|
expect(prompt).toContain("参考");
|
|
const custom = "客户指定绿色服装和森林场景,严格保持产品尺寸。";
|
|
expect(normalizeVideoCanvasFunctionConfig({ functions: { [key]: { prompt: custom } } }).functions[key].prompt).toBe(custom);
|
|
}
|
|
expect(config.functions["story-beat-grid-4"].prompt).toContain("2x2");
|
|
expect(config.functions["multi-view-nine-grid"].prompt).toContain("3x3");
|
|
expect(config.functions["continuous-storyboard-grid-25"].prompt).toContain("5x5");
|
|
});
|