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