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
107 lines
4.1 KiB
TypeScript
107 lines
4.1 KiB
TypeScript
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 };
|
||
}
|