evOo-local/packages/adapters/src/media/media-test-support.ts
Claude 485090c391
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
2026-10-03 13:35:34 +00:00

107 lines
4.1 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { 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 };
}