diff --git a/apps/api/src/media-capabilities.test.ts b/apps/api/src/media-capabilities.test.ts
index 1da582d..be74a1e 100644
--- a/apps/api/src/media-capabilities.test.ts
+++ b/apps/api/src/media-capabilities.test.ts
@@ -1,5 +1,6 @@
import type { MediaCapabilities } from "@rakazo/adapter-kit";
import type { Actor } from "@rakazo/contracts";
+import { MediaOperationSchema } from "@rakazo/contracts";
import { describe, expect, it } from "vitest";
import type { MediaRouteDeps } from "./media-jobs.js";
import { getMediaCapabilities } from "./media-jobs.js";
@@ -28,7 +29,7 @@ describe("media capabilities", () => {
it("reports nothing available without a media runtime", async () => {
const capabilities = await getMediaCapabilities({} as MediaRouteDeps, actor);
- expect(Object.values(capabilities)).toHaveLength(6);
+ expect(Object.keys(capabilities)).toEqual(MediaOperationSchema.options);
expect(Object.values(capabilities).every((value) => value === false)).toBe(true);
});
});
diff --git a/apps/web/e2e/media-card.spec.ts b/apps/web/e2e/media-card.spec.ts
index 6697d4a..af3da7a 100644
--- a/apps/web/e2e/media-card.spec.ts
+++ b/apps/web/e2e/media-card.spec.ts
@@ -223,6 +223,33 @@ test("renders a media job card while it runs and when it is done", async ({ page
});
});
+test("shows a finished video analysis as a done card without files", async ({ page }, testInfo) => {
+ const stamp = Date.now();
+ await signup(page, `media-analysis-${stamp}@rakazo.test`, "password12", "Media Analysis");
+ await completeOnboarding(page);
+ const botId = activeBotId(page);
+ // The cut points go to the bot only; the card just tells the user the analysis ran.
+ await seedMediaMessage(page, botId, () => ({
+ kind: "media",
+ jobId: "media-job-analysis",
+ operation: "analyze_video",
+ status: "done",
+ title: "Find the cut points",
+ progress: 1,
+ outputs: [],
+ }));
+ await page.reload();
+
+ const card = page.getByTestId("media-card");
+ await expect(card).toBeVisible({ timeout: 30_000 });
+ await expect(card).toHaveAttribute("data-status", "done");
+ await expect(card).toContainText("Find the cut points");
+ await expect(card).toContainText("Done");
+ await expect(card.getByRole("button")).toHaveCount(0);
+ await expect(card.getByRole("link")).toHaveCount(0);
+ await captureScreenshot(page, testInfo, "media-card-analysis");
+});
+
test("a platform export names its platform, shows a failed check and exports for others", async ({
page,
}, testInfo) => {
diff --git a/apps/web/src/components/media/MediaCard.test.tsx b/apps/web/src/components/media/MediaCard.test.tsx
index eb9513d..b0c35de 100644
--- a/apps/web/src/components/media/MediaCard.test.tsx
+++ b/apps/web/src/components/media/MediaCard.test.tsx
@@ -109,6 +109,23 @@ describe("media card", () => {
expect(html).not.toContain("Cancel");
});
+ it("shows a finished analysis as done, with nothing to open or act on", () => {
+ const html = renderToString(
+ ,
+ );
+ expect(html).toContain('data-status="done"');
+ expect(html).toContain("Find the cut points");
+ expect(html).toContain("Done");
+ expect(html).toContain("lucide-scan-search");
+ expect(html).not.toContain("Download");
+ expect(html).not.toContain("Export");
+ expect(html).not.toContain("Transcribe");
+ });
+
it("offers only what the space can run and what fits the output", () => {
const audio = { ...video, artifactId: "audio-1", kind: "audio" as const, name: "take.m4a" };
const onlyTranscribe = renderToString(
diff --git a/apps/web/src/components/media/MediaCard.tsx b/apps/web/src/components/media/MediaCard.tsx
index 85375eb..aa6cfc8 100644
--- a/apps/web/src/components/media/MediaCard.tsx
+++ b/apps/web/src/components/media/MediaCard.tsx
@@ -19,7 +19,16 @@ import {
DropdownMenuTrigger,
} from "@rakazo/ui-web";
import type { LucideIcon } from "lucide-react";
-import { Captions, ChevronDown, Download, Film, ImageIcon, Scissors, Sparkles } from "lucide-react";
+import {
+ Captions,
+ ChevronDown,
+ Download,
+ Film,
+ ImageIcon,
+ ScanSearch,
+ Scissors,
+ Sparkles,
+} from "lucide-react";
import { useEffect, useId, useState } from "react";
import type { ArtifactTarget } from "../../lib/artifact-open";
import { artifactUrl } from "../../lib/artifact-url";
@@ -54,6 +63,7 @@ const OPERATION_ICONS: Record = {
edit_video: Scissors,
export_video: Film,
transcribe: Captions,
+ analyze_video: ScanSearch,
};
export function isActiveMediaStatus(status: MediaJobStatus): boolean {
diff --git a/packages/adapter-kit/src/media.ts b/packages/adapter-kit/src/media.ts
index 9a4ea34..35e9d66 100644
--- a/packages/adapter-kit/src/media.ts
+++ b/packages/adapter-kit/src/media.ts
@@ -171,6 +171,14 @@ export interface MediaJobContext {
progress(fraction: number): Promise;
/** Copies an artifact of the job's space that the job's user may read into tempDir. */
fetchArtifact(artifactId: string): Promise;
+ /**
+ * Fetches the newest artifact made from `artifactId` (a transcript of a video, say) with one
+ * of these MIME types, like `fetchArtifact`; null when the user has none.
+ */
+ fetchDerivedArtifact(
+ artifactId: string,
+ mimeTypes: readonly string[],
+ ): Promise;
/** Stores a produced file as an artifact and adds it to the media card. */
addOutput(file: MediaJobOutputFile): Promise;
/** Shows a platform the job did not render on the media card, with the reason. */
diff --git a/packages/adapters/src/builtin-tools.ts b/packages/adapters/src/builtin-tools.ts
index 14127db..4a8aaae 100644
--- a/packages/adapters/src/builtin-tools.ts
+++ b/packages/adapters/src/builtin-tools.ts
@@ -1,5 +1,6 @@
import type { ConnectorTool } from "@rakazo/adapter-kit";
import {
+ AnalyzeVideoInputSchema,
BotSecretDestination,
BotSecretName,
EditImageInputSchema,
@@ -21,12 +22,15 @@ import { mediaToolInputSchema } from "./media-tools.js";
import { NOTIFY_USER_BODY_MAX_LENGTH, NOTIFY_USER_TITLE_MAX_LENGTH } from "./notify-user-tool.js";
/** A media tool's parameters from its operation's input schema, plus `continue_after`. */
-function mediaParameters(schema: z.ZodType) {
+function mediaParameters(schema: z.ZodType, options?: { continueAfter?: boolean }) {
const json = z.toJSONSchema(schema, { io: "input" });
- return mediaToolInputSchema({
- properties: (json.properties ?? {}) as Record,
- required: json.required,
- });
+ return mediaToolInputSchema(
+ {
+ properties: (json.properties ?? {}) as Record,
+ required: json.required,
+ },
+ options,
+ );
}
export const SUBAGENT_MODEL_TIER_DESCRIPTION =
@@ -565,7 +569,7 @@ export const builtinAgentTools: ConnectorTool[] = [
},
{
name: "edit_video",
- description: `Edit a video artifact into a new one. 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. title: a few words in the user's language for the card and file name.`,
+ description: `Edit a video artifact into a new one. 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; remove_silence {threshold_db -35, min_silence 0.6, padding 0.12} cuts pauses quieter than threshold_db dB and at least min_silence s long, keeping padding s on each side. ${VIDEO_PRESETS_AND_FITS} Videos come out as H.264/AAC MP4. title: a few words in the user's language for the card and file name.`,
inputSchema: mediaParameters(EditVideoInputSchema),
},
{
@@ -579,6 +583,12 @@ export const builtinAgentTools: ConnectorTool[] = [
"Transcribe speech in an audio or video artifact into SRT and VTT subtitle files, shown on a media card. Set continue_after to get the transcript back, e.g. to burn the .srt in with edit_video.",
inputSchema: mediaParameters(TranscribeInputSchema),
},
+ {
+ name: "analyze_video",
+ description:
+ "Find cut points in a video artifact: scene changes, pauses, loud moments and, if it was transcribed, transcript timing. Read-only; you are woken with the result as JSON (times in seconds) to plan edit_video steps. title: a few words in the user's language for the card.",
+ inputSchema: mediaParameters(AnalyzeVideoInputSchema, { continueAfter: false }),
+ },
{
name: "search_media",
description:
diff --git a/packages/adapters/src/media-operations.ts b/packages/adapters/src/media-operations.ts
index 7f93346..f46fd54 100644
--- a/packages/adapters/src/media-operations.ts
+++ b/packages/adapters/src/media-operations.ts
@@ -1,6 +1,7 @@
import type { MediaSettings } from "@rakazo/contracts";
import { createImageOperations } from "./media/image-operations.js";
import { createTranscriptionOperations, ffmpegTranscriptionAudio } from "./media/transcription.js";
+import { createVideoAnalysisOperations } from "./media/video-analysis.js";
import { createVideoOperations } from "./media/video-operations.js";
import type { MediaOperationRegistration } from "./media-runtime.js";
@@ -21,5 +22,6 @@ export function createMediaOperations(deps: MediaOperationDeps): MediaOperationR
loadSettings: deps.loadSettings,
audio: ffmpegTranscriptionAudio,
}),
+ ...createVideoAnalysisOperations(),
];
}
diff --git a/packages/adapters/src/media-runtime.ts b/packages/adapters/src/media-runtime.ts
index 4eb7702..1db6d1e 100644
--- a/packages/adapters/src/media-runtime.ts
+++ b/packages/adapters/src/media-runtime.ts
@@ -55,6 +55,8 @@ import { storeArtifactFromFile } from "./artifact-files.js";
/** Run trigger of the turn that wakes a bot when its media job ends (`continue_after`). */
export const MEDIA_WAKE_TRIGGER = "media";
+/** Longest job summary a woken bot reads; analyze_video fits its whole result into it. */
+export const MEDIA_SUMMARY_MAX_LENGTH = 12_000;
/**
* One media operation (`generate_image`, `edit_video`, …): how to validate and label its input,
@@ -123,7 +125,6 @@ export interface MediaRuntimeHost extends MediaRuntime {
const ACTIVE_STATUSES: MediaJobStatus[] = [...MEDIA_JOB_ACTIVE_STATUSES];
const TITLE_MAX_LENGTH = 200;
const ERROR_MAX_LENGTH = 500;
-const SUMMARY_MAX_LENGTH = 4_000;
const GPU_STATUS_TIMEOUT_MS = 10_000;
const GPU_RETRY_MS = 15_000;
/** Retry delay while another media job holds the GPU. */
@@ -275,9 +276,12 @@ export function mediaWakePrompt(job: {
const outputs = job.outputs.map((output) => `${output.name} (${output.artifactId})`).join(", ");
return [
`${label} is done.`,
- job.summary ? `Result: ${clamp(job.summary, SUMMARY_MAX_LENGTH)}` : "",
+ job.summary ? `Result: ${clamp(job.summary, MEDIA_SUMMARY_MAX_LENGTH)}` : "",
outputs ? `Outputs: ${outputs}.` : "",
- "The media card already shows the result to the user; continue your task.",
+ // A job without files (analyze_video) reports to the bot alone; its card just says done.
+ outputs
+ ? "The media card already shows the result to the user; continue your task."
+ : "Continue your task.",
]
.filter(Boolean)
.join(" ");
@@ -767,6 +771,20 @@ export function createMediaRuntime(
inputs.set(artifactId, input);
return input;
},
+ fetchDerivedArtifact: async (artifactId, mimeTypes) => {
+ signal.throwIfAborted();
+ const row = await deps.prisma.artifact.findFirst({
+ where: {
+ parentArtifactId: artifactId,
+ mimeType: { in: [...mimeTypes] },
+ spaceId: job.spaceId,
+ userId: job.userId,
+ },
+ orderBy: [{ createdAt: "desc" }, { id: "desc" }],
+ select: { id: true },
+ });
+ return row ? context.fetchArtifact(row.id) : null;
+ },
addOutput: async (file: MediaJobOutputFile) => {
signal.throwIfAborted();
const kind = attachmentKindOf(file.mimeType);
diff --git a/packages/adapters/src/media-tools.test.ts b/packages/adapters/src/media-tools.test.ts
index 29b7a38..e122d5d 100644
--- a/packages/adapters/src/media-tools.test.ts
+++ b/packages/adapters/src/media-tools.test.ts
@@ -17,6 +17,7 @@ const none: MediaCapabilities = {
edit_video: false,
export_video: false,
transcribe: false,
+ analyze_video: false,
};
const scope = {
@@ -135,6 +136,27 @@ describe("media tools", () => {
expect(start.mock.calls[1]?.[0]).toMatchObject({ continueAfter: false });
});
+ it("always wakes the bot for tools whose result is only for it", async () => {
+ const start = vi.fn(async (_input: StartMediaJobInput) => ({
+ jobId: "job-1",
+ messageId: "message-1",
+ }));
+ const result = await executeMediaTool(runtime(start), scope, "analyze_video", {
+ artifact_id: "video-1",
+ title: "Cut points",
+ continue_after: false,
+ });
+ expect(start.mock.calls[0]?.[0]).toMatchObject({
+ operation: "analyze_video",
+ input: { artifact_id: "video-1", title: "Cut points" },
+ continueAfter: true,
+ });
+ expect((result as { note: string }).note).toContain("woken");
+ expect(
+ mediaToolInputSchema({ properties: { title: { type: "string" } } }, { continueAfter: false }),
+ ).toEqual({ type: "object", properties: { title: { type: "string" } } });
+ });
+
it("returns refusals to the model and rethrows other failures", async () => {
const refused = runtime(async () => {
throw new MediaJobRejectedError("Invalid generate_image input: prompt: Required");
diff --git a/packages/adapters/src/media-tools.ts b/packages/adapters/src/media-tools.ts
index 0db6f26..aed9289 100644
--- a/packages/adapters/src/media-tools.ts
+++ b/packages/adapters/src/media-tools.ts
@@ -19,14 +19,23 @@ export const MEDIA_CONTINUE_AFTER_PROPERTY = {
},
} as const;
-/** A media tool's JSON schema: its own properties plus `continue_after`. */
-export function mediaToolInputSchema(schema: {
- properties: Record;
- required?: string[];
-}): ConnectorTool["inputSchema"] {
+/** Media tools whose result is only for the bot (no files): their jobs always wake it. */
+export const BOT_RESULT_MEDIA_TOOLS: ReadonlySet = new Set(["analyze_video"]);
+
+/**
+ * A media tool's JSON schema: its own properties plus `continue_after`, which a tool in
+ * BOT_RESULT_MEDIA_TOOLS leaves out (`continueAfter: false`).
+ */
+export function mediaToolInputSchema(
+ schema: { properties: Record; required?: string[] },
+ options: { continueAfter?: boolean } = {},
+): ConnectorTool["inputSchema"] {
return {
type: "object",
- properties: { ...schema.properties, ...MEDIA_CONTINUE_AFTER_PROPERTY },
+ properties: {
+ ...schema.properties,
+ ...(options.continueAfter === false ? {} : MEDIA_CONTINUE_AFTER_PROPERTY),
+ },
...(schema.required?.length ? { required: schema.required } : {}),
};
}
@@ -57,20 +66,20 @@ export async function executeMediaTool(
) {
if (!media) return { error: `${operation} is not available.` };
const { continue_after: continueAfter, ...input } = args;
+ const wake = continueAfter === true || BOT_RESULT_MEDIA_TOOLS.has(operation);
try {
const started = await media.start({
operation,
input,
...scope,
- continueAfter: continueAfter === true,
+ continueAfter: wake,
});
return {
jobId: started.jobId,
status: "queued",
- note:
- continueAfter === true
- ? "The job runs in the background. You are woken with its result when it ends; do not poll or wait."
- : "The job runs in the background and its card shows progress and the result to the user. Do not poll or wait.",
+ note: wake
+ ? "The job runs in the background. You are woken with its result when it ends; do not poll or wait."
+ : "The job runs in the background and its card shows progress and the result to the user. Do not poll or wait.",
};
} catch (error) {
if (error instanceof MediaJobRejectedError) return { error: error.message };
diff --git a/packages/adapters/src/media/image-operations.test.ts b/packages/adapters/src/media/image-operations.test.ts
index 88c906d..971ebe4 100644
--- a/packages/adapters/src/media/image-operations.test.ts
+++ b/packages/adapters/src/media/image-operations.test.ts
@@ -92,6 +92,7 @@ async function run(
size: (await stat(path)).size,
};
},
+ fetchDerivedArtifact: async () => null,
addOutput: async (file) => {
outputs.push(file);
const output: MediaOutput = {
diff --git a/packages/adapters/src/media/media-test-support.ts b/packages/adapters/src/media/media-test-support.ts
index f5e06ff..63d9f56 100644
--- a/packages/adapters/src/media/media-test-support.ts
+++ b/packages/adapters/src/media/media-test-support.ts
@@ -48,6 +48,8 @@ export interface TestArtifact {
name: string;
mimeType: string;
kind?: MediaOutputKind;
+ /** The artifact this one was made from, for `fetchDerivedArtifact`. */
+ parentArtifactId?: string;
}
/** A MediaJobContext over local files that records progress, outputs and skipped platforms. */
@@ -82,6 +84,16 @@ export async function fakeMediaJobContext(input: unknown, artifacts: Record {
+ // Entries are in creation order, so the last match is the newest.
+ const id = Object.keys(artifacts)
+ .filter((key) => {
+ const artifact = artifacts[key]!;
+ return artifact.parentArtifactId === artifactId && mimeTypes.includes(artifact.mimeType);
+ })
+ .at(-1);
+ return id ? context.fetchArtifact(id) : null;
+ },
addOutput: async (file): Promise => {
// Files must exist when handed over; the runner stores them before the job ends.
const size = (await stat(file.path)).size;
@@ -110,3 +122,24 @@ export async function fakeMediaJobContext(input: unknown, artifacts: Record {
+ const path = join(dir, "tone-gap-tone.mp4");
+ await runFfmpeg([
+ ...["-f", "lavfi", "-i", "color=c=red:size=320x180:rate=25:duration=2"],
+ ...["-f", "lavfi", "-i", "color=c=blue:size=320x180:rate=25:duration=2"],
+ ...["-f", "lavfi", "-i", "sine=frequency=440:sample_rate=48000:duration=1.5"],
+ ...["-f", "lavfi", "-i", "anullsrc=channel_layout=mono:sample_rate=48000"],
+ ...["-f", "lavfi", "-i", "sine=frequency=660:sample_rate=48000:duration=1.5"],
+ "-filter_complex",
+ "[0:v][1:v]concat=n=2:v=1:a=0[v];[3:a]atrim=duration=1[gap];[2:a][gap][4:a]concat=n=3:v=0:a=1[a]",
+ ...["-map", "[v]", "-map", "[a]"],
+ ...["-c:v", "libx264", "-preset", "ultrafast", "-pix_fmt", "yuv420p", "-c:a", "aac"],
+ `file:${path}`,
+ ]);
+ return path;
+}
diff --git a/packages/adapters/src/media/silence.test.ts b/packages/adapters/src/media/silence.test.ts
new file mode 100644
index 0000000..64380e3
--- /dev/null
+++ b/packages/adapters/src/media/silence.test.ts
@@ -0,0 +1,320 @@
+import { execFile } from "node:child_process";
+import { mkdtemp, rm } from "node:fs/promises";
+import { tmpdir } from "node:os";
+import { join } from "node:path";
+import { promisify } from "node:util";
+import type { EditVideoStep } from "@rakazo/contracts";
+import { EditVideoInputSchema } from "@rakazo/contracts";
+import { afterAll, beforeAll, describe, expect, it } from "vitest";
+import { detectFfmpeg } from "./ffmpeg.js";
+import { createCutTestClip, fakeMediaJobContext } from "./media-test-support.js";
+import {
+ MAX_SPEECH_SEGMENTS,
+ parseFrameMetadata,
+ parseSilences,
+ silenceDetectFilters,
+ speechSegments,
+} from "./silence.js";
+import type { PlanInput } from "./video-edit-plan.js";
+import { planVideoEdit } from "./video-edit-plan.js";
+import { createVideoOperations } from "./video-operations.js";
+
+const ffmpeg = await detectFfmpeg();
+
+// Printed by `silencedetect=noise=-35dB:duration=0.6,ametadata=mode=print:file=…` (ffmpeg 6.1)
+// for 1.5 s tone, 1 s silence, 1.5 s tone; then for silence, tone, silence up to the end.
+const TONE_GAP_TONE = `frame:98 pts:100352 pts_time:2.09067
+lavfi.silence_start=1.49996
+frame:117 pts:119808 pts_time:2.496
+lavfi.silence_end=2.50004
+lavfi.silence_duration=1.00008
+`;
+const SILENT_EDGES = `frame:14 pts:28672 pts_time:0.597333
+lavfi.silence_start=0
+frame:46 pts:94208 pts_time:1.96267
+lavfi.silence_end=2.00008
+lavfi.silence_duration=2.00008
+frame:84 pts:172032 pts_time:3.584
+lavfi.silence_start=2.99996
+`;
+
+const defaults = { padding: 0.12, thresholdDb: -35 };
+
+describe("silence detection output", () => {
+ it("reads printed frame metadata", () => {
+ expect(parseFrameMetadata(TONE_GAP_TONE)).toEqual([
+ { time: 2.09067, values: { "lavfi.silence_start": "1.49996" } },
+ {
+ time: 2.496,
+ values: { "lavfi.silence_end": "2.50004", "lavfi.silence_duration": "1.00008" },
+ },
+ ]);
+ expect(parseFrameMetadata("frame:1 pts:N/A pts_time:N/A\nlavfi.x=1\n")).toEqual([]);
+ });
+
+ it("pairs starts with ends and runs a silence without end to the end of the audio", () => {
+ expect(parseSilences(TONE_GAP_TONE, 4)).toEqual([{ start: 1.49996, end: 2.50004 }]);
+ expect(parseSilences(SILENT_EDGES, 4.5)).toEqual([
+ { start: 0, end: 2.00008 },
+ { start: 2.99996, end: 4.5 },
+ ]);
+ // A probe a little shorter than the decoded audio clamps the ranges to the timeline.
+ expect(parseSilences(SILENT_EDGES, 2)).toEqual([{ start: 0, end: 2 }]);
+ expect(parseSilences("", 4)).toEqual([]);
+ });
+
+ it("builds the filters from validated numbers and a generated file name", () => {
+ expect(silenceDetectFilters({ thresholdDb: -35, minSilence: 0.6 }, "silence-2.txt")).toEqual([
+ "silencedetect=noise=-35dB:duration=0.6",
+ "ametadata=mode=print:file=silence-2.txt",
+ ]);
+ });
+});
+
+describe("speech segments", () => {
+ it("keeps what is not silent, padded on both sides", () => {
+ expect(speechSegments([{ start: 1.5, end: 2.5 }], 4, defaults)).toEqual([
+ { start: 0, end: 1.62 },
+ { start: 2.38, end: 4 },
+ ]);
+ });
+
+ it("clamps padding to the timeline and drops silent edges", () => {
+ const segments = speechSegments(
+ [
+ { start: 0, end: 2 },
+ { start: 3, end: 4.5 },
+ ],
+ 4.5,
+ defaults,
+ );
+ expect(segments).toHaveLength(1);
+ expect(segments?.[0]?.start).toBeCloseTo(1.88);
+ expect(segments?.[0]?.end).toBeCloseTo(3.12);
+ expect(speechSegments([{ start: 3.95, end: 4 }], 4, { ...defaults, padding: 1 })).toBeNull();
+ });
+
+ it("merges speech where the padding closes a pause, and overlapping silences", () => {
+ expect(
+ speechSegments(
+ [
+ { start: 1, end: 1.2 },
+ { start: 2, end: 3 },
+ { start: 2.5, end: 3.5 },
+ ],
+ 5,
+ defaults,
+ ),
+ ).toEqual([
+ { start: 0, end: 2.12 },
+ { start: 3.38, end: 5 },
+ ]);
+ });
+
+ it("is null when nothing is silent and fails when nothing would be left", () => {
+ expect(speechSegments([], 4, defaults)).toBeNull();
+ expect(() => speechSegments([{ start: 0, end: 4 }], 4, defaults)).toThrow(
+ "remove_silence found nothing louder than -35 dB, so nothing would be left.",
+ );
+ // Blips shorter than 0.1 s without padding are noise, not speech.
+ expect(() =>
+ speechSegments(
+ [
+ { start: 0, end: 2 },
+ { start: 2.05, end: 4 },
+ ],
+ 4,
+ { ...defaults, padding: 0 },
+ ),
+ ).toThrow("nothing would be left");
+ });
+
+ it("keeps the longest pauses cut when there would be too many pieces", () => {
+ const silences = Array.from({ length: 5 }, (_, index) => ({
+ start: index * 2 + 1,
+ end: index * 2 + 1 + (index === 2 ? 0.9 : 0.5),
+ }));
+ expect(speechSegments(silences, 11, { padding: 0, thresholdDb: -35, maxSegments: 2 })).toEqual([
+ { start: 0, end: 5 },
+ { start: 5.9, end: 11 },
+ ]);
+ expect(MAX_SPEECH_SEGMENTS).toBe(200);
+ });
+});
+
+const source: PlanInput = {
+ path: "/jobs/1/input-source",
+ mimeType: "video/mp4",
+ name: "talk.mp4",
+ probe: {
+ durationMs: 10_000,
+ video: {
+ codec: "h264",
+ width: 1920,
+ height: 1080,
+ rotation: 0,
+ fps: 25,
+ sampleAspectRatio: 1,
+ },
+ audio: { codec: "aac", sampleRate: 48_000, channels: 2 },
+ hasAudio: true,
+ },
+};
+
+function steps(list: unknown[]): EditVideoStep[] {
+ return EditVideoInputSchema.parse({ artifact_id: "a", title: "Talk", steps: list }).steps;
+}
+
+function graphOf(args: string[]): string {
+ return args[args.indexOf("-filter_complex") + 1]!;
+}
+
+describe("remove_silence planning", () => {
+ it("fills the step's defaults", () => {
+ expect(steps([{ op: "remove_silence" }])).toEqual([
+ { op: "remove_silence", threshold_db: -35, min_silence: 0.6, padding: 0.12 },
+ ]);
+ });
+
+ it("detects silence on the audio the steps before it leave, into a null output", () => {
+ const [trim] = steps([{ op: "trim", start: 2, end: 8 }]);
+ const plan = planVideoEdit({
+ source,
+ steps: [trim!, { op: "speed", factor: 2 }],
+ analyzeAudio: silenceDetectFilters({ thresholdDb: -35, minSilence: 0.6 }, "silence-2.txt"),
+ outputPath: "",
+ });
+ expect(plan.args).toEqual([
+ ...["-ss", "2", "-t", "6", "-protocol_whitelist", "file", "-f", "mov"],
+ ...["-i", "file:/jobs/1/input-source"],
+ "-filter_complex",
+ "[0:a:0]atempo=2,silencedetect=noise=-35dB:duration=0.6,ametadata=mode=print:file=silence-2.txt[a1]",
+ ...["-map", "[a1]", "-f", "null", "-"],
+ ]);
+ expect(plan).toMatchObject({ kind: "audio", durationMs: 3000 });
+ });
+
+ it("cuts the detected pauses from picture and sound like a keep step", () => {
+ const list = steps([{ op: "remove_silence" }, { op: "reframe", preset: "vertical" }]);
+ const plan = planVideoEdit({
+ source,
+ steps: list,
+ speech: new Map([
+ [
+ list[0]!,
+ [
+ { start: 0, end: 1.62 },
+ { start: 2.38, end: 10 },
+ ],
+ ],
+ ]),
+ outputPath: "/jobs/1/output.mp4",
+ });
+ const graph = graphOf(plan.args);
+ expect(graph).toContain("[0:v:0]split=2[v1][v2]");
+ expect(graph).toContain("[v1]trim=start=0:end=1.62,setpts=PTS-STARTPTS[v3]");
+ expect(graph).toContain("[v2]trim=start=2.38:end=10,setpts=PTS-STARTPTS[v4]");
+ expect(graph).toContain("[0:a:0]asplit=2[a5][a6]");
+ expect(graph).toContain("[a5]atrim=start=0:end=1.62,asetpts=PTS-STARTPTS[a7]");
+ expect(graph).toContain("concat=n=2:v=1:a=1");
+ expect(plan).toMatchObject({ kind: "video", durationMs: 9240, width: 1080, height: 1920 });
+ });
+
+ it("leaves the timeline alone when nothing is silent and needs detection first", () => {
+ const list = steps([{ op: "remove_silence" }]);
+ const untouched = planVideoEdit({
+ source,
+ steps: list,
+ speech: new Map([[list[0]!, null]]),
+ outputPath: "/jobs/1/output.mp4",
+ });
+ expect(graphOf(untouched.args)).not.toContain("trim");
+ expect(untouched.durationMs).toBe(10_000);
+ expect(() => planVideoEdit({ source, steps: list, outputPath: "/jobs/1/output.mp4" })).toThrow(
+ "Silence was not detected before planning.",
+ );
+ });
+
+ it("needs audio to detect silence in", () => {
+ expect(() =>
+ planVideoEdit({
+ source,
+ steps: [{ op: "mute" }],
+ analyzeAudio: ["anullsink"],
+ outputPath: "",
+ }),
+ ).toThrow("talk.mp4 has no audio.");
+ });
+});
+
+/** Seconds of each stream of a file, by codec type. */
+async function streamSeconds(ffprobePath: string, path: string) {
+ const { stdout } = await promisify(execFile)(ffprobePath, [
+ ...["-v", "error", "-show_entries", "stream=codec_type,duration", "-of", "json", path],
+ ]);
+ const { streams } = JSON.parse(stdout) as {
+ streams: { codec_type: string; duration: string }[];
+ };
+ return Object.fromEntries(streams.map((stream) => [stream.codec_type, Number(stream.duration)]));
+}
+
+// CI runners may not have ffmpeg; these run wherever it is installed.
+describe.skipIf(!ffmpeg)("remove_silence with ffmpeg", () => {
+ let dir: string;
+ let clip: string;
+
+ beforeAll(async () => {
+ dir = await mkdtemp(join(tmpdir(), "evoo-silence-test-"));
+ clip = await createCutTestClip(dir);
+ });
+
+ afterAll(async () => {
+ if (dir) await rm(dir, { recursive: true, force: true });
+ });
+
+ async function edit(list: unknown[]) {
+ const [registration] = createVideoOperations();
+ const job = await fakeMediaJobContext(
+ registration!.inputSchema.parse({ artifact_id: "talk", title: "Ohne Pausen", steps: list }),
+ { talk: { path: clip, name: "talk.mp4", mimeType: "video/mp4" } },
+ );
+ try {
+ await registration!.handler.run(job.context);
+ const [output] = job.outputs;
+ // The fake context hands the file over in place; measure it before the temp dir goes.
+ return {
+ output: output!,
+ streams: await streamSeconds(ffmpeg!.ffprobePath, output!.path),
+ };
+ } finally {
+ await rm(job.tempDir, { recursive: true, force: true });
+ }
+ }
+
+ it("cuts the pause and keeps picture and sound the same length", async () => {
+ const { output, streams } = await edit([{ op: "remove_silence", padding: 0.1 }]);
+ // 4 s minus the 1 s pause, of which 0.1 s stays on each side.
+ expect(Math.abs(output.durationMs! - 3200)).toBeLessThan(100);
+ expect(Math.abs(streams.video! - 3.2)).toBeLessThan(0.1);
+ expect(Math.abs(streams.video! - streams.audio!)).toBeLessThan(0.08);
+ });
+
+ it("detects silence on the timeline the steps before it leave", async () => {
+ const { output, streams } = await edit([
+ { op: "trim", start: 1, end: 4 },
+ { op: "remove_silence", padding: 0.1 },
+ ]);
+ // In the trimmed 3 s the pause runs from 0.5 s to 1.5 s; 0.2 s of it stays.
+ expect(Math.abs(output.durationMs! - 2200)).toBeLessThan(100);
+ expect(Math.abs(streams.video! - streams.audio!)).toBeLessThan(0.08);
+ });
+
+ it("fails clearly when everything is silent", async () => {
+ await expect(edit([{ op: "mute" }, { op: "remove_silence" }])).rejects.toThrow(
+ "talk.mp4 has no audio.",
+ );
+ await expect(edit([{ op: "remove_silence", threshold_db: -10 }])).rejects.toThrow(
+ "remove_silence found nothing louder than -10 dB",
+ );
+ });
+});
diff --git a/packages/adapters/src/media/silence.ts b/packages/adapters/src/media/silence.ts
new file mode 100644
index 0000000..4f9435a
--- /dev/null
+++ b/packages/adapters/src/media/silence.ts
@@ -0,0 +1,211 @@
+import { readFile } from "node:fs/promises";
+import { join } from "node:path";
+import type { EditVideoStep } from "@rakazo/contracts";
+import { runFfmpeg } from "./ffmpeg.js";
+import type { VideoEditPlanRequest } from "./video-edit-plan.js";
+import { planVideoEdit } from "./video-edit-plan.js";
+
+/**
+ * Silence detection for `remove_silence` and `analyze_video`. ffmpeg's silencedetect marks
+ * silences in frame metadata, which `ametadata` prints to a file in the job's directory; the
+ * file holds nothing but frame times and numbers, unlike stderr with its interleaved logs.
+ */
+
+export interface TimeRange {
+ start: number;
+ end: number;
+}
+
+/** Shortest piece of speech kept on its own; shorter blips between two pauses are noise. */
+const MIN_SPEECH_SECONDS = 0.1;
+/** Most pieces one remove_silence step keeps, so the filter graph stays a sensible size. */
+export const MAX_SPEECH_SEGMENTS = 200;
+/** Closer to the end of the timeline than this counts as reaching it. */
+const EDGE_SECONDS = 0.01;
+
+/** One frame of `metadata=mode=print` output: its time and the values set on it. */
+export interface FrameMetadata {
+ time: number;
+ values: Record;
+}
+
+/**
+ * Parses the output of the `metadata`/`ametadata` filters in print mode:
+ * `frame:N pts:P pts_time:T` lines, each followed by `key=value` lines.
+ */
+export function parseFrameMetadata(text: string): FrameMetadata[] {
+ const frames: FrameMetadata[] = [];
+ let current: FrameMetadata | undefined;
+ for (const raw of text.split("\n")) {
+ const line = raw.trim();
+ const frame = /^frame:\s*\d+\s+pts:\s*\S+\s+pts_time:\s*(\S+)/.exec(line);
+ if (frame) {
+ const time = Number(frame[1]);
+ current = Number.isFinite(time) ? { time, values: {} } : undefined;
+ if (current) frames.push(current);
+ continue;
+ }
+ const separator = line.indexOf("=");
+ if (current && separator > 0) {
+ current.values[line.slice(0, separator)] = line.slice(separator + 1);
+ }
+ }
+ return frames;
+}
+
+/** Filters that detect silence and print it to `file`, a generated name in ffmpeg's cwd. */
+export function silenceDetectFilters(
+ options: { thresholdDb: number; minSilence: number },
+ file: string,
+): string[] {
+ return [
+ `silencedetect=noise=${number(options.thresholdDb)}dB:duration=${number(options.minSilence)}`,
+ `ametadata=mode=print:file=${file}`,
+ ];
+}
+
+/**
+ * Silences from silencedetect's printed metadata, sorted and clamped to `[0, duration]`. A
+ * silence still running when the audio ends has no end mark and lasts to the end.
+ */
+export function parseSilences(text: string, duration: number): TimeRange[] {
+ const silences: TimeRange[] = [];
+ let start: number | undefined;
+ const add = (from: number, to: number) => {
+ const range = { start: clamp(from, 0, duration), end: clamp(to, 0, duration) };
+ if (range.end > range.start) silences.push(range);
+ };
+ for (const frame of parseFrameMetadata(text)) {
+ const begins = Number(frame.values["lavfi.silence_start"]);
+ const ends = Number(frame.values["lavfi.silence_end"]);
+ // One frame can end a silence and start the next one; the end belongs to the earlier one.
+ if (frame.values["lavfi.silence_end"] !== undefined && Number.isFinite(ends)) {
+ add(start ?? 0, ends);
+ start = undefined;
+ }
+ if (frame.values["lavfi.silence_start"] !== undefined && Number.isFinite(begins)) {
+ start = begins;
+ }
+ }
+ if (start !== undefined) add(start, duration);
+ return mergeRanges(silences);
+}
+
+/**
+ * The parts of `[0, duration]` that are not silent, each widened by `padding` on both sides,
+ * clamped to the timeline and merged where the padding closes a gap. Null when nothing would
+ * be cut; throws when nothing would be left.
+ */
+export function speechSegments(
+ silences: readonly TimeRange[],
+ duration: number,
+ options: { padding: number; thresholdDb: number; maxSegments?: number },
+): TimeRange[] | null {
+ const speech: TimeRange[] = [];
+ let cursor = 0;
+ for (const silence of mergeRanges(silences)) {
+ if (silence.start > cursor) speech.push({ start: cursor, end: silence.start });
+ cursor = Math.max(cursor, silence.end);
+ }
+ if (cursor < duration) speech.push({ start: cursor, end: duration });
+ const padded = mergeRanges(
+ speech.map((range) => ({
+ start: clamp(range.start - options.padding, 0, duration),
+ end: clamp(range.end + options.padding, 0, duration),
+ })),
+ ).filter((range) => range.end - range.start >= MIN_SPEECH_SECONDS);
+ if (padded.length === 0) {
+ throw new Error(
+ `remove_silence found nothing louder than ${number(options.thresholdDb)} dB, so nothing would be left.`,
+ );
+ }
+ const kept = bridgeShortestGaps(padded, options.maxSegments ?? MAX_SPEECH_SEGMENTS);
+ const [only] = kept;
+ if (
+ kept.length === 1 &&
+ only &&
+ only.start <= EDGE_SECONDS &&
+ only.end >= duration - EDGE_SECONDS
+ ) {
+ return null;
+ }
+ return kept;
+}
+
+/**
+ * Detects the silence each remove_silence step sees, in step order: one audio-only ffmpeg pass
+ * over the timeline the steps before it leave, so trims, appends and speed changes before it
+ * are taken into account. The result is the `speech` map `planVideoEdit` cuts with.
+ */
+export async function detectSpeech(
+ request: Omit,
+ options: { workDir: string; signal: AbortSignal },
+): Promise