evOo-local/packages/adapters/src/media/video-operations.test.ts
Claude 44702e3770
feat(media): register video operations with the media runtime
edit_video and export_video now run as media jobs: both registrations join
the shared operation list the API and the worker build their runtime from,
name every artifact they read (source, appended clips, subtitle files) so
starting a job refuses artifacts the user cannot read, and their tool
schemas use the runtime's continue_after helper.

Container deployments get ffmpeg, ffprobe and the subtitle font in the
API/worker image. Tests cover the handlers against a fake job context:
cuts, blurred 9:16 reframes with subtitles, appended silent clips, audio
extraction, single frames, batch exports with posters and progress,
rotated phone video, clear input errors and cancellation. They skip where
ffmpeg is not installed; the planner and parser tests always run.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MRtGG9ARfqqhma3t85Ro2u
2026-10-03 13:45:50 +00:00

290 lines
11 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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 { rm } from "node:fs/promises";
import { join } from "node:path";
import { afterAll, beforeAll, describe, expect, it } from "vitest";
import { builtinAgentTools } from "../builtin-tools.js";
import { createMediaOperations } from "../media-operations.js";
import { MEDIA_CONTINUE_AFTER_PROPERTY } from "../media-tools.js";
import { parametersFor } from "../pi-runtime.js";
import { detectFfmpeg, probeMedia, runFfmpeg } from "./ffmpeg.js";
import type { TestArtifact, TestClips } from "./media-test-support.js";
import { createTestClips, fakeMediaJobContext } from "./media-test-support.js";
import { cleanFileName, createVideoOperations, subtitleFormat } from "./video-operations.js";
const ffmpeg = await detectFfmpeg();
function operation(name: "edit_video" | "export_video", deps = {}) {
const registration = createVideoOperations(deps).find((entry) => entry.operation === name);
if (!registration) throw new Error(`missing ${name}`);
return registration;
}
describe("video operation registrations", () => {
it("are registered for the runtime and unavailable without ffmpeg", async () => {
const names = createMediaOperations({ loadSettings: async () => ({}) as never }).map(
(entry) => entry.operation,
);
expect(names).toEqual(expect.arrayContaining(["edit_video", "export_video"]));
const edit = operation("edit_video", { detect: async () => null });
expect(await edit.available({ spaceId: "space-1" })).toBe(false);
const { context } = await fakeMediaJobContext({}, {});
await expect(edit.handler.run(context)).rejects.toThrow(
"ffmpeg and ffprobe are not installed on this server.",
);
});
it("names every artifact the input reads so start() can authorize them", () => {
const edit = operation("edit_video");
const input = edit.inputSchema.parse({
artifact_id: "src",
title: "Reel",
steps: [
{ op: "concat", artifact_ids: ["clip-1", "clip-2"] },
{ op: "subtitles", artifact_id: "subs" },
],
});
expect(edit.artifactIds?.(input)).toEqual(["src", "clip-1", "clip-2", "subs"]);
const exported = operation("export_video");
const exportInput = exported.inputSchema.parse({ artifact_id: "src", presets: ["vertical"] });
expect(exported.artifactIds?.(exportInput)).toEqual(["src"]);
});
it("titles exports by aspect ratio unless the bot gave a title", () => {
const exported = operation("export_video");
expect(
exported.title(
exported.inputSchema.parse({ artifact_id: "a", presets: ["vertical", "square"] }),
),
).toBe("9:16 · 1:1");
expect(
exported.title(
exported.inputSchema.parse({ artifact_id: "a", presets: ["vertical"], title: "Reel" }),
),
).toBe("Reel");
});
it("defines tools named like the operations, with continue_after, that the runtime can encode", () => {
for (const name of ["edit_video", "export_video"]) {
const tool = builtinAgentTools.find((entry) => entry.name === name);
expect(tool).toBeDefined();
expect(tool?.inputSchema.properties).toMatchObject(MEDIA_CONTINUE_AFTER_PROPERTY);
expect(() => parametersFor(tool!)).not.toThrow();
}
});
});
describe("subtitle and file name helpers", () => {
it("recognizes SRT and VTT by content only", () => {
expect(subtitleFormat("WEBVTT\n\n00:00.000 --> 00:01.000\nHi")).toBe("vtt");
expect(subtitleFormat("1\r\n00:00:01,000 --> 00:00:02,000\r\nHi")).toBe("srt");
expect(subtitleFormat("00:00:01.000 --> 00:00:02.000\nHi")).toBe("srt");
expect(subtitleFormat("#EXTM3U\n#EXTINF:1,\nfile:/etc/passwd")).toBeNull();
expect(subtitleFormat("WEBVTTX")).toBeNull();
});
it("cleans file names without losing the user's language", () => {
expect(cleanFileName("Reel: Sommer/Urlaub 2026", "video")).toBe("Reel Sommer Urlaub 2026");
expect(cleanFileName("../..\u0000", "video")).toBe("video");
expect(cleanFileName("x".repeat(200), "video")).toHaveLength(80);
});
});
// CI runners may not have ffmpeg; these run wherever it is installed.
describe.skipIf(!ffmpeg)("video operations with ffmpeg", () => {
let clips: TestClips;
let artifacts: Record<string, TestArtifact>;
beforeAll(async () => {
clips = await createTestClips();
artifacts = {
land: { path: clips.landscape, name: "Landscape clip.mp4", mimeType: "video/mp4" },
silent: { path: clips.silentPortrait, name: "silent.mp4", mimeType: "video/mp4" },
srt: { path: clips.srt, name: "captions.srt", mimeType: "application/x-subrip" },
vtt: { path: clips.vtt, name: "captions.vtt", mimeType: "text/vtt" },
notes: { path: clips.landscape, name: "notes.txt", mimeType: "text/plain" },
};
});
afterAll(async () => {
if (clips) await rm(clips.dir, { recursive: true, force: true });
});
async function run(name: "edit_video" | "export_video", input: Record<string, unknown>) {
const registration = operation(name);
const job = await fakeMediaJobContext(registration.inputSchema.parse(input), artifacts);
try {
const result = await registration.handler.run(job.context);
return { ...job, result };
} finally {
await rm(job.tempDir, { recursive: true, force: true });
}
}
it("cuts, reframes to 9:16 with a blurred fill, burns in subtitles and speeds up", async () => {
const { outputs, progress, result } = await run("edit_video", {
artifact_id: "land",
title: "Sommer-Reel",
steps: [
{ op: "trim", start: 0.5, end: 2.5 },
{ op: "reframe", preset: "vertical", fit: "blur" },
{ op: "subtitles", artifact_id: "srt" },
{ op: "speed", factor: 2 },
{ op: "poster", at: 0.5 },
],
});
expect(outputs).toHaveLength(1);
const [output] = outputs;
expect(output).toMatchObject({
name: "Sommer-Reel.mp4",
mimeType: "video/mp4",
width: 1080,
height: 1920,
parentArtifactId: "land",
});
expect(output!.posterSize).toBeGreaterThan(0);
expect(Math.abs(output!.durationMs! - 1000)).toBeLessThan(150);
expect(progress.at(-1)).toBe(1);
expect(progress).toEqual([...progress].sort((a, b) => a - b));
expect(result.summary).toContain("Sommer-Reel.mp4 (1080×1920, ");
});
it("keeps segments, appends a silent clip with generated silence, and changes volume", async () => {
const registration = operation("edit_video");
const job = await fakeMediaJobContext(
registration.inputSchema.parse({
artifact_id: "land",
title: "Cut",
steps: [
{
op: "keep",
segments: [
{ start: 0, end: 0.5 },
{ start: 2, end: 2.8 },
],
},
{ op: "concat", artifact_ids: ["silent"] },
{ op: "volume", gain: 0.5 },
{ op: "subtitles", artifact_id: "vtt" },
],
}),
artifacts,
);
await registration.handler.run(job.context);
const [output] = job.outputs;
const probe = await probeMedia(output!.path, { mimeType: "video/mp4" });
expect(probe.hasAudio).toBe(true);
expect(probe.video).toMatchObject({ width: 320, height: 180, rotation: 0 });
expect(Math.abs(probe.durationMs - 3300)).toBeLessThan(200);
await rm(job.tempDir, { recursive: true, force: true });
});
it("plays rotated phone video upright without rotating it twice", async (context) => {
const rotated = join(clips.dir, "rotated.mp4");
try {
await runFfmpeg([
...["-display_rotation", "90", "-i", `file:${clips.landscape}`],
...["-c", "copy", `file:${rotated}`],
]);
} catch {
context.skip("this ffmpeg cannot write rotation metadata");
}
const registration = operation("edit_video");
const job = await fakeMediaJobContext(
registration.inputSchema.parse({
artifact_id: "rotated",
title: "Upright",
steps: [{ op: "trim", start: 0, end: 1 }],
}),
{ rotated: { path: rotated, name: "rotated.mp4", mimeType: "video/mp4" } },
);
await registration.handler.run(job.context);
const probe = await probeMedia(job.outputs[0]!.path, { mimeType: "video/mp4" });
expect(probe.video).toMatchObject({ width: 180, height: 320, rotation: 0 });
await rm(job.tempDir, { recursive: true, force: true });
});
it("extracts the audio as M4A", async () => {
const { outputs } = await run("edit_video", {
artifact_id: "land",
title: "Ton",
steps: [{ op: "trim", start: 1, end: 2 }, { op: "extract_audio" }],
});
expect(outputs).toEqual([
expect.objectContaining({ name: "Ton.m4a", mimeType: "audio/mp4", parentArtifactId: "land" }),
]);
expect(outputs[0]?.posterPath).toBeUndefined();
expect(Math.abs(outputs[0]!.durationMs! - 1000)).toBeLessThan(100);
});
it("saves a single frame when poster is the only step", async () => {
const { outputs } = await run("edit_video", {
artifact_id: "land",
title: "Cover",
steps: [{ op: "poster", at: 1 }],
});
expect(outputs).toEqual([
expect.objectContaining({
name: "Cover.jpg",
mimeType: "image/jpeg",
width: 320,
height: 180,
}),
]);
});
it("exports one output per distinct preset with posters and batch progress", async () => {
const { outputs, progress } = await run("export_video", {
artifact_id: "land",
presets: ["square", "portrait", "square"],
fit: "pad",
});
expect(
outputs.map((output) => [output.name, output.preset, output.width, output.height]),
).toEqual([
["Landscape clip-1x1.mp4", "square", 1080, 1080],
["Landscape clip-4x5.mp4", "portrait", 1080, 1350],
]);
for (const output of outputs) {
expect(output.parentArtifactId).toBe("land");
expect(output.posterSize).toBeGreaterThan(0);
}
expect(progress).toContain(0.5);
expect(progress.at(-1)).toBe(1);
});
it("fails clearly on ranges past the end, non-subtitle files and non-videos", async () => {
await expect(
run("edit_video", {
artifact_id: "land",
title: "x",
steps: [{ op: "trim", start: 5, end: 6 }],
}),
).rejects.toThrow("trim 5–6 s is outside the 3 s video.");
await expect(
run("edit_video", {
artifact_id: "land",
title: "x",
steps: [{ op: "subtitles", artifact_id: "land" }],
}),
).rejects.toThrow("Landscape clip.mp4 is not an SRT or VTT file.");
await expect(
run("export_video", { artifact_id: "notes", presets: ["square"] }),
).rejects.toThrow("notes.txt is not a video.");
});
it("stops ffmpeg and stores nothing when the job is cancelled", async () => {
const registration = operation("export_video");
const job = await fakeMediaJobContext(
registration.inputSchema.parse({ artifact_id: "land", presets: ["cinema"], fit: "blur" }),
artifacts,
);
const progress = job.context.progress;
job.context.progress = async (fraction) => {
await progress(fraction);
job.controller.abort(new Error("cancelled"));
};
await expect(registration.handler.run(job.context)).rejects.toThrow("cancelled");
expect(job.outputs).toEqual([]);
await rm(job.tempDir, { recursive: true, force: true });
});
});