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
485 lines
18 KiB
TypeScript
485 lines
18 KiB
TypeScript
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");
|
||
});
|
||
});
|