Add remove_silence step to edit_video

Talking-head clips are mostly cut by hand to drop pauses, and the bot had
no way to find them: it could only keep segments it guessed. The new
remove_silence step finds every pause quieter than threshold_db (default
-35 dB) and at least min_silence long (default 0.6 s), keeps padding
(default 0.12 s) on each side so word onsets survive, and cuts the rest
through the existing keep-segments graph, so picture and sound stay in
sync.

Steps run in order, so silence is detected on the timeline the steps
before it leave: the planner gets an analysis mode that sends that audio
through silencedetect into a null output, and ametadata prints the marks
to a file in the job directory. The detected speech is handed back to the
planner per step. Nothing silent makes the step a no-op; nothing left
fails with a clear error. At most 200 pieces are kept, bridging the
shortest pauses first, so the filter graph stays a sensible size.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MRtGG9ARfqqhma3t85Ro2u
This commit is contained in:
Claude 2026-10-05 09:52:53 +00:00
parent 404a1d1782
commit 6ba9cef03f
No known key found for this signature in database
10 changed files with 665 additions and 3 deletions

View File

@ -559,7 +559,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),
},
{

View File

@ -104,3 +104,24 @@ export async function fakeMediaJobContext(input: unknown, artifacts: Record<stri
};
return { context, controller, progress, outputs, tempDir };
}
/**
* A 4 s talking-head stand-in at 320×180, 25 fps: red for 2 s then a hard cut to blue, over a
* 48 kHz tone that pauses from 1.5 s to 2.5 s.
*/
export async function createCutTestClip(dir: string): Promise<string> {
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;
}

View File

@ -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",
);
});
});

View File

@ -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<string, string>;
}
/**
* 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<VideoEditPlanRequest, "speech" | "analyzeAudio" | "outputPath">,
options: { workDir: string; signal: AbortSignal },
): Promise<Map<EditVideoStep, TimeRange[] | null>> {
const speech = new Map<EditVideoStep, TimeRange[] | null>();
for (const [index, step] of request.steps.entries()) {
if (step.op !== "remove_silence") continue;
const file = `silence-${index}.txt`;
const plan = planVideoEdit({
...request,
steps: request.steps.slice(0, index),
speech,
analyzeAudio: silenceDetectFilters(
{ thresholdDb: step.threshold_db, minSilence: step.min_silence },
file,
),
outputPath: "",
});
await runFfmpeg(plan.args, {
signal: options.signal,
cwd: options.workDir,
durationMs: plan.durationMs,
});
const duration = plan.durationMs / 1000;
const silences = parseSilences(await readFile(join(options.workDir, file), "utf8"), duration);
speech.set(
step,
speechSegments(silences, duration, {
padding: step.padding,
thresholdDb: step.threshold_db,
}),
);
}
return speech;
}
/** Sorted, with ranges that overlap or touch merged. */
function mergeRanges(ranges: readonly TimeRange[]): TimeRange[] {
const sorted = [...ranges].sort((a, b) => a.start - b.start);
const merged: TimeRange[] = [];
for (const range of sorted) {
const last = merged.at(-1);
if (last && range.start <= last.end) last.end = Math.max(last.end, range.end);
else merged.push({ ...range });
}
return merged;
}
/** At most `max` segments: the shortest gaps between them stay in until few enough remain. */
function bridgeShortestGaps(segments: readonly TimeRange[], max: number): TimeRange[] {
if (segments.length <= max) return [...segments];
const gaps = segments
.slice(1)
.map((segment, index) => ({ index, length: segment.start - segments[index]!.end }))
.sort((a, b) => a.length - b.length || a.index - b.index);
const bridged = new Set(gaps.slice(0, segments.length - max).map((gap) => gap.index));
const kept: TimeRange[] = [];
for (const [index, segment] of segments.entries()) {
const last = kept.at(-1);
if (last && bridged.has(index - 1)) last.end = segment.end;
else kept.push({ ...segment });
}
return kept;
}
function clamp(value: number, min: number, max: number): number {
return Math.min(max, Math.max(min, value));
}
function number(value: number): string {
return String(Number(value.toFixed(6)));
}

View File

@ -22,6 +22,16 @@ export interface VideoEditPlanRequest {
clips?: ReadonlyMap<string, PlanInput>;
/** Subtitle files named by subtitles steps, by artifact id: safe names relative to ffmpeg's cwd. */
subtitleFiles?: ReadonlyMap<string, string>;
/**
* What each remove_silence step keeps, on its own input timeline: speech segments, or null
* when nothing is silent. Detected beforehand by an analysis plan (see silence.ts).
*/
speech?: ReadonlyMap<EditVideoStep, readonly { start: number; end: number }[] | null>;
/**
* Analysis instead of an encode: the audio left by the steps runs through these filters
* into a null output, so they can report on it (silencedetect writing a file, say).
*/
analyzeAudio?: readonly string[];
outputPath: string;
}
@ -106,7 +116,8 @@ export function planVideoEdit(request: VideoEditPlanRequest): VideoEditPlan {
const video = source.probe.video;
if (!video) throw new Error(`${source.name} has no video.`);
const sourceSeconds = source.probe.durationMs / 1000;
const audioOnly = request.steps.at(-1)?.op === "extract_audio";
const audioOnly =
request.steps.at(-1)?.op === "extract_audio" || request.analyzeAudio !== undefined;
const graph = new FilterGraph();
const inputs: string[][] = [];
const addInput = (input: PlanInput, seek?: { start: number; end: number }) => {
@ -221,6 +232,12 @@ export function planVideoEdit(request: VideoEditPlanRequest): VideoEditPlan {
case "poster":
posterAt = step.at;
break;
case "remove_silence": {
const speech = request.speech?.get(step);
if (speech === undefined) throw new Error("Silence was not detected before planning.");
if (speech) keepSegments(graph, timeline, speech);
break;
}
}
}
@ -233,6 +250,24 @@ export function planVideoEdit(request: VideoEditPlanRequest): VideoEditPlan {
const output = `file:${request.outputPath}`;
if (audioOnly || !timeline.video) {
if (!timeline.audio) throw new Error(`${source.name} has no audio.`);
if (request.analyzeAudio) {
timeline.audio.filters.push(...request.analyzeAudio);
const audio = graph.flush(timeline.audio, "a");
return {
args: [
...inputs.flat(),
"-filter_complex",
graph.toString(),
"-map",
`[${audio}]`,
"-f",
"null",
"-",
],
kind: "audio",
durationMs,
};
}
timeline.audio.filters.push(AUDIO_FORMAT);
const audio = graph.flush(timeline.audio, "a");
return {

View File

@ -17,6 +17,7 @@ import {
renderPoster,
runFfmpeg,
} from "./ffmpeg.js";
import { detectSpeech } from "./silence.js";
import type { PlanInput, VideoEditPlan } from "./video-edit-plan.js";
import { planVideoEdit } from "./video-edit-plan.js";
@ -129,7 +130,18 @@ async function runEditVideo(context: MediaJobContext): Promise<MediaOperationRes
}
const audioOnly = input.steps.at(-1)?.op === "extract_audio";
const outputPath = join(workDir, audioOnly ? "output.m4a" : "output.mp4");
const plan = planVideoEdit({ source, steps: input.steps, clips, subtitleFiles, outputPath });
const speech = await detectSpeech(
{ source, steps: input.steps, clips, subtitleFiles },
{ workDir, signal: context.signal },
);
const plan = planVideoEdit({
source,
steps: input.steps,
clips,
subtitleFiles,
speech,
outputPath,
});
const output = await encode(context, plan, outputPath, workDir, progress.range(0, 1), {
name: `${name}${audioOnly ? ".m4a" : ".mp4"}`,
parentArtifactId: source.artifactId,

View File

@ -15,6 +15,7 @@ export * from "./integration-settings.js";
export * from "./local-settings.js";
export * from "./mcp.js";
export * from "./media.js";
export * from "./media-analysis.js";
export * from "./media-generation.js";
export * from "./media-jobs.js";
export * from "./media-settings.js";

View File

@ -0,0 +1,38 @@
import { describe, expect, it } from "vitest";
import { SILENCE_DEFAULTS } from "./media-analysis.js";
import { EditVideoInputSchema } from "./media-video.js";
const edit = (steps: unknown[]) =>
EditVideoInputSchema.safeParse({ artifact_id: "a", title: "Reel", steps });
describe("remove_silence step", () => {
it("fills its defaults and parses again unchanged", () => {
const parsed = EditVideoInputSchema.parse({
artifact_id: "a",
title: "Reel",
steps: [
{ op: "trim", start: 0, end: 30 },
{ op: "remove_silence", padding: 0 },
],
});
expect(parsed.steps[1]).toEqual({
op: "remove_silence",
threshold_db: SILENCE_DEFAULTS.thresholdDb,
min_silence: SILENCE_DEFAULTS.minSilence,
padding: 0,
});
expect(EditVideoInputSchema.parse(parsed)).toEqual(parsed);
});
it("keeps its settings in a useful range", () => {
expect(edit([{ op: "remove_silence", threshold_db: -61 }]).success).toBe(false);
expect(edit([{ op: "remove_silence", threshold_db: -5 }]).success).toBe(false);
expect(edit([{ op: "remove_silence", min_silence: 0.1 }]).success).toBe(false);
expect(edit([{ op: "remove_silence", min_silence: 6 }]).success).toBe(false);
expect(edit([{ op: "remove_silence", padding: -0.1 }]).success).toBe(false);
expect(edit([{ op: "remove_silence", padding: 1.5 }]).success).toBe(false);
expect(
edit([{ op: "remove_silence", threshold_db: -60, min_silence: 5, padding: 1 }]).success,
).toBe(true);
});
});

View File

@ -0,0 +1,22 @@
import * as z from "zod";
/** Cut-point finding for talking-head and social video: the `remove_silence` step of `edit_video`. */
/** Defaults of `remove_silence`. */
export const SILENCE_DEFAULTS = {
/** Quieter than this (dBFS) counts as silence: below speech, above a typical noise floor. */
thresholdDb: -35,
/** Shorter pauses are the rhythm of speech and stay. */
minSilence: 0.6,
/** Kept on both sides of what is cut, so breaths and word onsets are not clipped. */
padding: 0.12,
} as const;
/** Cuts every pause and keeps sound and picture in sync, like a keep step over the speech. */
export const RemoveSilenceStepSchema = z.object({
op: z.literal("remove_silence"),
threshold_db: z.number().min(-60).max(-10).default(SILENCE_DEFAULTS.thresholdDb),
min_silence: z.number().min(0.2).max(5).default(SILENCE_DEFAULTS.minSilence),
padding: z.number().min(0).max(1).default(SILENCE_DEFAULTS.padding),
});
export type RemoveSilenceStep = z.infer<typeof RemoveSilenceStepSchema>;

View File

@ -1,6 +1,7 @@
import * as z from "zod";
import { Id } from "./ids.js";
import { MediaFitSchema, MediaPresetIdSchema } from "./media.js";
import { RemoveSilenceStepSchema } from "./media-analysis.js";
/**
* Inputs of the `edit_video` and `export_video` media operations, which are also the arguments
@ -39,6 +40,7 @@ export const EditVideoStepSchema = z.discriminatedUnion("op", [
z.object({ op: z.literal("extract_audio") }),
/** Cover frame at this second of the result; as the only kind of step, the result is that image. */
z.object({ op: z.literal("poster"), at: Seconds }),
RemoveSilenceStepSchema,
]);
export type EditVideoStep = z.infer<typeof EditVideoStepSchema>;