feat(media): edit and export video with ffmpeg

Bots can now cut, reframe and export videos for every common format without
a hosted service: ffmpeg and ffprobe from PATH do the work on the server.

- ffmpeg wrapper: PATH detection, ffprobe JSON probing (rotation, pixel
  aspect, frame rate, audio, packet scan when the container has no
  duration), runs with -progress parsing, abort and stall kills, an output
  size cap, partial-output cleanup and short errors without temp paths.
  Inputs go through the file protocol with the demuxer forced from the MIME
  type, so a crafted upload cannot be probed as a playlist that reads other
  files or URLs. extractAudio (16 kHz mono WAV) and renderPoster for
  transcription and media cards.
- A pure planner turns edit steps (trim, keep, concat, reframe, speed,
  volume, mute, subtitles, extract_audio, poster) into one filter_complex
  pass: leading cuts seek the input, appended clips are normalized to the
  frame, fps and 48 kHz stereo (silence where a clip has none), reframes
  crop, pad or blur to the presets, speed chains atempo, subtitles burn in
  from generated file names with a readable style that stays clear of the
  controls on 9:16 frames. H.264/AAC MP4 with yuv420p and faststart;
  location metadata is stripped.
- edit_video and export_video operations and tool definitions; the inputs
  are shared contracts so the media card can start exports.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MRtGG9ARfqqhma3t85Ro2u
This commit is contained in:
Claude 2026-10-03 13:35:34 +00:00
parent 7ff93f08bb
commit 485090c391
No known key found for this signature in database
10 changed files with 2282 additions and 0 deletions

View File

@ -2,6 +2,8 @@ import type { ConnectorTool } from "@rakazo/adapter-kit";
import {
BotSecretDestination,
BotSecretName,
EditVideoInputSchema,
ExportVideoInputSchema,
MODEL_TIERS,
SecretAskPurpose,
SecretHttpRequest,
@ -12,6 +14,25 @@ import { z } from "zod";
export const SUBAGENT_MODEL_TIER_DESCRIPTION =
"Optional model tier: fast for quick simple work, code for programming, standard otherwise. Uses the model this space maps to the tier, or your own model when it maps none. Ignored when model_provider and model_id are set.";
/** A media tool's arguments: its operation's input plus whether to wake the bot when it ends. */
function mediaToolSchema(input: z.ZodType): Record<string, unknown> {
const schema = z.toJSONSchema(input, { io: "input" });
return {
...schema,
properties: {
...schema.properties,
continue_after: {
type: "boolean",
description:
"true to be woken with the result when the job ends, to continue a multi-step task. Default false: the card alone shows the result.",
},
},
};
}
const VIDEO_PRESETS_AND_FITS =
"Presets: vertical 9:16, landscape 16:9, square 1:1, portrait 4:5, classic 4:3, cinema 21:9. fit: crop (default) fills the frame and cuts the edges, pad adds bars, blur fills the bars with a blurred copy.";
export const DELEGATION_TOOL_NAMES = new Set([
"run_subagent",
"spawn_bot",
@ -326,6 +347,16 @@ export const builtinAgentTools: ConnectorTool[] = [
},
},
},
{
name: "edit_video",
description: `Edit a video artifact in a background job; a media card shows progress and the result, a new version of the source. steps run in order, times in seconds on the result of the steps before: trim {start, end}; keep {segments: [{start, end}]} keeps those parts and cuts the rest; concat {artifact_ids} appends videos fitted to the current frame; reframe {preset, fit}; speed {factor 0.25–4}; volume {gain 0–8, 1 unchanged}; mute; subtitles {artifact_id} burns in an SRT or VTT file; extract_audio (last step) makes the result M4A audio; poster {at} picks the cover frame, or as the only step saves that frame as an image. ${VIDEO_PRESETS_AND_FITS} Videos come out as H.264/AAC MP4 with a poster. title: a few words in the user's language for the card and file name.`,
inputSchema: mediaToolSchema(EditVideoInputSchema),
},
{
name: "export_video",
description: `Export a video artifact to one or more presets in a background job: one H.264/AAC MP4 with a poster per preset, each a new version of the source, shown on a media card. ${VIDEO_PRESETS_AND_FITS} For cuts, speed, audio or subtitles use edit_video.`,
inputSchema: mediaToolSchema(ExportVideoInputSchema),
},
{
name: "add_mcp_server",
description:

View File

@ -76,6 +76,8 @@ export * from "./mcp-emulator.js";
export * from "./mcp-oauth.js";
export * from "./mcp-server-tool.js";
export * from "./mcp-transport.js";
export * from "./media/ffmpeg.js";
export * from "./media/video-operations.js";
export * from "./memory-provider-factory.js";
export * from "./messaging-context.js";
export * from "./messaging-delivery.js";

View File

@ -0,0 +1,256 @@
import { existsSync } from "node:fs";
import { rm, writeFile } from "node:fs/promises";
import { join } from "node:path";
import { afterAll, beforeAll, describe, expect, it } from "vitest";
import type { FfmpegProgress } from "./ffmpeg.js";
import {
createFfmpegProgressParser,
detectFfmpeg,
extractAudio,
FfmpegError,
ffmpegInputArgs,
ffmpegSeconds,
packetDurationMs,
parseProbeOutput,
probeMedia,
renderPoster,
runFfmpeg,
} from "./ffmpeg.js";
import type { TestClips } from "./media-test-support.js";
import { createTestClips } from "./media-test-support.js";
const ffmpeg = await detectFfmpeg();
describe("ffmpeg progress", () => {
it("reports each block, tolerates split chunks and N/A times, and marks the end", () => {
const updates: FfmpegProgress[] = [];
const feed = createFfmpegProgressParser((update) => updates.push(update));
feed("frame=0\nout_time_us=N/A\ntotal_size=48\nprogress=cont");
feed("inue\nframe=75\nout_time_us=2995");
feed("374\nout_time_ms=2995374\ntotal_size=42300\nprogress=end\n");
expect(updates).toEqual([
{ totalSize: 48, done: false },
{ outTimeUs: 2_995_374, totalSize: 42_300, done: true },
]);
});
});
describe("probe parsing", () => {
const stream = (fields: Record<string, unknown>) => ({
codec_type: "video",
codec_name: "h264",
...fields,
});
it("swaps the size of rotated video and reads frame rate and audio", () => {
const probe = parseProbeOutput(
JSON.stringify({
format: { duration: "12.345", format_name: "mov,mp4,m4a,3gp,3g2,mj2" },
streams: [
stream({
width: 1920,
height: 1080,
avg_frame_rate: "30000/1001",
sample_aspect_ratio: "1:1",
side_data_list: [{ side_data_type: "Display Matrix", rotation: -90 }],
}),
{ codec_type: "audio", codec_name: "aac", sample_rate: "48000", channels: 2 },
],
}),
);
expect(probe).toEqual({
durationMs: 12_345,
formatName: "mov,mp4,m4a,3gp,3g2,mj2",
video: {
codec: "h264",
width: 1080,
height: 1920,
rotation: 270,
fps: 30000 / 1001,
sampleAspectRatio: 1,
},
audio: { codec: "aac", sampleRate: 48_000, channels: 2 },
hasAudio: true,
});
});
it("reads legacy rotate tags, skips cover art and falls back to stream durations", () => {
const probe = parseProbeOutput(
JSON.stringify({
format: { format_name: "mov" },
streams: [
stream({ width: 600, height: 600, disposition: { attached_pic: 1 } }),
stream({
width: 640,
height: 480,
avg_frame_rate: "0/0",
r_frame_rate: "90000/1",
sample_aspect_ratio: "4:3",
tags: { rotate: "90" },
duration: "4.5",
}),
],
}),
);
expect(probe.durationMs).toBe(4500);
expect(probe.hasAudio).toBe(false);
expect(probe.video).toMatchObject({
width: 480,
height: 640,
rotation: 90,
fps: 0,
sampleAspectRatio: 3 / 4,
});
});
it("reads the end of the last packet when the container has no duration", () => {
expect(packetDurationMs("0.000000,0.040000\n2.960000,0.040000\nN/A,N/A\n")).toBe(3000);
expect(packetDurationMs("")).toBe(0);
});
});
describe("ffmpeg arguments", () => {
it("opens inputs through the file protocol with the demuxer of their MIME type", () => {
expect(
ffmpegInputArgs("/a/b.webm", {
mimeType: "video/webm",
startSeconds: 2,
durationSeconds: 1.5,
}),
).toEqual([
...["-ss", "2", "-t", "1.5", "-protocol_whitelist", "file", "-f", "matroska"],
...["-i", "file:/a/b.webm"],
]);
expect(ffmpegInputArgs("/a/b", { mimeType: "application/octet-stream" })).toEqual([
...["-protocol_whitelist", "file", "-i", "file:/a/b"],
]);
expect(ffmpegSeconds(1e-7)).toBe("0");
expect(ffmpegSeconds(12.34567)).toBe("12.346");
});
});
// CI runners may not have ffmpeg; these run wherever it is installed.
describe.skipIf(!ffmpeg)("ffmpeg integration", () => {
let clips: TestClips;
beforeAll(async () => {
clips = await createTestClips();
});
afterAll(async () => {
if (clips) await rm(clips.dir, { recursive: true, force: true });
});
it("probes duration, size, frame rate and audio", async () => {
const probe = await probeMedia(clips.landscape, { mimeType: "video/mp4" });
expect(probe.durationMs).toBeGreaterThanOrEqual(2900);
expect(probe.durationMs).toBeLessThanOrEqual(3100);
expect(probe.video).toMatchObject({ codec: "h264", width: 320, height: 180, fps: 25 });
expect(probe.hasAudio).toBe(true);
const silent = await probeMedia(clips.silentPortrait, { mimeType: "video/mp4" });
expect(silent.hasAudio).toBe(false);
expect(silent.video).toMatchObject({ width: 180, height: 320 });
});
it("refuses a playlist disguised as an MP4 instead of following its URLs", async () => {
const playlist = join(clips.dir, "playlist.mp4");
await writeFile(
playlist,
`#EXTM3U\n#EXT-X-MEDIA-SEQUENCE:0\n#EXTINF:1.0,\nfile:${clips.srt}\n#EXT-X-ENDLIST\n`,
);
await expect(probeMedia(playlist, { mimeType: "video/mp4" })).rejects.toThrow(FfmpegError);
});
it("reports progress up to 1 for a run", async () => {
const output = join(clips.dir, "progress.mp4");
const fractions: number[] = [];
await runFfmpeg(
[
...ffmpegInputArgs(clips.landscape, { mimeType: "video/mp4" }),
...[
"-c:v",
"libx264",
"-preset",
"ultrafast",
"-c:a",
"aac",
"-f",
"mp4",
`file:${output}`,
],
],
{ durationMs: 3000, onProgress: (fraction) => fractions.push(fraction) },
);
expect(fractions.at(-1)).toBe(1);
expect(fractions).toEqual([...fractions].sort((a, b) => a - b));
expect(existsSync(output)).toBe(true);
});
it("fails with the last stderr line, without directory paths, and removes outputs", async () => {
const output = join(clips.dir, "broken.mp4");
await writeFile(output, "partial");
const failure = runFfmpeg(
[
...ffmpegInputArgs(join(clips.dir, "missing.mp4"), { mimeType: "video/mp4" }),
`file:${output}`,
],
{ outputs: [output] },
);
const error = await failure.catch((caught: unknown) => caught);
expect(error).toBeInstanceOf(FfmpegError);
expect((error as Error).message).toMatch(/^ffmpeg failed: .*missing\.mp4.*No such file/);
expect((error as Error).message).not.toContain(clips.dir);
expect(existsSync(output)).toBe(false);
});
it("kills ffmpeg on abort and removes the partial output", async () => {
const output = join(clips.dir, "aborted.mp4");
const controller = new AbortController();
const run = runFfmpeg(
[
...["-re", "-f", "lavfi", "-i", "testsrc=size=320x180:rate=25"],
...["-t", "60", "-c:v", "libx264", "-preset", "ultrafast", "-f", "mp4", `file:${output}`],
],
{
signal: controller.signal,
durationMs: 60_000,
onProgress: () => controller.abort(new Error("cancelled")),
outputs: [output],
},
);
await expect(run).rejects.toThrow("cancelled");
expect(existsSync(output)).toBe(false);
});
it("stops when the output outgrows its cap", async () => {
const output = join(clips.dir, "too-large.mp4");
const run = runFfmpeg(
[
...["-f", "lavfi", "-i", "testsrc=size=640x360:rate=25:duration=20"],
...["-c:v", "libx264", "-preset", "ultrafast", "-crf", "0", "-f", "mp4", `file:${output}`],
],
{ maxOutputBytes: 64 * 1024, outputs: [output] },
);
await expect(run).rejects.toThrow("The result would exceed 0 MB.");
expect(existsSync(output)).toBe(false);
});
it("extracts 16 kHz mono WAV for transcription, optionally a range", async () => {
const output = join(clips.dir, "speech.wav");
await extractAudio(clips.landscape, output, { mimeType: "video/mp4", startMs: 1000 });
const probe = await probeMedia(output, { mimeType: "audio/wav" });
expect(probe.audio).toEqual({ codec: "pcm_s16le", sampleRate: 16_000, channels: 1 });
expect(probe.video).toBeUndefined();
expect(Math.abs(probe.durationMs - 2000)).toBeLessThan(100);
await expect(
extractAudio(clips.silentPortrait, join(clips.dir, "none.wav"), { mimeType: "video/mp4" }),
).rejects.toThrow("The file has no audio track.");
});
it("renders a poster frame, falling back to the first frame past the end", async () => {
const poster = join(clips.dir, "poster.jpg");
await renderPoster(clips.landscape, poster, { mimeType: "video/mp4", atMs: 60_000 });
const probe = await probeMedia(poster);
expect(probe.video).toMatchObject({ codec: "mjpeg", width: 320, height: 180 });
});
});

View File

@ -0,0 +1,603 @@
import { spawn } from "node:child_process";
import { constants } from "node:fs";
import { access, rm, stat } from "node:fs/promises";
import { setPriority } from "node:os";
import { delimiter, join } from "node:path";
/**
* ffmpeg and ffprobe from PATH. Every call passes an argument array (never a shell), opens
* inputs through the `file` protocol only, and forces the demuxer from the artifact's MIME type
* so a crafted upload cannot make ffmpeg probe it as a playlist that reads other files or URLs.
*/
export interface FfmpegInfo {
ffmpegPath: string;
ffprobePath: string;
version: string;
}
export interface MediaProbe {
durationMs: number;
formatName: string;
/** Width and height as displayed: swapped when the stream is rotated by 90 or 270 degrees. */
video?: {
codec: string;
width: number;
height: number;
rotation: 0 | 90 | 180 | 270;
/** Frames per second; 0 when unknown or implausible (variable frame rate files). */
fps: number;
/** Pixel aspect ratio of the displayed frame; 1 for square pixels. */
sampleAspectRatio: number;
};
audio?: { codec: string; sampleRate: number; channels: number };
hasAudio: boolean;
}
export interface FfmpegProgress {
outTimeUs?: number;
totalSize?: number;
done: boolean;
}
export interface RunFfmpegOptions {
signal?: AbortSignal;
/** Expected output duration; progress is reported as out_time / durationMs. */
durationMs?: number;
onProgress?: (fraction: number) => void;
cwd?: string;
/** Kills ffmpeg once the output grows past this size. */
maxOutputBytes?: number;
/** Files removed when the run fails or is aborted. */
outputs?: readonly string[];
/** Kills ffmpeg when it reports no progress for this long. */
stallTimeoutMs?: number;
ffmpegPath?: string;
}
export class FfmpegError extends Error {
constructor(
message: string,
readonly exitCode: number | null,
readonly stderrTail: string,
) {
super(message);
this.name = "FfmpegError";
}
}
const DEFAULT_STALL_TIMEOUT_MS = 2 * 60_000;
const PROBE_TIMEOUT_MS = 60_000;
const STDERR_TAIL_BYTES = 8 * 1024;
const KILL_GRACE_MS = 5_000;
const MAX_PLAUSIBLE_FPS = 240;
const DEMUXERS: Record<string, string> = {
"video/mp4": "mov",
"video/quicktime": "mov",
"audio/mp4": "mov",
"video/webm": "matroska",
"audio/webm": "matroska",
"audio/mpeg": "mp3",
"audio/wav": "wav",
"audio/ogg": "ogg",
};
let detection: Promise<FfmpegInfo | null> | undefined;
/** Finds ffmpeg and ffprobe on PATH once per process; null when either is missing or broken. */
export function detectFfmpeg(): Promise<FfmpegInfo | null> {
detection ??= locateFfmpeg();
return detection;
}
async function locateFfmpeg(): Promise<FfmpegInfo | null> {
const [ffmpegPath, ffprobePath] = await Promise.all([
findExecutable("ffmpeg"),
findExecutable("ffprobe"),
]);
if (!ffmpegPath || !ffprobePath) return null;
try {
const [ffmpeg, ffprobe] = await Promise.all([
capture(ffmpegPath, ["-hide_banner", "-version"], { timeoutMs: 10_000 }),
capture(ffprobePath, ["-hide_banner", "-version"], { timeoutMs: 10_000 }),
]);
if (ffmpeg.code !== 0 || ffprobe.code !== 0) return null;
const version = /version (\S+)/.exec(ffmpeg.stdout)?.[1] ?? "unknown";
return { ffmpegPath, ffprobePath, version };
} catch {
return null;
}
}
async function findExecutable(name: string): Promise<string | null> {
const extensions =
process.platform === "win32"
? (process.env.PATHEXT ?? ".EXE").split(";").filter(Boolean)
: [""];
for (const directory of (process.env.PATH ?? "").split(delimiter)) {
if (!directory) continue;
for (const extension of extensions) {
const candidate = join(directory, `${name}${extension}`);
try {
await access(candidate, constants.X_OK);
if ((await stat(candidate)).isFile()) return candidate;
} catch {
// Not in this directory.
}
}
}
return null;
}
export async function requireFfmpeg(): Promise<FfmpegInfo> {
const info = await detectFfmpeg();
if (!info) throw new Error("ffmpeg and ffprobe are not installed on this server.");
return info;
}
/** ffmpeg time argument with millisecond precision and no exponent notation. */
export function ffmpegSeconds(seconds: number): string {
return String(Number(Math.max(0, seconds).toFixed(3)));
}
/** Input arguments for a local file: optional seek, file protocol only, forced demuxer. */
export function ffmpegInputArgs(
path: string,
options: { mimeType?: string; startSeconds?: number; durationSeconds?: number } = {},
): string[] {
const demuxer = options.mimeType ? DEMUXERS[options.mimeType] : undefined;
return [
...(options.startSeconds ? ["-ss", ffmpegSeconds(options.startSeconds)] : []),
...(options.durationSeconds !== undefined
? ["-t", ffmpegSeconds(options.durationSeconds)]
: []),
"-protocol_whitelist",
"file",
...(demuxer ? ["-f", demuxer] : []),
"-i",
`file:${path}`,
];
}
export async function probeMedia(
path: string,
options: { signal?: AbortSignal; mimeType?: string; ffprobePath?: string } = {},
): Promise<MediaProbe> {
const ffprobePath = options.ffprobePath ?? (await requireFfmpeg()).ffprobePath;
const input = ffmpegInputArgs(path, { mimeType: options.mimeType });
const result = await capture(
ffprobePath,
["-v", "error", ...input, "-print_format", "json", "-show_format", "-show_streams"],
{ signal: options.signal, timeoutMs: PROBE_TIMEOUT_MS },
);
if (result.code !== 0) throw ffmpegFailure("ffprobe", result.code, result.stderr);
const probe = parseProbeOutput(result.stdout);
if (probe.durationMs > 0 || (!probe.video && !probe.audio)) return probe;
// Recordings streamed by browsers often carry no duration header; read it from the packets.
const packets = await capture(
ffprobePath,
[
"-v",
"error",
...input,
"-select_streams",
probe.video ? "v:0" : "a:0",
"-show_entries",
"packet=pts_time,duration_time",
"-of",
"csv=p=0",
],
{ signal: options.signal, timeoutMs: PROBE_TIMEOUT_MS * 5 },
);
if (packets.code !== 0) throw ffmpegFailure("ffprobe", packets.code, packets.stderr);
return { ...probe, durationMs: packetDurationMs(packets.stdout) };
}
/** End of the last packet, from ffprobe `packet=pts_time,duration_time` CSV lines. */
export function packetDurationMs(csv: string): number {
let end = 0;
for (const line of csv.split("\n")) {
const [pts, duration] = line.split(",").map(Number);
if (pts === undefined || !Number.isFinite(pts)) continue;
end = Math.max(end, pts + (duration !== undefined && Number.isFinite(duration) ? duration : 0));
}
return Math.round(end * 1000);
}
interface ProbeStream {
codec_type?: string;
codec_name?: string;
width?: number;
height?: number;
avg_frame_rate?: string;
r_frame_rate?: string;
sample_aspect_ratio?: string;
sample_rate?: string;
channels?: number;
duration?: string;
disposition?: { attached_pic?: number };
tags?: { rotate?: string };
side_data_list?: { rotation?: number }[];
}
/** Parses `ffprobe -print_format json -show_format -show_streams` output. */
export function parseProbeOutput(json: string): MediaProbe {
const data = JSON.parse(json) as {
format?: { duration?: string; format_name?: string };
streams?: ProbeStream[];
};
const streams = data.streams ?? [];
// Cover art in audio files is a single attached picture, not a video track.
const video = streams.find(
(stream) => stream.codec_type === "video" && stream.disposition?.attached_pic !== 1,
);
const audio = streams.find((stream) => stream.codec_type === "audio");
const seconds =
positive(Number(data.format?.duration)) ??
Math.max(0, ...streams.map((stream) => positive(Number(stream.duration)) ?? 0));
return {
durationMs: Math.round(seconds * 1000),
formatName: data.format?.format_name ?? "",
...(video?.width && video.height
? { video: probeVideo(video, video.width, video.height) }
: {}),
...(audio
? {
audio: {
codec: audio.codec_name ?? "",
sampleRate: Number(audio.sample_rate) || 0,
channels: audio.channels ?? 0,
},
}
: {}),
hasAudio: Boolean(audio),
};
}
function probeVideo(stream: ProbeStream, width: number, height: number) {
const rotation = normalizeRotation(
stream.side_data_list?.find((entry) => typeof entry.rotation === "number")?.rotation ??
Number(stream.tags?.rotate ?? 0),
);
const sideways = rotation === 90 || rotation === 270;
const sar = ratio(stream.sample_aspect_ratio, ":") || 1;
const fps = ratio(stream.avg_frame_rate, "/") || ratio(stream.r_frame_rate, "/");
return {
codec: stream.codec_name ?? "",
width: sideways ? height : width,
height: sideways ? width : height,
rotation,
fps: fps > 0 && fps <= MAX_PLAUSIBLE_FPS ? fps : 0,
sampleAspectRatio: sideways ? 1 / sar : sar,
};
}
function normalizeRotation(degrees: number): 0 | 90 | 180 | 270 {
if (!Number.isFinite(degrees)) return 0;
const quarter = (((Math.round(degrees / 90) % 4) + 4) % 4) as 0 | 1 | 2 | 3;
return ([0, 90, 180, 270] as const)[quarter];
}
function ratio(value: string | undefined, separator: string): number {
if (!value) return 0;
const [numerator, denominator] = value.split(separator).map(Number);
if (!numerator || !denominator || !Number.isFinite(numerator / denominator)) return 0;
return numerator / denominator;
}
function positive(value: number): number | undefined {
return Number.isFinite(value) && value > 0 ? value : undefined;
}
/** Feeds ffmpeg `-progress` output (key=value lines) and reports each completed block. */
export function createFfmpegProgressParser(
onUpdate: (progress: FfmpegProgress) => void,
): (chunk: string) => void {
let buffer = "";
let current: Omit<FfmpegProgress, "done"> = {};
return (chunk) => {
buffer += chunk;
for (let newline = buffer.indexOf("\n"); newline >= 0; newline = buffer.indexOf("\n")) {
const line = buffer.slice(0, newline).trim();
buffer = buffer.slice(newline + 1);
const separator = line.indexOf("=");
if (separator < 0) continue;
const key = line.slice(0, separator);
const value = line.slice(separator + 1).trim();
const number = Number(value);
// out_time_ms is microseconds too (a long-standing ffmpeg naming bug).
if ((key === "out_time_us" || key === "out_time_ms") && value && number >= 0) {
current.outTimeUs = number;
} else if (key === "total_size" && value && number >= 0) {
current.totalSize = number;
} else if (key === "progress") {
onUpdate({ ...current, done: value === "end" });
current = {};
}
}
};
}
/** Runs ffmpeg with progress on stdout; rejects with the stderr tail when it fails. */
export async function runFfmpeg(
args: readonly string[],
options: RunFfmpegOptions = {},
): Promise<void> {
options.signal?.throwIfAborted();
const ffmpegPath = options.ffmpegPath ?? (await requireFfmpeg()).ffmpegPath;
try {
await spawnFfmpeg(ffmpegPath, args, options);
} catch (error) {
await Promise.all((options.outputs ?? []).map((path) => rm(path, { force: true })));
throw error;
}
}
function spawnFfmpeg(
ffmpegPath: string,
args: readonly string[],
options: RunFfmpegOptions,
): Promise<void> {
const { signal, durationMs, onProgress, maxOutputBytes } = options;
return new Promise((resolve, reject) => {
const child = spawn(
ffmpegPath,
[
"-hide_banner",
"-nostdin",
"-nostats",
"-loglevel",
"error",
"-progress",
"pipe:1",
"-y",
...args,
],
{ cwd: options.cwd, stdio: ["ignore", "pipe", "pipe"], windowsHide: true },
);
if (child.pid) {
try {
// Long encodes must not starve the API process that shares this machine.
setPriority(child.pid, 10);
} catch {
// Lowering priority is best effort.
}
}
let stderr = "";
let settled = false;
let reported = 0;
let failure: unknown;
let stallTimer: NodeJS.Timeout | undefined;
const finish = (error?: unknown) => {
if (settled) return;
settled = true;
clearTimeout(stallTimer);
signal?.removeEventListener("abort", abort);
if (error === undefined) resolve();
else reject(error);
};
// Settles once the process is gone, so callers can delete its files right away.
const kill = (error: unknown) => {
if (failure !== undefined || settled) return;
failure = error;
clearTimeout(stallTimer);
child.kill("SIGKILL");
setTimeout(() => finish(error), KILL_GRACE_MS).unref?.();
};
const abort = () => kill(signal?.reason ?? new Error("aborted"));
const stallTimeoutMs = options.stallTimeoutMs ?? DEFAULT_STALL_TIMEOUT_MS;
const armStallTimer = () => {
clearTimeout(stallTimer);
stallTimer = setTimeout(
() => kill(new FfmpegError("ffmpeg stopped making progress.", null, tail(stderr))),
stallTimeoutMs,
);
stallTimer.unref?.();
};
const parse = createFfmpegProgressParser((progress) => {
if (failure !== undefined) return;
if (maxOutputBytes !== undefined && (progress.totalSize ?? 0) > maxOutputBytes) {
kill(new Error(outputTooLargeMessage(maxOutputBytes)));
return;
}
if (!onProgress) return;
const fraction = progress.done
? 1
: durationMs && progress.outTimeUs !== undefined
? Math.min(1, progress.outTimeUs / (durationMs * 1000))
: 0;
if (fraction > reported) {
reported = fraction;
onProgress(fraction);
}
});
signal?.addEventListener("abort", abort, { once: true });
armStallTimer();
child.stdout.setEncoding("utf8");
child.stdout.on("data", (chunk: string) => {
if (failure === undefined) armStallTimer();
parse(chunk);
});
child.stderr.setEncoding("utf8");
child.stderr.on("data", (chunk: string) => {
stderr = tail(stderr + chunk);
});
child.on("error", (error) => finish(failure ?? error));
child.on("close", (code) => {
if (failure !== undefined) finish(failure);
else if (code === 0) finish();
else finish(ffmpegFailure("ffmpeg", code, stderr));
});
if (signal?.aborted) abort();
});
}
export function outputTooLargeMessage(maxBytes: number): string {
return `The result would exceed ${Math.round(maxBytes / (1024 * 1024))} MB.`;
}
function tail(text: string): string {
return text.length > STDERR_TAIL_BYTES ? text.slice(-STDERR_TAIL_BYTES) : text;
}
function ffmpegFailure(tool: string, code: number | null, stderr: string): FfmpegError {
// The cause is usually the line before ffmpeg's generic summary ("Conversion failed!").
const lines = stderr
.split("\n")
.map((line) => line.trim())
.filter(Boolean)
.slice(-2);
// Name files by their base name only; the job's temp paths mean nothing to the reader.
const detail = lines.join(" ").replaceAll(/(?:file:)?(?:[A-Za-z]:)?[\\/][^\s'"]*[\\/]/g, "");
return new FfmpegError(
detail ? `${tool} failed: ${detail}` : `${tool} failed with exit code ${code}.`,
code,
tail(stderr),
);
}
/** Extracts the first audio track as 16 kHz mono 16-bit WAV, the format speech models expect. */
export async function extractAudio(
input: string,
output: string,
options: {
signal?: AbortSignal;
mimeType?: string;
startMs?: number;
endMs?: number;
onProgress?: (fraction: number) => void;
} = {},
): Promise<void> {
const tools = await requireFfmpeg();
const probe = await probeMedia(input, {
signal: options.signal,
mimeType: options.mimeType,
ffprobePath: tools.ffprobePath,
});
if (!probe.hasAudio) throw new Error("The file has no audio track.");
const startMs = Math.max(0, options.startMs ?? 0);
const endMs = Math.min(probe.durationMs, options.endMs ?? probe.durationMs);
if (endMs <= startMs) throw new Error("The audio range is empty.");
await runFfmpeg(
[
...ffmpegInputArgs(input, {
mimeType: options.mimeType,
startSeconds: startMs / 1000,
durationSeconds: (endMs - startMs) / 1000,
}),
"-map",
"0:a:0",
"-ac",
"1",
"-ar",
"16000",
"-c:a",
"pcm_s16le",
"-map_metadata",
"-1",
"-f",
"wav",
`file:${output}`,
],
{
signal: options.signal,
durationMs: endMs - startMs,
onProgress: options.onProgress,
outputs: [output],
ffmpegPath: tools.ffmpegPath,
},
);
}
/** Writes one JPEG frame at atMs (default 0), falling back to the first frame past the end. */
export async function renderPoster(
input: string,
output: string,
options: { signal?: AbortSignal; mimeType?: string; atMs?: number } = {},
): Promise<void> {
const ffmpegPath = (await requireFfmpeg()).ffmpegPath;
const render = (atMs: number) =>
runFfmpeg(
[
...ffmpegInputArgs(input, { mimeType: options.mimeType, startSeconds: atMs / 1000 }),
"-map",
"0:v:0",
"-frames:v",
"1",
"-c:v",
"mjpeg",
"-q:v",
"3",
"-map_metadata",
"-1",
"-f",
"image2",
"-update",
"1",
`file:${output}`,
],
{ signal: options.signal, outputs: [output], ffmpegPath },
);
const atMs = Math.max(0, options.atMs ?? 0);
await render(atMs);
// Seeking into the last frame's gap writes nothing; the first frame is better than no poster.
if (atMs > 0 && !(await nonEmptyFile(output))) await render(0);
if (!(await nonEmptyFile(output))) throw new Error("Could not render a poster frame.");
}
async function nonEmptyFile(path: string): Promise<boolean> {
try {
return (await stat(path)).size > 0;
} catch {
return false;
}
}
interface CaptureResult {
code: number | null;
stdout: string;
stderr: string;
}
function capture(
command: string,
args: readonly string[],
options: { signal?: AbortSignal; timeoutMs: number },
): Promise<CaptureResult> {
options.signal?.throwIfAborted();
return new Promise((resolve, reject) => {
const child = spawn(command, args, { stdio: ["ignore", "pipe", "pipe"], windowsHide: true });
const stdout: Buffer[] = [];
let stderr = "";
let settled = false;
const finish = (error: unknown, result?: CaptureResult) => {
if (settled) return;
settled = true;
clearTimeout(timer);
options.signal?.removeEventListener("abort", abort);
if (result) resolve(result);
else reject(error);
};
const kill = (error: unknown) => {
child.kill("SIGKILL");
finish(error);
};
const abort = () => kill(options.signal?.reason ?? new Error("aborted"));
const timer = setTimeout(
() => kill(new Error(`${command} timed out after ${options.timeoutMs} ms`)),
options.timeoutMs,
);
timer.unref?.();
options.signal?.addEventListener("abort", abort, { once: true });
child.stdout.on("data", (chunk: Buffer) => stdout.push(chunk));
child.stderr.setEncoding("utf8");
child.stderr.on("data", (chunk: string) => {
stderr = tail(stderr + chunk);
});
child.on("error", (error) => finish(error));
child.on("close", (code) =>
finish(undefined, { code, stdout: Buffer.concat(stdout).toString("utf8"), stderr }),
);
});
}

View File

@ -0,0 +1,106 @@
import { copyFile, mkdtemp, stat, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import type { MediaJobContext, MediaJobInputFile, MediaJobOutputFile } from "@rakazo/adapter-kit";
import type { MediaOutput, MediaOutputKind } from "@rakazo/contracts";
import { attachmentKindOf } from "@rakazo/contracts";
import { runFfmpeg } from "./ffmpeg.js";
export interface TestClips {
dir: string;
/** 320×180, 25 fps, 3 s, 440 Hz tone. */
landscape: string;
/** 180×320, 30 fps, 2 s, no audio. */
silentPortrait: string;
srt: string;
vtt: string;
}
/** Tiny generated clips; nothing binary is committed. */
export async function createTestClips(): Promise<TestClips> {
const dir = await mkdtemp(join(tmpdir(), "evoo-media-test-"));
const landscape = join(dir, "landscape.mp4");
const silentPortrait = join(dir, "silent-portrait.mp4");
const encode = ["-c:v", "libx264", "-preset", "ultrafast", "-pix_fmt", "yuv420p"];
await runFfmpeg([
...["-f", "lavfi", "-i", "testsrc=size=320x180:rate=25:duration=3"],
...["-f", "lavfi", "-i", "sine=frequency=440:sample_rate=44100:duration=3"],
...encode,
...["-c:a", "aac", "-shortest", `file:${landscape}`],
]);
await runFfmpeg([
...["-f", "lavfi", "-i", "testsrc2=size=180x320:rate=30:duration=2"],
...encode,
`file:${silentPortrait}`,
]);
const srt = join(dir, "captions.srt");
await writeFile(
srt,
"1\n00:00:00,100 --> 00:00:01,500\nHello from the subtitles\n\n2\n00:00:01,500 --> 00:00:02,900\nZweite Zeile äöü\n",
);
const vtt = join(dir, "captions.vtt");
await writeFile(vtt, "WEBVTT\n\n00:00.100 --> 00:01.500\nHello from WebVTT\n");
return { dir, landscape, silentPortrait, srt, vtt };
}
export interface TestArtifact {
path: string;
name: string;
mimeType: string;
kind?: MediaOutputKind;
}
/** A MediaJobContext over local files that records progress and outputs. */
export async function fakeMediaJobContext(input: unknown, artifacts: Record<string, TestArtifact>) {
const tempDir = await mkdtemp(join(tmpdir(), "evoo-media-job-"));
const controller = new AbortController();
const progress: number[] = [];
const outputs: (MediaJobOutputFile & { size: number; posterSize?: number })[] = [];
const context: MediaJobContext = {
jobId: "job-1",
spaceId: "space-1",
userId: "user-1",
botId: null,
input,
tempDir,
signal: controller.signal,
progress: async (fraction) => {
progress.push(fraction);
},
fetchArtifact: async (artifactId): Promise<MediaJobInputFile> => {
const artifact = artifacts[artifactId];
if (!artifact) throw new Error(`Artifact ${artifactId} not found.`);
const path = join(tempDir, `input-${artifactId}`);
await copyFile(artifact.path, path);
return {
artifactId,
path,
name: artifact.name,
mimeType: artifact.mimeType,
kind: artifact.kind ?? attachmentKindOf(artifact.mimeType),
size: (await stat(path)).size,
};
},
addOutput: async (file): Promise<MediaOutput> => {
// Files must exist when handed over; the runner stores them before the job ends.
const size = (await stat(file.path)).size;
const posterSize = file.posterPath ? (await stat(file.posterPath)).size : undefined;
outputs.push({ ...file, size, ...(posterSize !== undefined ? { posterSize } : {}) });
const index = outputs.length;
return {
artifactId: `output-${index}`,
kind: attachmentKindOf(file.mimeType),
mimeType: file.mimeType,
name: file.name,
size,
...(file.width ? { width: file.width } : {}),
...(file.height ? { height: file.height } : {}),
...(file.durationMs !== undefined ? { durationMs: file.durationMs } : {}),
...(file.posterPath ? { posterArtifactId: `poster-${index}` } : {}),
...(file.parentArtifactId ? { parentArtifactId: file.parentArtifactId } : {}),
...(file.preset ? { preset: file.preset } : {}),
};
},
};
return { context, controller, progress, outputs, tempDir };
}

View File

@ -0,0 +1,301 @@
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,
formatName: "mov,mp4,m4a,3gp,3g2,mj2",
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]");
});
});

View File

@ -0,0 +1,571 @@
import type { EditVideoStep, MediaFit } from "@rakazo/contracts";
import { MEDIA_PRESETS } from "@rakazo/contracts";
import type { MediaProbe } from "./ffmpeg.js";
import { ffmpegInputArgs, ffmpegSeconds } from "./ffmpeg.js";
/**
* Turns an ordered list of edit steps into one ffmpeg invocation with a single filter_complex
* pass. Pure: the caller probes the inputs and runs the arguments.
*/
export interface PlanInput {
path: string;
mimeType: string;
name: string;
probe: MediaProbe;
}
export interface VideoEditPlanRequest {
source: PlanInput;
steps: readonly EditVideoStep[];
/** Videos named by concat steps, by artifact id. */
clips?: ReadonlyMap<string, PlanInput>;
/** Subtitle files named by subtitles steps, by artifact id: safe names relative to ffmpeg's cwd. */
subtitleFiles?: ReadonlyMap<string, string>;
outputPath: string;
}
export interface VideoEditPlan {
args: string[];
kind: "video" | "audio";
durationMs: number;
width?: number;
height?: number;
posterAtMs?: number;
}
export const VIDEO_ENCODE_ARGS = [
"-c:v",
"libx264",
"-preset",
"veryfast",
"-crf",
"21",
"-pix_fmt",
"yuv420p",
"-movflags",
"+faststart",
];
const AUDIO_ENCODE_ARGS = ["-c:a", "aac", "-b:a", "160k"];
const AUDIO_ONLY_ENCODE_ARGS = ["-c:a", "aac", "-b:a", "192k", "-movflags", "+faststart"];
/** Location and other camera metadata stay private; chapters do not survive cuts anyway. */
const STRIP_METADATA_ARGS = ["-map_metadata", "-1", "-map_chapters", "-1"];
const AUDIO_FORMAT = "aformat=sample_fmts=fltp:sample_rates=48000:channel_layouts=stereo";
const SAFE_FILE_NAME = /^[A-Za-z0-9][A-Za-z0-9._-]*$/;
const MIN_SEGMENT_SECONDS = 0.1;
const FALLBACK_FPS = 30;
interface Track {
label: string;
filters: string[];
}
interface Timeline {
/** Null when the result is audio only: video work is skipped. */
video: Track | null;
audio: Track | null;
width: number;
height: number;
fps: number;
seconds: number;
/** How appended clips fill the frame: the last reframe's fit, otherwise bars. */
fit: MediaFit;
}
class FilterGraph {
private readonly chains: string[] = [];
private count = 0;
label(kind: "v" | "a"): string {
this.count += 1;
return `${kind}${this.count}`;
}
add(inputs: readonly string[], filters: readonly string[], outputs: readonly string[]) {
const pads = (labels: readonly string[]) => labels.map((label) => `[${label}]`).join("");
this.chains.push(`${pads(inputs)}${filters.join(",")}${pads(outputs)}`);
}
/** Ends a track's pending filters in a labelled pad other chains can consume. */
flush(track: Track, kind: "v" | "a"): string {
if (track.filters.length === 0) return track.label;
const output = this.label(kind);
this.add([track.label], track.filters, [output]);
track.label = output;
track.filters = [];
return output;
}
toString(): string {
return this.chains.join(";");
}
}
export function planVideoEdit(request: VideoEditPlanRequest): VideoEditPlan {
const { source } = request;
const video = source.probe.video;
if (!video) throw new Error(`${source.name} has no video.`);
const sourceSeconds = source.probe.durationMs / 1000;
const audioOnly = request.steps.at(-1)?.op === "extract_audio";
const graph = new FilterGraph();
const inputs: string[][] = [];
const addInput = (input: PlanInput, seek?: { start: number; end: number }) => {
inputs.push(
ffmpegInputArgs(input.path, {
mimeType: input.mimeType,
...(seek ? { startSeconds: seek.start, durationSeconds: seek.end - seek.start } : {}),
}),
);
return inputs.length - 1;
};
let steps = request.steps;
// A leading cut seeks the input instead of decoding everything before it.
const first = steps[0];
let segments: Segment[] | undefined;
if (first?.op === "trim" || first?.op === "keep") {
segments = normalizeSegments(
first.op === "trim" ? [first] : first.segments,
sourceSeconds,
first.op,
);
steps = steps.slice(1);
}
const seek = segments && { start: segments[0]!.start, end: segments.at(-1)!.end };
const index = addInput(source, seek);
const size = displaySize(video);
const timeline: Timeline = {
video: audioOnly ? null : { label: `${index}:v:0`, filters: sourceFilters(video) },
audio: source.probe.hasAudio ? { label: `${index}:a:0`, filters: [] } : null,
width: size.width,
height: size.height,
fps: video.fps,
seconds: seek ? seek.end - seek.start : sourceSeconds,
fit: "pad",
};
if (segments && seek && segments.length > 1) {
keepSegments(
graph,
timeline,
segments.map((segment) => ({
start: segment.start - seek.start,
end: segment.end - seek.start,
})),
);
}
let posterAt: number | undefined;
for (const step of steps) {
switch (step.op) {
case "trim":
case "keep":
keepSegments(
graph,
timeline,
normalizeSegments(step.op === "trim" ? [step] : step.segments, timeline.seconds, step.op),
);
break;
case "concat":
appendClips(
graph,
timeline,
step.artifact_ids.map((id) => {
const clip = request.clips?.get(id);
if (!clip) throw new Error(`Video ${id} is not available.`);
return { clip, index: addInput(clip) };
}),
);
break;
case "reframe": {
const preset = MEDIA_PRESETS[step.preset];
if (timeline.video) {
timeline.video = fitFrame(
graph,
timeline.video,
timeline,
{ width: preset.width, height: preset.height },
step.fit,
);
}
timeline.width = preset.width;
timeline.height = preset.height;
timeline.fit = step.fit;
break;
}
case "speed":
timeline.video?.filters.push(
`setpts=PTS/${number(step.factor)}`,
...(timeline.fps ? [`fps=${number(timeline.fps)}`] : []),
);
timeline.audio?.filters.push(...atempoFilters(step.factor));
timeline.seconds /= step.factor;
break;
case "volume":
timeline.audio?.filters.push(`volume=${number(step.gain)}`);
break;
case "mute":
timeline.audio = null;
break;
case "subtitles": {
const file = request.subtitleFiles?.get(step.artifact_id);
if (!file) throw new Error(`Subtitles ${step.artifact_id} are not available.`);
// Only generated names reach the filter graph, so nothing here needs escaping.
if (!SAFE_FILE_NAME.test(file)) throw new Error("Unsafe subtitle file name.");
timeline.video?.filters.push(
`subtitles=filename=${file}:force_style='${subtitleStyle(timeline.width, timeline.height)}'`,
);
break;
}
case "extract_audio":
break;
case "poster":
posterAt = step.at;
break;
}
}
if (posterAt !== undefined && posterAt >= timeline.seconds) {
throw new Error(
`poster at ${seconds(posterAt)} s is past the end of the ${seconds(timeline.seconds)} s result.`,
);
}
const durationMs = Math.round(timeline.seconds * 1000);
const output = `file:${request.outputPath}`;
if (audioOnly || !timeline.video) {
if (!timeline.audio) throw new Error(`${source.name} has no audio.`);
timeline.audio.filters.push(AUDIO_FORMAT);
const audio = graph.flush(timeline.audio, "a");
return {
args: [
...inputs.flat(),
"-filter_complex",
graph.toString(),
"-map",
`[${audio}]`,
...AUDIO_ONLY_ENCODE_ARGS,
...STRIP_METADATA_ARGS,
"-f",
"mp4",
output,
],
kind: "audio",
durationMs,
};
}
timeline.video.filters.push("format=yuv420p");
const videoOut = graph.flush(timeline.video, "v");
let audioOut: string | undefined;
if (timeline.audio) {
timeline.audio.filters.push(AUDIO_FORMAT);
audioOut = graph.flush(timeline.audio, "a");
}
return {
args: [
...inputs.flat(),
"-filter_complex",
graph.toString(),
"-map",
`[${videoOut}]`,
...(audioOut ? ["-map", `[${audioOut}]`, ...AUDIO_ENCODE_ARGS] : []),
...VIDEO_ENCODE_ARGS,
...STRIP_METADATA_ARGS,
"-f",
"mp4",
output,
],
kind: "video",
durationMs,
width: timeline.width,
height: timeline.height,
...(posterAt !== undefined ? { posterAtMs: Math.round(posterAt * 1000) } : {}),
};
}
interface Segment {
start: number;
end: number;
}
/** Validates ranges against the timeline, clamps ends to it, sorts and merges overlaps. */
function normalizeSegments(
ranges: readonly Segment[],
duration: number,
op: "trim" | "keep",
): Segment[] {
const clamped = ranges.map((range) => {
if (range.start >= duration) {
throw new Error(
`${op} ${seconds(range.start)}–${seconds(range.end)} s is outside the ${seconds(duration)} s video.`,
);
}
const end = Math.min(range.end, duration);
if (end - range.start < MIN_SEGMENT_SECONDS) {
throw new Error(
`${op} ${seconds(range.start)}–${seconds(end)} s is shorter than ${MIN_SEGMENT_SECONDS} s.`,
);
}
return { start: range.start, end };
});
clamped.sort((a, b) => a.start - b.start);
const merged: Segment[] = [];
for (const segment of clamped) {
const last = merged.at(-1);
if (last && segment.start <= last.end) last.end = Math.max(last.end, segment.end);
else merged.push({ ...segment });
}
return merged;
}
/** Keeps sorted, disjoint segments of the timeline: one trim, or split + trims + concat. */
function keepSegments(graph: FilterGraph, timeline: Timeline, segments: readonly Segment[]) {
const [only] = segments;
if (segments.length === 1 && only) {
const range = `start=${ffmpegSeconds(only.start)}:end=${ffmpegSeconds(only.end)}`;
timeline.video?.filters.push(`trim=${range}`, "setpts=PTS-STARTPTS");
timeline.audio?.filters.push(`atrim=${range}`, "asetpts=PTS-STARTPTS");
timeline.seconds = only.end - only.start;
return;
}
const count = segments.length;
const branch = (track: Track | null, kind: "v" | "a"): string[] => {
if (!track) return [];
const input = graph.flush(track, kind);
const splits = segments.map(() => graph.label(kind));
graph.add([input], [`${kind === "v" ? "split" : "asplit"}=${count}`], splits);
return segments.map((segment, index) => {
const output = graph.label(kind);
const range = `start=${ffmpegSeconds(segment.start)}:end=${ffmpegSeconds(segment.end)}`;
graph.add(
[splits[index]!],
kind === "v"
? [`trim=${range}`, "setpts=PTS-STARTPTS"]
: [`atrim=${range}`, "asetpts=PTS-STARTPTS"],
[output],
);
return output;
});
};
const videoParts = branch(timeline.video, "v");
const audioParts = branch(timeline.audio, "a");
concatParts(graph, timeline, videoParts, audioParts, count);
timeline.seconds = segments.reduce((total, segment) => total + segment.end - segment.start, 0);
}
/** Appends clips normalized to the timeline: frame size, fit, fps and 48 kHz stereo audio. */
function appendClips(
graph: FilterGraph,
timeline: Timeline,
clips: readonly { clip: PlanInput; index: number }[],
) {
const fps = timeline.fps || FALLBACK_FPS;
const needsAudio = timeline.audio !== null || clips.some(({ clip }) => clip.probe.hasAudio);
const videoParts: string[] = [];
const audioParts: string[] = [];
if (timeline.video) {
timeline.video.filters.push("setsar=1", "format=yuv420p");
videoParts.push(graph.flush(timeline.video, "v"));
}
if (needsAudio) audioParts.push(audioPart(graph, timeline.audio, timeline.seconds));
let seconds = timeline.seconds;
for (const { clip, index } of clips) {
const video = clip.probe.video;
if (!video) throw new Error(`${clip.name} has no video.`);
const clipSeconds = clip.probe.durationMs / 1000;
if (clipSeconds <= 0) throw new Error(`Could not read the length of ${clip.name}.`);
if (timeline.video) {
const track = fitFrame(
graph,
{ label: `${index}:v:0`, filters: sourceFilters(video) },
displaySize(video),
timeline,
timeline.fit,
);
track.filters.push(`fps=${number(fps)}`, "format=yuv420p");
videoParts.push(graph.flush(track, "v"));
}
if (needsAudio) {
const track = clip.probe.hasAudio ? { label: `${index}:a:0`, filters: [] } : null;
audioParts.push(audioPart(graph, track, clipSeconds));
}
seconds += clipSeconds;
}
concatParts(graph, timeline, videoParts, audioParts, clips.length + 1);
timeline.seconds = seconds;
}
/** A clip's audio in the common format, or silence as long as the clip when it has none. */
function audioPart(graph: FilterGraph, track: Track | null, seconds: number): string {
if (track) {
track.filters.push(AUDIO_FORMAT);
return graph.flush(track, "a");
}
const output = graph.label("a");
graph.add(
[],
[
"anullsrc=channel_layout=stereo:sample_rate=48000",
`atrim=end=${ffmpegSeconds(seconds)}`,
AUDIO_FORMAT,
],
[output],
);
return output;
}
function concatParts(
graph: FilterGraph,
timeline: Timeline,
videoParts: readonly string[],
audioParts: readonly string[],
count: number,
) {
const inputs = Array.from({ length: count }, (_, index) => [
...(videoParts[index] ? [videoParts[index]] : []),
...(audioParts[index] ? [audioParts[index]] : []),
]).flat();
const video = videoParts.length > 0 ? graph.label("v") : undefined;
const audio = audioParts.length > 0 ? graph.label("a") : undefined;
graph.add(
inputs,
[`concat=n=${count}:v=${video ? 1 : 0}:a=${audio ? 1 : 0}`],
[...(video ? [video] : []), ...(audio ? [audio] : [])],
);
timeline.video = video ? { label: video, filters: [] } : null;
timeline.audio = audio ? { label: audio, filters: [] } : null;
}
interface Size {
width: number;
height: number;
}
/** Scales a frame of size `from` into `to`: center crop, bars, or a blurred copy behind it. */
function fitFrame(graph: FilterGraph, track: Track, from: Size, to: Size, fit: MediaFit): Track {
const { width, height } = to;
if (Math.abs(from.width / from.height - width / height) < 0.01) {
track.filters.push(`scale=${width}:${height}`, "setsar=1");
return track;
}
const contain = `scale=${width}:${height}:force_original_aspect_ratio=decrease:force_divisible_by=2`;
if (fit === "crop") {
track.filters.push(
`scale=${width}:${height}:force_original_aspect_ratio=increase`,
`crop=${width}:${height}`,
"setsar=1",
);
return track;
}
if (fit === "pad") {
track.filters.push(
contain,
`pad=${width}:${height}:(ow-iw)/2:(oh-ih)/2:color=black`,
"setsar=1",
);
return track;
}
// Blur a small copy and scale it up: as smooth as a large blur at a fraction of the cost.
const input = graph.flush(track, "v");
const [background, foreground, blurred, contained, output] = [
graph.label("v"),
graph.label("v"),
graph.label("v"),
graph.label("v"),
graph.label("v"),
];
const smallWidth = even(width / 4);
const smallHeight = even(height / 4);
const radius = Math.max(2, Math.floor(Math.min(smallWidth, smallHeight) / 10));
graph.add([input], ["split=2"], [background, foreground]);
graph.add(
[background],
[
`scale=${smallWidth}:${smallHeight}:force_original_aspect_ratio=increase`,
`crop=${smallWidth}:${smallHeight}`,
`boxblur=${radius}:2`,
`scale=${width}:${height}`,
"setsar=1",
],
[blurred],
);
graph.add([foreground], [contain, "setsar=1"], [contained]);
graph.add([blurred, contained], ["overlay=(W-w)/2:(H-h)/2"], [output]);
return { label: output, filters: [] };
}
/** Square pixels and even dimensions, which yuv420p H.264 requires. */
function sourceFilters(video: NonNullable<MediaProbe["video"]>): string[] {
const size = displaySize(video);
if (Math.abs(video.sampleAspectRatio - 1) > 0.01) {
return [`scale=${size.width}:${size.height}`, "setsar=1"];
}
if (size.width !== video.width || size.height !== video.height) {
return [`crop=${size.width}:${size.height}:0:0`];
}
return [];
}
function displaySize(video: NonNullable<MediaProbe["video"]>): Size {
if (Math.abs(video.sampleAspectRatio - 1) > 0.01) {
return {
width: even(Math.round(video.width * video.sampleAspectRatio)),
height: even(video.height),
};
}
return { width: even(video.width), height: even(video.height) };
}
/** atempo takes 0.5–2 per instance on older ffmpeg builds, so larger changes chain it. */
export function atempoFilters(factor: number): string[] {
const filters: string[] = [];
let rest = factor;
while (rest > 2) {
filters.push("atempo=2");
rest /= 2;
}
while (rest < 0.5) {
filters.push("atempo=0.5");
rest /= 0.5;
}
filters.push(`atempo=${number(rest)}`);
return filters;
}
/**
* Readable burned-in subtitles: bold white text with a dark outline, sized to the frame, and
* lifted above the bottom fifth of tall frames where 9:16 apps place their own controls.
*/
export function subtitleStyle(width: number, height: number): string {
const tall = height >= width * 1.5;
const fontSize = Math.round(Math.min(width, height) * 0.06);
const marginX = Math.round(width * (tall ? 0.1 : 0.05));
const marginY = Math.round(height * (tall ? 0.2 : 0.06));
return [
`PlayResX=${width}`,
`PlayResY=${height}`,
"FontName=DejaVu Sans",
`FontSize=${fontSize}`,
"Bold=1",
"PrimaryColour=&H00FFFFFF",
"OutlineColour=&H00000000",
"BackColour=&H80000000",
"BorderStyle=1",
`Outline=${Math.max(2, Math.round(fontSize / 16))}`,
"Shadow=1",
"Alignment=2",
`MarginL=${marginX}`,
`MarginR=${marginX}`,
`MarginV=${marginY}`,
].join(",");
}
function even(value: number): number {
return Math.max(2, 2 * Math.floor(value / 2));
}
function number(value: number): string {
return String(Number(value.toFixed(6)));
}
function seconds(value: number): string {
return String(Number(value.toFixed(2)));
}

View File

@ -0,0 +1,331 @@
import { copyFile, mkdtemp, open, stat } from "node:fs/promises";
import { join } from "node:path";
import type {
MediaJobContext,
MediaJobInputFile,
MediaOperationHandler,
MediaOperationResult,
} from "@rakazo/adapter-kit";
import type { EditVideoInput, ExportVideoInput, MediaOutput } from "@rakazo/contracts";
import {
ARTIFACT_MAX_BYTES_BY_KIND,
EditVideoInputSchema,
ExportVideoInputSchema,
MEDIA_PRESETS,
} from "@rakazo/contracts";
import type { FfmpegInfo } from "./ffmpeg.js";
import {
detectFfmpeg,
outputTooLargeMessage,
probeMedia,
renderPoster,
runFfmpeg,
} from "./ffmpeg.js";
import type { PlanInput, VideoEditPlan } from "./video-edit-plan.js";
import { planVideoEdit } from "./video-edit-plan.js";
export interface VideoOperationsDeps {
/** Finds ffmpeg; defaults to PATH lookup. */
detect?: () => Promise<FfmpegInfo | null>;
}
const PROGRESS_INTERVAL_MS = 1_000;
/** Share of each output's progress spent encoding; the rest is probing and the poster. */
const ENCODE_SHARE = 0.95;
const SUBTITLE_MAX_BYTES = 5 * 1024 * 1024;
/** `edit_video` and `export_video`, available wherever ffmpeg and ffprobe are installed. */
export function createVideoOperations(deps: VideoOperationsDeps = {}) {
const detect = deps.detect ?? detectFfmpeg;
const available = async () => (await detect()) !== null;
const tools = async () => {
const info = await detect();
if (!info) throw new Error("ffmpeg and ffprobe are not installed on this server.");
return info;
};
const editVideo: MediaOperationHandler = {
operation: "edit_video",
run: async (context) => runEditVideo(context, await tools()),
};
const exportVideo: MediaOperationHandler = {
operation: "export_video",
run: async (context) => runExportVideo(context, await tools()),
};
return [
{
operation: "edit_video" as const,
inputSchema: EditVideoInputSchema,
title: (input: EditVideoInput) => input.title,
available,
handler: editVideo,
},
{
operation: "export_video" as const,
inputSchema: ExportVideoInputSchema,
title: (input: ExportVideoInput) =>
input.title ?? input.presets.map((preset) => MEDIA_PRESETS[preset].aspect).join(" · "),
available,
handler: exportVideo,
},
];
}
async function runEditVideo(
context: MediaJobContext,
tools: FfmpegInfo,
): Promise<MediaOperationResult> {
const input = EditVideoInputSchema.parse(context.input);
const workDir = await mkdtemp(join(context.tempDir, "edit-"));
const progress = createProgressReporter(context);
const source = await loadVideo(context, tools, input.artifact_id);
const name = cleanFileName(input.title, cleanFileName(withoutExtension(source.name), "video"));
if (input.steps.every((step) => step.op === "poster")) {
const at = input.steps[0]?.op === "poster" ? input.steps[0].at : 0;
if (at * 1000 >= source.probe.durationMs) {
throw new Error(
`poster at ${at} s is past the end of the ${source.probe.durationMs / 1000} s video.`,
);
}
const posterPath = join(workDir, "poster.jpg");
await renderPoster(source.path, posterPath, {
signal: context.signal,
mimeType: source.mimeType,
atMs: at * 1000,
});
const output = await context.addOutput({
path: posterPath,
name: `${name}.jpg`,
mimeType: "image/jpeg",
width: source.probe.video?.width,
height: source.probe.video?.height,
parentArtifactId: source.artifactId,
});
await progress.done();
return { summary: `Saved the frame at ${at} s of ${source.name} as ${describe(output)}.` };
}
const clips = new Map<string, PlanInput>();
const subtitleFiles = new Map<string, string>();
for (const step of input.steps) {
if (step.op === "concat") {
for (const id of step.artifact_ids) {
if (!clips.has(id)) clips.set(id, await loadVideo(context, tools, id));
}
} else if (step.op === "subtitles" && !subtitleFiles.has(step.artifact_id)) {
subtitleFiles.set(
step.artifact_id,
await prepareSubtitles(context, step.artifact_id, workDir, subtitleFiles.size),
);
}
}
const audioOnly = input.steps.at(-1)?.op === "extract_audio";
const outputPath = join(workDir, audioOnly ? "output.m4a" : "output.mp4");
const plan = planVideoEdit({ source, steps: input.steps, clips, subtitleFiles, outputPath });
const output = await encode(context, tools, plan, outputPath, workDir, progress.range(0, 1), {
name: `${name}${audioOnly ? ".m4a" : ".mp4"}`,
parentArtifactId: source.artifactId,
});
await progress.done();
return {
summary: `Edited ${source.name} into ${describe(output)}.`,
};
}
async function runExportVideo(
context: MediaJobContext,
tools: FfmpegInfo,
): Promise<MediaOperationResult> {
const input = ExportVideoInputSchema.parse(context.input);
const presets = [...new Set(input.presets)];
const workDir = await mkdtemp(join(context.tempDir, "export-"));
const progress = createProgressReporter(context);
const source = await loadVideo(context, tools, input.artifact_id);
const base = cleanFileName(withoutExtension(source.name), "video");
const outputs: MediaOutput[] = [];
for (const [index, preset] of presets.entries()) {
const outputPath = join(workDir, `${preset}.mp4`);
const plan = planVideoEdit({
source,
steps: [{ op: "reframe", preset, fit: input.fit }],
outputPath,
});
const share = 1 / presets.length;
outputs.push(
await encode(
context,
tools,
plan,
outputPath,
workDir,
progress.range(index * share, (index + 1) * share),
{
name: `${base}-${MEDIA_PRESETS[preset].aspect.replace(":", "x")}.mp4`,
parentArtifactId: source.artifactId,
preset,
},
),
);
}
await progress.done();
return { summary: `Exported ${source.name} as ${outputs.map(describe).join(", ")}.` };
}
/** Runs a plan, checks the result, renders its poster and stores it as an output. */
async function encode(
context: MediaJobContext,
tools: FfmpegInfo,
plan: VideoEditPlan,
outputPath: string,
workDir: string,
report: (fraction: number) => void,
file: Pick<Parameters<MediaJobContext["addOutput"]>[0], "name" | "parentArtifactId" | "preset">,
): Promise<MediaOutput> {
const maxBytes = ARTIFACT_MAX_BYTES_BY_KIND[plan.kind];
await runFfmpeg(plan.args, {
signal: context.signal,
cwd: workDir,
durationMs: plan.durationMs,
onProgress: (fraction) => report(fraction * ENCODE_SHARE),
maxOutputBytes: maxBytes,
outputs: [outputPath],
ffmpegPath: tools.ffmpegPath,
});
if ((await stat(outputPath)).size > maxBytes) throw new Error(outputTooLargeMessage(maxBytes));
const probe = await probeMedia(outputPath, {
signal: context.signal,
mimeType: plan.kind === "audio" ? "audio/mp4" : "video/mp4",
ffprobePath: tools.ffprobePath,
});
if (plan.kind === "audio") {
const output = await context.addOutput({
...file,
path: outputPath,
mimeType: "audio/mp4",
durationMs: probe.durationMs,
});
report(1);
return output;
}
const posterPath = outputPath.replace(/\.mp4$/, ".jpg");
await renderPoster(outputPath, posterPath, {
signal: context.signal,
mimeType: "video/mp4",
atMs: plan.posterAtMs ?? Math.min(1_000, probe.durationMs / 4),
});
const output = await context.addOutput({
...file,
path: outputPath,
mimeType: "video/mp4",
width: probe.video?.width ?? plan.width,
height: probe.video?.height ?? plan.height,
durationMs: probe.durationMs,
posterPath,
});
report(1);
return output;
}
async function loadVideo(
context: MediaJobContext,
tools: FfmpegInfo,
artifactId: string,
): Promise<PlanInput & MediaJobInputFile> {
const file = await context.fetchArtifact(artifactId);
if (file.kind !== "video") throw new Error(`${file.name} is not a video.`);
const probe = await probeMedia(file.path, {
signal: context.signal,
mimeType: file.mimeType,
ffprobePath: tools.ffprobePath,
});
if (!probe.video) throw new Error(`${file.name} has no video.`);
if (probe.durationMs <= 0) throw new Error(`Could not read the length of ${file.name}.`);
return { ...file, probe };
}
/** Copies an SRT/VTT artifact to a generated name, so no user text reaches the filter graph. */
async function prepareSubtitles(
context: MediaJobContext,
artifactId: string,
workDir: string,
index: number,
): Promise<string> {
const file = await context.fetchArtifact(artifactId);
const format = file.size <= SUBTITLE_MAX_BYTES ? subtitleFormat(await readHead(file.path)) : null;
if (!format) throw new Error(`${file.name} is not an SRT or VTT file.`);
const name = `subtitles-${index}.${format}`;
await copyFile(file.path, join(workDir, name));
return name;
}
async function readHead(path: string): Promise<string> {
const handle = await open(path, "r");
try {
const buffer = Buffer.alloc(1024);
const { bytesRead } = await handle.read(buffer, 0, buffer.length, 0);
return buffer.subarray(0, bytesRead).toString("utf8");
} finally {
await handle.close();
}
}
/**
* Recognizes WebVTT and SubRip by content. Anything else is refused, so ffmpeg never probes
* an arbitrary file (a playlist, say) handed in as subtitles.
*/
export function subtitleFormat(head: string): "srt" | "vtt" | null {
const text = head.replace(/^/, "").trimStart();
if (/^WEBVTT(?:[ \t\r\n]|$)/.test(text)) return "vtt";
if (/^(?:\d+[ \t]*\r?\n)?\d{1,2}:\d{2}:\d{2}[,.]\d{1,3}[ \t]*-->/.test(text)) return "srt";
return null;
}
/** A name for produced files: no path separators or control characters, at most 80 chars. */
export function cleanFileName(text: string, fallback: string): string {
const name = text
.replaceAll(/[\p{Cc}\\/:*?"<>|]+/gu, " ")
.replaceAll(/\s+/g, " ")
.replace(/^[\s.]+/, "")
.slice(0, 80)
.trim();
return name || fallback;
}
function withoutExtension(name: string): string {
return name.replace(/\.[A-Za-z0-9]{1,5}$/, "");
}
function describe(output: MediaOutput): string {
const size = output.width && output.height ? ` ${output.width}×${output.height}` : "";
const length =
output.durationMs !== undefined ? ` ${Number((output.durationMs / 1000).toFixed(1))} s` : "";
return `${output.name}${size}${length} (artifact ${output.artifactId})`;
}
/**
* Maps ffmpeg progress onto the card: monotonic, at most once a second, written in order and
* never failing the job when a write fails.
*/
function createProgressReporter(context: MediaJobContext) {
let reported = 0;
let reportedAt = 0;
let writes = Promise.resolve();
const write = (fraction: number) => {
reported = fraction;
reportedAt = Date.now();
writes = writes.then(() => context.progress(fraction)).catch(() => undefined);
};
return {
range: (from: number, to: number) => (fraction: number) => {
const value = Math.min(1, from + (to - from) * Math.min(1, Math.max(0, fraction)));
const boundary = fraction >= 1;
if (value <= reported) return;
if (!boundary && Date.now() - reportedAt < PROGRESS_INTERVAL_MS) return;
write(value);
},
done: async () => {
if (reported < 1) write(1);
await writes;
},
};
}

View File

@ -10,6 +10,7 @@ export * from "./integration-settings.js";
export * from "./local-settings.js";
export * from "./mcp.js";
export * from "./media.js";
export * from "./media-video.js";
export * from "./model-tiers.js";
export * from "./openai-compatible-ui.js";
export * from "./project-goals.js";

View File

@ -0,0 +1,80 @@
import * as z from "zod";
import { Id } from "./ids.js";
import { MediaFitSchema, MediaPresetIdSchema } from "./media.js";
/**
* Inputs of the `edit_video` and `export_video` media operations, which are also the arguments
* of the tools with those names. Times are seconds on the timeline left by the previous steps.
*/
const Seconds = z.number().min(0).max(86_400);
const TimeRangeSchema = z
.object({ start: Seconds, end: Seconds })
.refine((range) => range.end > range.start, "end must be after start");
/** Written by the bot in the user's language; shown on the media card and used as file name. */
const MediaTitleSchema = z.string().trim().min(1).max(120);
export const EditVideoStepSchema = z.discriminatedUnion("op", [
z
.object({ op: z.literal("trim"), start: Seconds, end: Seconds })
.refine((step) => step.end > step.start, "end must be after start"),
/** Keeps these parts and cuts everything between them. */
z.object({ op: z.literal("keep"), segments: z.array(TimeRangeSchema).min(1).max(50) }),
/** Appends these videos, fitted to the current frame. */
z.object({ op: z.literal("concat"), artifact_ids: z.array(Id).min(1).max(20) }),
z.object({
op: z.literal("reframe"),
preset: MediaPresetIdSchema,
fit: MediaFitSchema.default("crop"),
}),
z.object({ op: z.literal("speed"), factor: z.number().min(0.25).max(4) }),
/** Linear gain: 0.5 halves the loudness, 2 doubles it. */
z.object({ op: z.literal("volume"), gain: z.number().min(0).max(8) }),
z.object({ op: z.literal("mute") }),
/** Burns in an SRT or VTT artifact. */
z.object({ op: z.literal("subtitles"), artifact_id: Id }),
/** Last step only: the result is the audio as M4A. */
z.object({ op: z.literal("extract_audio") }),
/** Cover frame at this second of the result; as the only kind of step, the result is that image. */
z.object({ op: z.literal("poster"), at: Seconds }),
]);
export type EditVideoStep = z.infer<typeof EditVideoStepSchema>;
export const EditVideoInputSchema = z
.object({
artifact_id: Id,
steps: z.array(EditVideoStepSchema).min(1).max(20),
title: MediaTitleSchema,
})
.superRefine((input, context) => {
const ops = input.steps.map((step) => step.op);
const extract = ops.indexOf("extract_audio");
if (extract >= 0 && extract !== ops.length - 1) {
context.addIssue({
code: "custom",
path: ["steps", extract],
message: "extract_audio must be the last step",
});
}
if (extract >= 0 && ops.includes("poster")) {
context.addIssue({
code: "custom",
path: ["steps"],
message: "poster cannot be combined with extract_audio",
});
}
if (ops.filter((op) => op === "poster").length > 1) {
context.addIssue({ code: "custom", path: ["steps"], message: "use at most one poster step" });
}
});
export type EditVideoInput = z.infer<typeof EditVideoInputSchema>;
export const ExportVideoInputSchema = z.object({
artifact_id: Id,
presets: z.array(MediaPresetIdSchema).min(1).max(6),
fit: MediaFitSchema.default("crop"),
title: MediaTitleSchema.optional(),
});
export type ExportVideoInput = z.infer<typeof ExportVideoInputSchema>;