import type { EditVideoStep } from "@rakazo/contracts"; import { describe, expect, it } from "vitest"; import type { MediaProbe } from "./ffmpeg.js"; import type { PlanInput } from "./video-edit-plan.js"; import { atempoFilters, planVideoEdit, subtitleStyle } from "./video-edit-plan.js"; function probe( overrides: Partial> & { durationMs?: number; hasAudio?: boolean; } = {}, ): MediaProbe { const { durationMs = 10_000, hasAudio = true, ...video } = overrides; return { durationMs, video: { codec: "h264", width: 1920, height: 1080, rotation: 0, fps: 25, sampleAspectRatio: 1, ...video, }, ...(hasAudio ? { audio: { codec: "aac", sampleRate: 44_100, channels: 2 } } : {}), hasAudio, }; } const source: PlanInput = { path: "/jobs/1/input-source", mimeType: "video/mp4", name: "holiday.mp4", probe: probe(), }; function plan(steps: EditVideoStep[], extra: Partial[0]> = {}) { return planVideoEdit({ source, steps, outputPath: "/jobs/1/output.mp4", ...extra }); } function graphOf(args: string[]): string { return args[args.indexOf("-filter_complex") + 1]!; } describe("planVideoEdit", () => { it("seeks the input for a leading trim and encodes H.264/AAC MP4", () => { const result = plan([{ op: "trim", start: 1.5, end: 4 }]); expect(result.args).toEqual([ ...["-ss", "1.5", "-t", "2.5", "-protocol_whitelist", "file", "-f", "mov"], ...["-i", "file:/jobs/1/input-source"], "-filter_complex", "[0:v:0]format=yuv420p[v1];[0:a:0]aformat=sample_fmts=fltp:sample_rates=48000:channel_layouts=stereo[a2]", ...["-map", "[v1]", "-map", "[a2]", "-c:a", "aac", "-b:a", "160k"], ...["-c:v", "libx264", "-preset", "veryfast", "-crf", "21", "-pix_fmt", "yuv420p"], ...["-movflags", "+faststart", "-map_metadata", "-1", "-map_chapters", "-1"], ...["-f", "mp4", "file:/jobs/1/output.mp4"], ]); expect(result).toMatchObject({ kind: "video", durationMs: 2500, width: 1920, height: 1080 }); }); it("keeps sorted, merged segments from one seek with split, trims and concat", () => { const result = plan([ { op: "keep", segments: [ { start: 6, end: 8 }, { start: 2, end: 3 }, { start: 2.5, end: 4 }, ], }, ]); expect(result.args.slice(0, 4)).toEqual(["-ss", "2", "-t", "6"]); const graph = graphOf(result.args); expect(graph).toContain("[0:v:0]split=2[v1][v2]"); expect(graph).toContain("[v1]trim=start=0:end=2,setpts=PTS-STARTPTS[v3]"); expect(graph).toContain("[v2]trim=start=4:end=6,setpts=PTS-STARTPTS[v4]"); expect(graph).toContain("[0:a:0]asplit=2[a5][a6]"); expect(graph).toContain("[v3][a7][v4][a8]concat=n=2:v=1:a=1[v9][a10]"); expect(result.durationMs).toBe(4000); }); it("applies later cuts on the timeline left by earlier steps", () => { const result = plan([ { op: "speed", factor: 2 }, { op: "trim", start: 1, end: 99 }, ]); expect(graphOf(result.args)).toContain( "[0:v:0]setpts=PTS/2,fps=25,trim=start=1:end=5,setpts=PTS-STARTPTS,format=yuv420p[v1]", ); expect(result.durationMs).toBe(4000); }); it("rejects ranges outside the video and too short ones", () => { expect(() => plan([{ op: "trim", start: 12, end: 18 }])).toThrow( "trim 12–18 s is outside the 10 s video.", ); expect(() => plan([ { op: "speed", factor: 2 }, { op: "keep", segments: [ { start: 1, end: 2 }, { start: 6, end: 7 }, ], }, ]), ).toThrow("keep 6–7 s is outside the 5 s video."); expect(() => plan([{ op: "trim", start: 9.95, end: 12 }])).toThrow("shorter than 0.1 s"); }); it("appends clips fitted to the frame, with silence for clips without audio", () => { const clip: PlanInput = { path: "/jobs/1/input-clip", mimeType: "video/webm", name: "clip.webm", probe: probe({ width: 1080, height: 1920, fps: 30, durationMs: 2000, hasAudio: false }), }; const result = plan([{ op: "concat", artifact_ids: ["clip"] }], { clips: new Map([["clip", clip]]), }); expect(result.args).toContain("matroska"); const graph = graphOf(result.args); expect(graph).toContain("[0:v:0]setsar=1,format=yuv420p[v1]"); expect(graph).toContain( "[1:v:0]scale=1920:1080:force_original_aspect_ratio=decrease:force_divisible_by=2,pad=1920:1080:(ow-iw)/2:(oh-ih)/2:color=black,setsar=1,fps=25,format=yuv420p[v3]", ); expect(graph).toContain( "anullsrc=channel_layout=stereo:sample_rate=48000,atrim=end=2,aformat=sample_fmts=fltp:sample_rates=48000:channel_layouts=stereo[a4]", ); expect(graph).toContain("[v1][a2][v3][a4]concat=n=2:v=1:a=1"); expect(result.durationMs).toBe(12_000); }); it("adds silence to a silent source when an appended clip has audio", () => { const silent = { ...source, probe: probe({ hasAudio: false }) }; const result = planVideoEdit({ source: silent, steps: [{ op: "concat", artifact_ids: ["clip"] }], clips: new Map([["clip", source]]), outputPath: "/out.mp4", }); expect(graphOf(result.args)).toContain( "anullsrc=channel_layout=stereo:sample_rate=48000,atrim=end=10", ); expect(result.args.filter((arg) => arg === "-map")).toHaveLength(2); }); it("appends clips with the last reframe's fit", () => { const result = plan( [ { op: "reframe", preset: "vertical", fit: "crop" }, { op: "concat", artifact_ids: ["clip"] }, ], { clips: new Map([["clip", source]]) }, ); expect(graphOf(result.args)).toContain( "[1:v:0]scale=1080:1920:force_original_aspect_ratio=increase,crop=1080:1920,setsar=1,fps=25", ); }); it("reframes by crop, pad or blur, and only scales when the aspect already matches", () => { expect(graphOf(plan([{ op: "reframe", preset: "square", fit: "crop" }]).args)).toContain( "scale=1080:1080:force_original_aspect_ratio=increase,crop=1080:1080,setsar=1", ); expect(graphOf(plan([{ op: "reframe", preset: "portrait", fit: "pad" }]).args)).toContain( "pad=1080:1350:(ow-iw)/2:(oh-ih)/2:color=black,setsar=1", ); const blur = plan([{ op: "reframe", preset: "vertical", fit: "blur" }]); expect(graphOf(blur.args)).toBe( [ "[0:v:0]split=2[v1][v2]", "[v1]scale=270:480:force_original_aspect_ratio=increase,crop=270:480,boxblur=27:2,scale=1080:1920,setsar=1[v3]", "[v2]scale=1080:1920:force_original_aspect_ratio=decrease:force_divisible_by=2,setsar=1[v4]", "[v3][v4]overlay=(W-w)/2:(H-h)/2[v5]", "[v5]format=yuv420p[v6]", "[0:a:0]aformat=sample_fmts=fltp:sample_rates=48000:channel_layouts=stereo[a7]", ].join(";"), ); expect(blur).toMatchObject({ width: 1080, height: 1920 }); expect(graphOf(plan([{ op: "reframe", preset: "landscape", fit: "blur" }]).args)).toContain( "[0:v:0]scale=1920:1080,setsar=1,format=yuv420p", ); }); it("changes speed with atempo chains and keeps the source frame rate", () => { const fast = plan([{ op: "speed", factor: 4 }]); expect(graphOf(fast.args)).toContain("setpts=PTS/4,fps=25"); expect(graphOf(fast.args)).toContain("atempo=2,atempo=2"); expect(fast.durationMs).toBe(2500); expect(atempoFilters(0.25)).toEqual(["atempo=0.5", "atempo=0.5"]); expect(atempoFilters(3)).toEqual(["atempo=2", "atempo=1.5"]); expect(atempoFilters(0.8)).toEqual(["atempo=0.8"]); }); it("changes volume, and mute drops the audio track", () => { expect(graphOf(plan([{ op: "volume", gain: 0.5 }]).args)).toContain( "[0:a:0]volume=0.5,aformat", ); const muted = plan([{ op: "volume", gain: 2 }, { op: "mute" }]); expect(muted.args.filter((arg) => arg === "-map")).toHaveLength(1); expect(muted.args).not.toContain("aac"); }); it("burns in subtitles styled for the current frame from a generated file name", () => { const result = plan( [ { op: "reframe", preset: "vertical", fit: "crop" }, { op: "subtitles", artifact_id: "subs" }, ], { subtitleFiles: new Map([["subs", "subtitles-0.srt"]]) }, ); expect(graphOf(result.args)).toContain( `subtitles=filename=subtitles-0.srt:force_style='${subtitleStyle(1080, 1920)}'`, ); expect(() => plan([{ op: "subtitles", artifact_id: "subs" }], { subtitleFiles: new Map([["subs", "x.srt:force_style='a'"]]), }), ).toThrow("Unsafe subtitle file name."); }); it("styles subtitles above the controls of tall frames", () => { expect(subtitleStyle(1080, 1920)).toContain("MarginV=384"); expect(subtitleStyle(1080, 1920)).toContain("FontSize=65"); expect(subtitleStyle(1920, 1080)).toContain("MarginV=65"); expect(subtitleStyle(1920, 1080)).toContain("PlayResX=1920,PlayResY=1080"); }); it("extracts audio without decoding video work", () => { const result = planVideoEdit({ source, steps: [ { op: "trim", start: 0, end: 5 }, { op: "reframe", preset: "vertical", fit: "blur" }, { op: "subtitles", artifact_id: "subs" }, { op: "extract_audio" }, ], subtitleFiles: new Map([["subs", "subtitles-0.srt"]]), outputPath: "/out.m4a", }); expect(graphOf(result.args)).toBe( "[0:a:0]aformat=sample_fmts=fltp:sample_rates=48000:channel_layouts=stereo[a1]", ); expect(result.args).toEqual(expect.arrayContaining(["-map", "[a1]", "-b:a", "192k"])); expect(result.args).not.toContain("libx264"); expect(result).toMatchObject({ kind: "audio", durationMs: 5000 }); expect(() => planVideoEdit({ source: { ...source, probe: probe({ hasAudio: false }) }, steps: [{ op: "extract_audio" }], outputPath: "/out.m4a", }), ).toThrow("holiday.mp4 has no audio."); }); it("validates the poster time against the result", () => { expect( plan([ { op: "trim", start: 0, end: 4 }, { op: "poster", at: 3 }, ]).posterAtMs, ).toBe(3000); expect(() => plan([ { op: "trim", start: 0, end: 4 }, { op: "poster", at: 5 }, ]), ).toThrow("poster at 5 s is past the end of the 4 s result."); }); it("keeps file paths out of the filter graph", () => { const hostile = { ...source, path: "/jobs/1/a'b[c]:d;e,f.mp4", name: "x'];[0:v]drawtext=" }; const result = planVideoEdit({ source: hostile, steps: [{ op: "reframe", preset: "square", fit: "blur" }], outputPath: "/jobs/1/out.mp4", }); expect(graphOf(result.args)).not.toContain("a'b"); expect(graphOf(result.args)).not.toContain("drawtext"); expect(result.args).toContain("file:/jobs/1/a'b[c]:d;e,f.mp4"); }); it("squares pixels and evens odd frame sizes", () => { const anamorphic = { ...source, probe: probe({ width: 1440, height: 1080, sampleAspectRatio: 4 / 3 }), }; const result = planVideoEdit({ source: anamorphic, steps: [{ op: "mute" }], outputPath: "/o.mp4", }); expect(graphOf(result.args)).toBe("[0:v:0]scale=1920:1080,setsar=1,format=yuv420p[v1]"); expect(result).toMatchObject({ width: 1920, height: 1080 }); const odd = { ...source, probe: probe({ width: 721, height: 405 }) }; const evened = planVideoEdit({ source: odd, steps: [{ op: "mute" }], outputPath: "/o.mp4" }); expect(graphOf(evened.args)).toBe("[0:v:0]crop=720:404:0:0,format=yuv420p[v1]"); }); });