evOo-local/packages/adapters/src/media/video-edit-plan.test.ts
Claude 3eb1fdd68f
Brand videos with a logo overlay and an intro before the cut
edit_video gains an overlay step that lays a PNG, JPEG or WebP image over the
video at a corner or the center, sized to a share of the frame width, with
optional opacity and time range; tall frames keep the 9:16 safe areas clear.
concat takes at: "start" so an intro can go before the video. The image is
authorized like other inputs, read with its own demuxer and refused above
8192 pixels on a side. New Studio specialists are told to brand with the
user's logo and intro.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MRtGG9ARfqqhma3t85Ro2u
2026-10-05 10:53:57 +00:00

485 lines
18 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 { MEDIA_PLATFORM_PROFILES } from "@rakazo/contracts";
import { describe, expect, it } from "vitest";
import type { MediaProbe } from "./ffmpeg.js";
import { platformTarget } from "./platform-export.js";
import type { PlanInput } from "./video-edit-plan.js";
import { atempoFilters, planLoudnessFix, 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"], at: "end" }], {
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("puts an intro before the video", () => {
const result = plan(
[
{ op: "trim", start: 0, end: 4 },
{ op: "concat", artifact_ids: ["clip"], at: "start" },
],
{ clips: new Map([["clip", { ...source, probe: probe({ durationMs: 1500 }) }]]) },
);
const graph = graphOf(result.args);
expect(graph).toContain("[0:v:0]setsar=1,format=yuv420p[v1]");
expect(graph).toContain("[v3][a4][v1][a2]concat=n=2:v=1:a=1");
expect(result.durationMs).toBe(5500);
});
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"], at: "end" }],
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"], at: "end" },
],
{ 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("lays a logo over a corner with margins, its own demuxer, opacity and timing", () => {
const logo: PlanInput = {
path: "/jobs/1/input-logo",
mimeType: "image/png",
name: "logo.png",
probe: probe({ codec: "png", width: 600, height: 200, durationMs: 40, hasAudio: false }),
};
const result = plan(
[
{
op: "overlay",
artifact_id: "logo",
position: "top_right",
width: 0.2,
opacity: 0.5,
start: 1,
},
],
{ images: new Map([["logo", logo]]) },
);
expect(result.args).toEqual(
expect.arrayContaining(["-f", "png_pipe", "-i", "file:/jobs/1/input-logo"]),
);
expect(graphOf(result.args)).toBe(
[
"[1:v:0]scale=w=384:h=994:force_original_aspect_ratio=decrease:force_divisible_by=2,format=rgba,colorchannelmixer=aa=0.5[v1]",
"[0:v:0][v1]overlay=x=W-w-77:y=43:enable='between(t,1,10)'[v2]",
"[v2]format=yuv420p[v3]",
"[0:a:0]aformat=sample_fmts=fltp:sample_rates=48000:channel_layouts=stereo[a4]",
].join(";"),
);
expect(result.durationMs).toBe(10_000);
});
it("keeps a logo clear of 9:16 app buttons and centers one when asked", () => {
const logo: PlanInput = { ...source, mimeType: "image/png", name: "logo.png" };
const images = new Map([["logo", logo]]);
const tall = graphOf(
plan(
[
{ op: "reframe", preset: "vertical", fit: "crop" },
{ op: "overlay", artifact_id: "logo", position: "bottom_left", width: 0.15, opacity: 1 },
],
{ images },
).args,
);
expect(tall).toContain("scale=w=162:h=1248:");
expect(tall).toContain("overlay=x=65:y=H-h-384[");
expect(tall).not.toContain("colorchannelmixer");
const center = graphOf(
plan([{ op: "overlay", artifact_id: "logo", position: "center", width: 0.5, opacity: 1 }], {
images,
}).args,
);
expect(center).toContain("overlay=x=(W-w)/2:y=(H-h)/2[");
});
it("ignores overlays in audio results and refuses a missing image or a late start", () => {
const steps: EditVideoStep[] = [
{ op: "overlay", artifact_id: "logo", position: "top_right", width: 0.15, opacity: 1 },
{ op: "extract_audio" },
];
const audio = plan(steps);
expect(audio.kind).toBe("audio");
expect(audio.args).not.toContain("png_pipe");
expect(() =>
plan([
{ op: "overlay", artifact_id: "logo", position: "top_right", width: 0.15, opacity: 1 },
]),
).toThrow("Image logo is not available.");
expect(() =>
plan(
[
{ op: "trim", start: 0, end: 2 },
{
op: "overlay",
artifact_id: "logo",
position: "top_right",
width: 0.15,
opacity: 1,
start: 2,
},
],
{ images: new Map([["logo", source]]) },
),
).toThrow("overlay from 2 s starts after the end of the 2 s video.");
});
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]");
});
});
describe("platform export plans", () => {
const fast = { ...source, probe: probe({ fps: 120, durationMs: 20_000 }) };
it.each(Object.values(MEDIA_PLATFORM_PROFILES))(
"renders $name to its frame, frame rate, bitrate and loudness",
(profile) => {
const target = platformTarget(profile, "crop", 20_000);
const result = planVideoEdit({
source: fast,
steps: [],
target,
outputPath: "/jobs/1/output.mp4",
});
const { width, height, fps, maxBitrateKbps } = profile.video;
const graph = graphOf(result.args);
expect(graph).toContain(`fps=${fps}`);
expect(graph).toMatch(new RegExp(`scale=${width}:${height}[,:]`));
expect(graph).toContain(
`loudnorm=I=${profile.audio.targetLufs}:TP=${profile.audio.truePeakDb}:LRA=11,atrim=end=20`,
);
expect(target.maxBitrateKbps).toBe(maxBitrateKbps);
const maxrate = result.args.indexOf("-maxrate");
expect(result.args.slice(maxrate, maxrate + 4)).toEqual([
"-maxrate",
`${maxBitrateKbps}k`,
"-bufsize",
`${maxBitrateKbps * 2}k`,
]);
expect(result).toMatchObject({ kind: "video", width, height, durationMs: 20_000 });
},
);
it("keeps a slower frame rate, fits by the export's fit, and skips loudness without audio", () => {
const target = platformTarget(MEDIA_PLATFORM_PROFILES.tiktok, "pad", 10_000);
const graph = graphOf(
planVideoEdit({
source: { ...source, probe: probe({ fps: 24, hasAudio: false }) },
steps: [],
target,
outputPath: "/jobs/1/output.mp4",
}).args,
);
expect(graph).not.toContain("fps=");
expect(graph).toContain("pad=1080:1920:(ow-iw)/2:(oh-ih)/2:color=black");
expect(graph).not.toContain("loudnorm");
// An unknown (variable) frame rate gets the platform's.
const variable = graphOf(
planVideoEdit({
source: { ...source, probe: probe({ fps: 0 }) },
steps: [],
target,
outputPath: "/jobs/1/output.mp4",
}).args,
);
expect(variable).toContain("fps=30");
});
it("plans a linear second loudness pass that copies the video", () => {
const args = planLoudnessFix({
inputPath: "/jobs/1/tiktok.mp4",
outputPath: "/jobs/1/tiktok-loudness.mp4",
durationMs: 12_500,
target: { integratedLufs: -14, truePeakDb: -1 },
measured: {
integratedLufs: -17.4,
truePeakDb: -4.2,
rangeLu: 5.1,
thresholdLufs: -27.6,
offsetLu: 0.3,
},
});
expect(args).toContain("file:/jobs/1/tiktok.mp4");
expect(graphOf(args)).toBe(
"[0:a:0]loudnorm=I=-14:TP=-1:LRA=11:measured_I=-17.4:measured_TP=-4.2:measured_LRA=5.1:measured_thresh=-27.6:offset=0.3:linear=true,atrim=end=12.5,aformat=sample_fmts=fltp:sample_rates=48000:channel_layouts=stereo[a1]",
);
expect(args.join(" ")).toContain("-map 0:v:0 -map [a1] -c:v copy -c:a aac -b:a 160k");
expect(args.at(-1)).toBe("file:/jobs/1/tiktok-loudness.mp4");
});
});