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
301 lines
11 KiB
TypeScript
301 lines
11 KiB
TypeScript
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]");
|
||
});
|
||
});
|