evOo-local/packages/adapters/src/media/video-edit-plan.test.ts
Claude f14d91d24a
refactor(media): trim unused ffmpeg wrapper options
Binary paths, the ffmpeg version, the probe's container name and a
configurable stall timeout had no caller; detection is cached per process,
so every helper looks the binaries up itself.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MRtGG9ARfqqhma3t85Ro2u
2026-10-03 13:51:44 +00:00

301 lines
11 KiB
TypeScript
Raw 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 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<NonNullable<MediaProbe["video"]>> & {
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<Parameters<typeof planVideoEdit>[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]");
});
});