chore: import upstream snapshot with attribution
This commit is contained in:
@@ -0,0 +1,3 @@
|
||||
from .src.python.ffmpeg_tool import FfmpegTool
|
||||
|
||||
__all__ = ["FfmpegTool"]
|
||||
@@ -0,0 +1 @@
|
||||
{}
|
||||
@@ -0,0 +1,508 @@
|
||||
import { Tool } from '@sdk/base-tool'
|
||||
import { ToolkitConfig } from '@sdk/toolkit-config'
|
||||
|
||||
const DEFAULT_SETTINGS: Record<string, unknown> = {}
|
||||
const REQUIRED_SETTINGS: string[] = []
|
||||
|
||||
export default class FfmpegTool extends Tool {
|
||||
private static readonly TOOLKIT = 'video_streaming'
|
||||
private readonly config: ReturnType<typeof ToolkitConfig.load>
|
||||
|
||||
constructor() {
|
||||
super()
|
||||
// Load configuration from central toolkits directory
|
||||
this.config = ToolkitConfig.load(FfmpegTool.TOOLKIT, this.toolName)
|
||||
const toolSettings = ToolkitConfig.loadToolSettings(
|
||||
FfmpegTool.TOOLKIT,
|
||||
this.toolName,
|
||||
DEFAULT_SETTINGS
|
||||
)
|
||||
this.settings = toolSettings
|
||||
this.requiredSettings = REQUIRED_SETTINGS
|
||||
this.checkRequiredSettings(this.toolName)
|
||||
}
|
||||
|
||||
get toolName(): string {
|
||||
return 'ffmpeg'
|
||||
}
|
||||
|
||||
get toolkit(): string {
|
||||
return FfmpegTool.TOOLKIT
|
||||
}
|
||||
|
||||
get description(): string {
|
||||
return this.config['description']
|
||||
}
|
||||
|
||||
/**
|
||||
* Get global FFmpeg arguments to hide banner and set log level to error
|
||||
*/
|
||||
private getGlobalArgs(): string[] {
|
||||
return ['-hide_banner', '-loglevel', 'error']
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts a video file to a different format.
|
||||
* @param inputPath The file path of the video to be converted.
|
||||
* @param outputPath The desired file path for the converted video.
|
||||
* @returns A promise that resolves with the path to the converted video file.
|
||||
*/
|
||||
async convertVideoFormat(
|
||||
inputPath: string,
|
||||
outputPath: string
|
||||
): Promise<string> {
|
||||
try {
|
||||
await this.executeCommand({
|
||||
binaryName: 'ffmpeg',
|
||||
args: [...this.getGlobalArgs(), '-i', inputPath, outputPath],
|
||||
options: { sync: true }
|
||||
})
|
||||
|
||||
return outputPath
|
||||
} catch (error: unknown) {
|
||||
throw new Error(`Video conversion failed: ${(error as Error).message}`)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Extracts the audio track from a video file and saves it as a separate audio file.
|
||||
* @param videoPath The file path of the video from which to extract audio.
|
||||
* @param audioPath The desired file path for the extracted audio.
|
||||
* @returns A promise that resolves with the path to the extracted audio file.
|
||||
*/
|
||||
async extractAudio(videoPath: string, audioPath: string): Promise<string> {
|
||||
try {
|
||||
// Keep it simple: do not force codec/bitrate. Let ffmpeg choose defaults based on extension.
|
||||
// Add -progress to emit periodic key=value lines we can log as progress.
|
||||
const args = [
|
||||
...this.getGlobalArgs(),
|
||||
'-y',
|
||||
'-i',
|
||||
videoPath,
|
||||
'-vn',
|
||||
// Progress to stderr so we can parse without interfering with stdout JSON
|
||||
'-progress',
|
||||
'pipe:2',
|
||||
audioPath
|
||||
]
|
||||
|
||||
await this.executeCommand({
|
||||
binaryName: 'ffmpeg',
|
||||
args,
|
||||
options: { sync: false },
|
||||
onOutput: (data: string, isError?: boolean) => {
|
||||
// Parse ffmpeg -progress key=value lines from stderr
|
||||
if (!isError) return
|
||||
const lines = data.split('\n')
|
||||
for (const line of lines) {
|
||||
const trimmed = line.trim()
|
||||
if (!trimmed || !trimmed.includes('=')) continue
|
||||
const [key, value] = trimmed.split('=')
|
||||
if (!key || value === undefined) continue
|
||||
|
||||
// Log some useful progress keys
|
||||
if (key === 'progress') {
|
||||
this.log(`ffmpeg progress: ${value}`)
|
||||
} else if (key === 'out_time_ms') {
|
||||
const ms = parseInt(value, 10)
|
||||
if (!Number.isNaN(ms)) {
|
||||
const seconds = Math.floor(ms / 1_000_000)
|
||||
this.log(`processed_time_seconds=${seconds}`)
|
||||
}
|
||||
} else if (key === 'speed') {
|
||||
this.log(`speed=${value}`)
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
return audioPath
|
||||
} catch (error: unknown) {
|
||||
throw new Error(`Audio extraction failed: ${(error as Error).message}`)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Trims a media (video or audio) file to a specified duration.
|
||||
* @param inputPath The file path of the media to be trimmed.
|
||||
* @param outputPath The desired file path for the trimmed media.
|
||||
* @param startTime The start time for the trim, formatted as HH:MM:SS.
|
||||
* @param endTime The end time for the trim, formatted as HH:MM:SS.
|
||||
* @returns A promise that resolves with the path to the trimmed media file.
|
||||
*/
|
||||
async trimMedia(
|
||||
inputPath: string,
|
||||
outputPath: string,
|
||||
startTime: string,
|
||||
endTime: string
|
||||
): Promise<string> {
|
||||
try {
|
||||
await this.executeCommand({
|
||||
binaryName: 'ffmpeg',
|
||||
args: [
|
||||
...this.getGlobalArgs(),
|
||||
'-i',
|
||||
inputPath,
|
||||
'-ss',
|
||||
startTime,
|
||||
'-to',
|
||||
endTime,
|
||||
'-c',
|
||||
'copy',
|
||||
outputPath
|
||||
],
|
||||
options: { sync: true }
|
||||
})
|
||||
|
||||
return outputPath
|
||||
} catch (error: unknown) {
|
||||
throw new Error(`Video trimming failed: ${(error as Error).message}`)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Resizes a video to the specified dimensions.
|
||||
* @param inputPath The file path of the video to be resized.
|
||||
* @param outputPath The desired file path for the resized video.
|
||||
* @param width The target width of the video in pixels.
|
||||
* @param height The target height of the video in pixels.
|
||||
* @returns A promise that resolves with the path to the resized video file.
|
||||
*/
|
||||
async resizeVideo(
|
||||
inputPath: string,
|
||||
outputPath: string,
|
||||
width: number,
|
||||
height: number
|
||||
): Promise<string> {
|
||||
try {
|
||||
await this.executeCommand({
|
||||
binaryName: 'ffmpeg',
|
||||
args: [
|
||||
...this.getGlobalArgs(),
|
||||
'-i',
|
||||
inputPath,
|
||||
'-vf',
|
||||
`scale=${width}:${height}`,
|
||||
outputPath
|
||||
],
|
||||
options: { sync: true }
|
||||
})
|
||||
|
||||
return outputPath
|
||||
} catch (error: unknown) {
|
||||
throw new Error(`Video resizing failed: ${(error as Error).message}`)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Merges a video file with a separate audio file.
|
||||
* @param videoPath The file path of the video file.
|
||||
* @param audioPath The file path of the audio file.
|
||||
* @param outputPath The desired file path for the combined video and audio.
|
||||
* @returns A promise that resolves with the path to the merged video file.
|
||||
*/
|
||||
async combineVideoAndAudio(
|
||||
videoPath: string,
|
||||
audioPath: string,
|
||||
outputPath: string
|
||||
): Promise<string> {
|
||||
try {
|
||||
await this.executeCommand({
|
||||
binaryName: 'ffmpeg',
|
||||
args: [
|
||||
...this.getGlobalArgs(),
|
||||
'-i',
|
||||
videoPath,
|
||||
'-i',
|
||||
audioPath,
|
||||
'-c:v',
|
||||
'copy',
|
||||
'-c:a',
|
||||
'aac',
|
||||
'-strict',
|
||||
'experimental',
|
||||
outputPath
|
||||
],
|
||||
options: { sync: true }
|
||||
})
|
||||
|
||||
return outputPath
|
||||
} catch (error: unknown) {
|
||||
throw new Error(
|
||||
`Video and audio combination failed: ${(error as Error).message}`
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Replaces the audio track of a video with a new audio file.
|
||||
* Removes/mutes the original audio and merges the new audio with the video.
|
||||
* @param videoPath The file path of the video file.
|
||||
* @param newAudioPath The file path of the new audio file to replace the original audio.
|
||||
* @param outputPath The desired file path for the video with replaced audio.
|
||||
* @returns A promise that resolves with the path to the video file with new audio.
|
||||
*/
|
||||
async replaceVideoAudio(
|
||||
videoPath: string,
|
||||
newAudioPath: string,
|
||||
outputPath: string
|
||||
): Promise<string> {
|
||||
try {
|
||||
// Use -map to explicitly map video from first input and audio from second input
|
||||
// This effectively removes the original audio and replaces it with the new audio
|
||||
await this.executeCommand({
|
||||
binaryName: 'ffmpeg',
|
||||
args: [
|
||||
...this.getGlobalArgs(),
|
||||
'-y', // Overwrite output file if it exists
|
||||
'-i',
|
||||
videoPath,
|
||||
'-i',
|
||||
newAudioPath,
|
||||
'-map',
|
||||
'0:v:0', // Map video from first input
|
||||
'-map',
|
||||
'1:a:0', // Map audio from second input
|
||||
'-c:v',
|
||||
'copy', // Copy video codec (no re-encoding)
|
||||
'-c:a',
|
||||
'aac', // Encode audio to AAC
|
||||
'-b:a',
|
||||
'192k', // Audio bitrate
|
||||
'-shortest', // Finish encoding when the shortest input stream ends
|
||||
outputPath
|
||||
],
|
||||
options: { sync: true }
|
||||
})
|
||||
|
||||
return outputPath
|
||||
} catch (error: unknown) {
|
||||
throw new Error(
|
||||
`Video audio replacement failed: ${(error as Error).message}`
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Compresses a video to reduce its file size.
|
||||
* @param inputPath The file path of the video to be compressed.
|
||||
* @param outputPath The desired file path for the compressed video.
|
||||
* @param bitrate The target bitrate for the video (e.g., "1000k").
|
||||
* @returns A promise that resolves with the path to the compressed video file.
|
||||
*/
|
||||
async compressVideo(
|
||||
inputPath: string,
|
||||
outputPath: string,
|
||||
bitrate: string
|
||||
): Promise<string> {
|
||||
try {
|
||||
await this.executeCommand({
|
||||
binaryName: 'ffmpeg',
|
||||
args: [
|
||||
...this.getGlobalArgs(),
|
||||
'-i',
|
||||
inputPath,
|
||||
'-b:v',
|
||||
bitrate,
|
||||
outputPath
|
||||
],
|
||||
options: { sync: true }
|
||||
})
|
||||
|
||||
return outputPath
|
||||
} catch (error: unknown) {
|
||||
throw new Error(`Video compression failed: ${(error as Error).message}`)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Adjusts the tempo (speed) of an audio file using the atempo filter.
|
||||
* If the speed factor is greater than 2.0, multiple atempo filters are chained.
|
||||
* @param inputPath The file path of the audio to be speed-adjusted.
|
||||
* @param outputPath The desired file path for the speed-adjusted audio.
|
||||
* @param speedFactor The speed multiplier (e.g., 1.3 for 30% faster, 0.8 for 20% slower). Must be between 0.5 and 100.0.
|
||||
* @param sampleRate Optional sample rate for the output audio (defaults to the input's sample rate).
|
||||
* @returns A promise that resolves with the path to the speed-adjusted audio file.
|
||||
*/
|
||||
async adjustTempo(
|
||||
inputPath: string,
|
||||
outputPath: string,
|
||||
speedFactor: number,
|
||||
sampleRate?: number
|
||||
): Promise<string> {
|
||||
try {
|
||||
if (speedFactor < 0.5 || speedFactor > 100.0) {
|
||||
throw new Error('Speed factor must be between 0.5 and 100.0')
|
||||
}
|
||||
|
||||
// FFmpeg's atempo filter only supports values between 0.5 and 2.0
|
||||
// For larger speed factors, we need to chain multiple atempo filters
|
||||
const atempoFilters: string[] = []
|
||||
let remainingSpeed = speedFactor
|
||||
|
||||
while (remainingSpeed > 2.0) {
|
||||
atempoFilters.push('atempo=2.0')
|
||||
remainingSpeed /= 2.0
|
||||
}
|
||||
|
||||
if (remainingSpeed < 1.0 && remainingSpeed < 0.5) {
|
||||
while (remainingSpeed < 0.5) {
|
||||
atempoFilters.push('atempo=0.5')
|
||||
remainingSpeed /= 0.5
|
||||
}
|
||||
}
|
||||
|
||||
atempoFilters.push(`atempo=${remainingSpeed.toFixed(6)}`)
|
||||
|
||||
const filterComplex = atempoFilters.join(',')
|
||||
const args = [
|
||||
...this.getGlobalArgs(),
|
||||
'-y',
|
||||
'-i',
|
||||
inputPath,
|
||||
'-filter:a',
|
||||
filterComplex
|
||||
]
|
||||
|
||||
if (sampleRate) {
|
||||
args.push('-ar', sampleRate.toString())
|
||||
}
|
||||
|
||||
args.push(outputPath)
|
||||
|
||||
await this.executeCommand({
|
||||
binaryName: 'ffmpeg',
|
||||
args,
|
||||
options: { sync: true }
|
||||
})
|
||||
|
||||
return outputPath
|
||||
} catch (error: unknown) {
|
||||
throw new Error(
|
||||
`Audio tempo adjustment failed: ${(error as Error).message}`
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Merges two audio files into one.
|
||||
* @param firstAudioPath The path to the first audio file.
|
||||
* @param secondAudioPath The path to the second audio file.
|
||||
* @param outputPath The desired file path for the merged audio.
|
||||
* @returns A promise that resolves with the path to the merged audio file.
|
||||
*/
|
||||
async mergeAudio(
|
||||
firstAudioPath: string,
|
||||
secondAudioPath: string,
|
||||
outputPath: string
|
||||
): Promise<string> {
|
||||
try {
|
||||
await this.executeCommand({
|
||||
binaryName: 'ffmpeg',
|
||||
args: [
|
||||
...this.getGlobalArgs(),
|
||||
'-y',
|
||||
'-i',
|
||||
firstAudioPath,
|
||||
'-i',
|
||||
secondAudioPath,
|
||||
'-filter_complex',
|
||||
'amix=inputs=2:duration=longest:dropout_transition=0',
|
||||
outputPath
|
||||
],
|
||||
options: { sync: true }
|
||||
})
|
||||
|
||||
return outputPath
|
||||
} catch (error: unknown) {
|
||||
throw new Error(`Audio merging failed: ${(error as Error).message}`)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Assembles multiple audio segments into a single audio file with precise timing.
|
||||
* Each segment is placed at its exact timestamp with silence padding where needed.
|
||||
* Similar to pydub's overlay functionality but using FFmpeg.
|
||||
* @param segments Array of {path: string, startMs: number} objects representing audio segments and their start times in milliseconds
|
||||
* @param outputPath The desired file path for the assembled audio
|
||||
* @param totalDurationMs The total duration of the output audio in milliseconds
|
||||
* @param sampleRate Optional sample rate for the output audio (default: 22050)
|
||||
* @returns A promise that resolves with the path to the assembled audio file
|
||||
*/
|
||||
async assembleAudioSegments(
|
||||
segments: Array<{ path: string, startMs: number }>,
|
||||
outputPath: string,
|
||||
totalDurationMs: number,
|
||||
sampleRate: number = 22_050
|
||||
): Promise<string> {
|
||||
try {
|
||||
if (segments.length === 0) {
|
||||
throw new Error('No segments provided for assembly')
|
||||
}
|
||||
|
||||
// Build FFmpeg filter_complex for assembling segments at precise timestamps
|
||||
// We'll use the adelay filter to position each segment at its start time
|
||||
const inputs: string[] = []
|
||||
const filterParts: string[] = []
|
||||
|
||||
// Add all segment files as inputs
|
||||
for (const segment of segments) {
|
||||
inputs.push('-i', segment.path)
|
||||
}
|
||||
|
||||
// Build filter chain: adelay each segment, then amix them all together
|
||||
for (let i = 0; i < segments.length; i += 1) {
|
||||
const delayMs = segments[i]?.startMs ?? 0
|
||||
// adelay takes delay in milliseconds
|
||||
filterParts.push(`[${i}:a]adelay=${delayMs}|${delayMs}[a${i}]`)
|
||||
}
|
||||
|
||||
// Mix all delayed streams together with normalization
|
||||
// Use amix with normalize=0 and weights=1 to prevent volume reduction
|
||||
const mixInputs = segments.map((_, i) => `[a${i}]`).join('')
|
||||
filterParts.push(
|
||||
`${mixInputs}amix=inputs=${segments.length}:duration=longest:dropout_transition=0:normalize=0[mixed]`
|
||||
)
|
||||
|
||||
// Apply dynamic normalization and compression to maintain consistent volume
|
||||
filterParts.push('[mixed]dynaudnorm=f=150:g=15:p=0.9:s=5[normalized]')
|
||||
|
||||
// Apply a slight compression to even out volume levels
|
||||
filterParts.push(
|
||||
'[normalized]acompressor=threshold=0.089:ratio=4:attack=20:release=250[aout]'
|
||||
)
|
||||
|
||||
const filterComplex = filterParts.join(';')
|
||||
|
||||
// Calculate total duration in seconds for ffmpeg
|
||||
const totalDurationS = totalDurationMs / 1000
|
||||
|
||||
const args = [
|
||||
...this.getGlobalArgs(),
|
||||
'-y',
|
||||
...inputs,
|
||||
'-filter_complex',
|
||||
filterComplex,
|
||||
'-map',
|
||||
'[aout]',
|
||||
'-ar',
|
||||
sampleRate.toString(),
|
||||
'-t',
|
||||
totalDurationS.toFixed(3),
|
||||
'-c:a',
|
||||
'pcm_s16le',
|
||||
outputPath
|
||||
]
|
||||
|
||||
await this.executeCommand({
|
||||
binaryName: 'ffmpeg',
|
||||
args,
|
||||
options: { sync: true }
|
||||
})
|
||||
|
||||
return outputPath
|
||||
} catch (error: unknown) {
|
||||
throw new Error(`Audio assembly failed: ${(error as Error).message}`)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export { default } from './ffmpeg-tool'
|
||||
@@ -0,0 +1,401 @@
|
||||
from typing import List, Dict, Optional
|
||||
from bridges.python.src.sdk.base_tool import BaseTool, ExecuteCommandOptions
|
||||
from bridges.python.src.sdk.toolkit_config import ToolkitConfig
|
||||
|
||||
DEFAULT_SETTINGS = {}
|
||||
REQUIRED_SETTINGS = []
|
||||
|
||||
|
||||
class FfmpegTool(BaseTool):
|
||||
TOOLKIT = "video_streaming"
|
||||
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self.config = ToolkitConfig.load(self.TOOLKIT, self.tool_name)
|
||||
self.settings = ToolkitConfig.load_tool_settings(
|
||||
self.TOOLKIT, self.tool_name, DEFAULT_SETTINGS
|
||||
)
|
||||
self.required_settings = REQUIRED_SETTINGS
|
||||
self._check_required_settings(self.tool_name)
|
||||
|
||||
@property
|
||||
def tool_name(self) -> str:
|
||||
return "ffmpeg"
|
||||
|
||||
@property
|
||||
def toolkit(self) -> str:
|
||||
return self.TOOLKIT
|
||||
|
||||
@property
|
||||
def description(self) -> str:
|
||||
return self.config["description"]
|
||||
|
||||
def _get_global_args(self) -> List[str]:
|
||||
"""Get global FFmpeg arguments to hide banner and set log level to error"""
|
||||
return ["-hide_banner", "-loglevel", "error"]
|
||||
|
||||
def convert_video_format(self, input_path: str, output_path: str) -> str:
|
||||
"""
|
||||
Converts a video file to a different format.
|
||||
|
||||
Args:
|
||||
input_path: The file path of the video to be converted.
|
||||
output_path: The desired file path for the converted video.
|
||||
|
||||
Returns:
|
||||
The path to the converted video file.
|
||||
"""
|
||||
try:
|
||||
self.execute_command(
|
||||
ExecuteCommandOptions(
|
||||
binary_name="ffmpeg",
|
||||
args=self._get_global_args() + ["-i", input_path, output_path],
|
||||
options={"sync": True},
|
||||
)
|
||||
)
|
||||
|
||||
return output_path
|
||||
except Exception as e:
|
||||
raise Exception(f"Video conversion failed: {str(e)}")
|
||||
|
||||
def extract_audio(self, video_path: str, audio_path: str) -> str:
|
||||
"""
|
||||
Extracts the audio track from a video file and saves it as a separate audio file.
|
||||
|
||||
Args:
|
||||
video_path: The file path of the video from which to extract audio.
|
||||
audio_path: The desired file path for the extracted audio.
|
||||
|
||||
Returns:
|
||||
The path to the extracted audio file.
|
||||
"""
|
||||
try:
|
||||
# Keep it simple: don't force codec/bitrate, let ffmpeg decide from extension.
|
||||
# Use -progress pipe:2 to stream progress to stderr and log it.
|
||||
args = self._get_global_args() + [
|
||||
"-y",
|
||||
"-i",
|
||||
video_path,
|
||||
"-vn",
|
||||
"-progress",
|
||||
"pipe:2",
|
||||
audio_path,
|
||||
]
|
||||
|
||||
def on_output(data: str, is_error: bool = False) -> None:
|
||||
if not is_error:
|
||||
return
|
||||
for line in data.split("\n"):
|
||||
line = line.strip()
|
||||
if not line or "=" not in line:
|
||||
continue
|
||||
key, value = line.split("=", 1)
|
||||
if key == "progress":
|
||||
self.log(f"ffmpeg progress: {value}")
|
||||
elif key == "out_time_ms":
|
||||
try:
|
||||
ms = int(value)
|
||||
seconds = ms // 1_000_000
|
||||
self.log(f"processed_time_seconds={seconds}")
|
||||
except Exception:
|
||||
pass
|
||||
elif key == "speed":
|
||||
self.log(f"speed={value}")
|
||||
|
||||
self.execute_command(
|
||||
ExecuteCommandOptions(
|
||||
binary_name="ffmpeg",
|
||||
args=args,
|
||||
options={"sync": False},
|
||||
on_output=on_output,
|
||||
)
|
||||
)
|
||||
|
||||
return audio_path
|
||||
except Exception as e:
|
||||
raise Exception(f"Audio extraction failed: {str(e)}")
|
||||
|
||||
def trim_media(
|
||||
self, input_path: str, output_path: str, start_time: str, end_time: str
|
||||
) -> str:
|
||||
"""
|
||||
Trims a media (video or audio) file to a specified duration.
|
||||
|
||||
Args:
|
||||
input_path: The file path of the media to be trimmed.
|
||||
output_path: The desired file path for the trimmed media.
|
||||
start_time: The start time for the trim, formatted as HH:MM:SS.
|
||||
end_time: The end time for the trim, formatted as HH:MM:SS.
|
||||
|
||||
Returns:
|
||||
The path to the trimmed media file.
|
||||
"""
|
||||
try:
|
||||
self.execute_command(
|
||||
ExecuteCommandOptions(
|
||||
binary_name="ffmpeg",
|
||||
args=self._get_global_args()
|
||||
+ [
|
||||
"-i",
|
||||
input_path,
|
||||
"-ss",
|
||||
start_time,
|
||||
"-to",
|
||||
end_time,
|
||||
"-c",
|
||||
"copy",
|
||||
output_path,
|
||||
],
|
||||
options={"sync": True},
|
||||
)
|
||||
)
|
||||
|
||||
return output_path
|
||||
except Exception as e:
|
||||
raise Exception(f"Video trimming failed: {str(e)}")
|
||||
|
||||
def resize_video(
|
||||
self, input_path: str, output_path: str, width: int, height: int
|
||||
) -> str:
|
||||
"""
|
||||
Resizes a video to the specified dimensions.
|
||||
|
||||
Args:
|
||||
input_path: The file path of the video to be resized.
|
||||
output_path: The desired file path for the resized video.
|
||||
width: The target width of the video in pixels.
|
||||
height: The target height of the video in pixels.
|
||||
|
||||
Returns:
|
||||
The path to the resized video file.
|
||||
"""
|
||||
try:
|
||||
self.execute_command(
|
||||
ExecuteCommandOptions(
|
||||
binary_name="ffmpeg",
|
||||
args=self._get_global_args()
|
||||
+ ["-i", input_path, "-vf", f"scale={width}:{height}", output_path],
|
||||
options={"sync": True},
|
||||
)
|
||||
)
|
||||
|
||||
return output_path
|
||||
except Exception as e:
|
||||
raise Exception(f"Video resizing failed: {str(e)}")
|
||||
|
||||
def combine_video_and_audio(
|
||||
self, video_path: str, audio_path: str, output_path: str
|
||||
) -> str:
|
||||
"""
|
||||
Merges a video file with a separate audio file.
|
||||
|
||||
Args:
|
||||
video_path: The file path of the video file.
|
||||
audio_path: The file path of the audio file.
|
||||
output_path: The desired file path for the combined video and audio.
|
||||
|
||||
Returns:
|
||||
The path to the merged video file.
|
||||
"""
|
||||
try:
|
||||
self.execute_command(
|
||||
ExecuteCommandOptions(
|
||||
binary_name="ffmpeg",
|
||||
args=self._get_global_args()
|
||||
+ [
|
||||
"-i",
|
||||
video_path,
|
||||
"-i",
|
||||
audio_path,
|
||||
"-c:v",
|
||||
"copy",
|
||||
"-c:a",
|
||||
"aac",
|
||||
"-strict",
|
||||
"experimental",
|
||||
output_path,
|
||||
],
|
||||
options={"sync": True},
|
||||
)
|
||||
)
|
||||
|
||||
return output_path
|
||||
except Exception as e:
|
||||
raise Exception(f"Video and audio combination failed: {str(e)}")
|
||||
|
||||
def compress_video(self, input_path: str, output_path: str, bitrate: str) -> str:
|
||||
"""
|
||||
Compresses a video to reduce its file size.
|
||||
|
||||
Args:
|
||||
input_path: The file path of the video to be compressed.
|
||||
output_path: The desired file path for the compressed video.
|
||||
bitrate: The target bitrate for the video (e.g., "1000k").
|
||||
|
||||
Returns:
|
||||
The path to the compressed video file.
|
||||
"""
|
||||
try:
|
||||
self.execute_command(
|
||||
ExecuteCommandOptions(
|
||||
binary_name="ffmpeg",
|
||||
args=self._get_global_args()
|
||||
+ ["-i", input_path, "-b:v", bitrate, output_path],
|
||||
options={"sync": True},
|
||||
)
|
||||
)
|
||||
|
||||
return output_path
|
||||
except Exception as e:
|
||||
raise Exception(f"Video compression failed: {str(e)}")
|
||||
|
||||
def adjust_tempo(
|
||||
self,
|
||||
input_path: str,
|
||||
output_path: str,
|
||||
speed_factor: float,
|
||||
sample_rate: int = None,
|
||||
) -> str:
|
||||
"""
|
||||
Adjusts the tempo (speed) of an audio file using the atempo filter.
|
||||
If the speed factor is greater than 2.0, multiple atempo filters are chained.
|
||||
|
||||
Args:
|
||||
input_path: The file path of the audio to be speed-adjusted.
|
||||
output_path: The desired file path for the speed-adjusted audio.
|
||||
speed_factor: The speed multiplier (e.g., 1.3 for 30% faster, 0.8 for 20% slower). Must be between 0.5 and 100.0.
|
||||
sample_rate: Optional sample rate for the output audio (defaults to the input's sample rate).
|
||||
|
||||
Returns:
|
||||
The path to the speed-adjusted audio file.
|
||||
"""
|
||||
try:
|
||||
if speed_factor < 0.5 or speed_factor > 100.0:
|
||||
raise ValueError("Speed factor must be between 0.5 and 100.0")
|
||||
|
||||
# FFmpeg's atempo filter only supports values between 0.5 and 2.0
|
||||
# For larger speed factors, we need to chain multiple atempo filters
|
||||
atempo_filters = []
|
||||
remaining_speed = speed_factor
|
||||
|
||||
while remaining_speed > 2.0:
|
||||
atempo_filters.append("atempo=2.0")
|
||||
remaining_speed /= 2.0
|
||||
|
||||
if remaining_speed < 1.0 and remaining_speed < 0.5:
|
||||
while remaining_speed < 0.5:
|
||||
atempo_filters.append("atempo=0.5")
|
||||
remaining_speed /= 0.5
|
||||
|
||||
atempo_filters.append(f"atempo={remaining_speed:.6f}")
|
||||
|
||||
filter_complex = ",".join(atempo_filters)
|
||||
args = self._get_global_args() + [
|
||||
"-y",
|
||||
"-i",
|
||||
input_path,
|
||||
"-filter:a",
|
||||
filter_complex,
|
||||
]
|
||||
|
||||
if sample_rate:
|
||||
args.extend(["-ar", str(sample_rate)])
|
||||
|
||||
args.append(output_path)
|
||||
|
||||
self.execute_command(
|
||||
ExecuteCommandOptions(
|
||||
binary_name="ffmpeg", args=args, options={"sync": True}
|
||||
)
|
||||
)
|
||||
|
||||
return output_path
|
||||
except Exception as e:
|
||||
raise Exception(f"Audio tempo adjustment failed: {str(e)}")
|
||||
|
||||
def assemble_audio_segments(
|
||||
self,
|
||||
segments: List[Dict[str, any]],
|
||||
output_path: str,
|
||||
total_duration_ms: int,
|
||||
sample_rate: int = 22_050,
|
||||
) -> str:
|
||||
"""
|
||||
Assembles multiple audio segments into a single audio file with precise timing.
|
||||
Each segment is placed at its exact timestamp with silence padding where needed.
|
||||
Similar to pydub's overlay functionality but using FFmpeg.
|
||||
|
||||
Args:
|
||||
segments: List of dictionaries with 'path' (str) and 'start_ms' (int) keys
|
||||
representing audio segments and their start times in milliseconds
|
||||
output_path: The desired file path for the assembled audio
|
||||
total_duration_ms: The total duration of the output audio in milliseconds
|
||||
sample_rate: Optional sample rate for the output audio (default: 22050)
|
||||
|
||||
Returns:
|
||||
The path to the assembled audio file
|
||||
"""
|
||||
try:
|
||||
if not segments:
|
||||
raise ValueError("No segments provided for assembly")
|
||||
|
||||
# Build FFmpeg filter_complex for assembling segments at precise timestamps
|
||||
# We'll use the adelay filter to position each segment at its start time
|
||||
inputs = []
|
||||
filter_parts = []
|
||||
|
||||
# Add all segment files as inputs
|
||||
for segment in segments:
|
||||
inputs.extend(["-i", segment["path"]])
|
||||
|
||||
# Build filter chain: adelay each segment, then amix them all together
|
||||
for i, segment in enumerate(segments):
|
||||
delay_ms = segment.get("start_ms", 0)
|
||||
# adelay takes delay in milliseconds
|
||||
filter_parts.append(f"[{i}:a]adelay={delay_ms}|{delay_ms}[a{i}]")
|
||||
|
||||
# Mix all delayed streams together with normalization
|
||||
# Use amix with normalize=0 and weights=1 to prevent volume reduction
|
||||
mix_inputs = "".join([f"[a{i}]" for i in range(len(segments))])
|
||||
filter_parts.append(
|
||||
f"{mix_inputs}amix=inputs={len(segments)}:duration=longest:dropout_transition=0:normalize=0[mixed]"
|
||||
)
|
||||
|
||||
# Apply dynamic normalization and compression to maintain consistent volume
|
||||
filter_parts.append("[mixed]dynaudnorm=f=150:g=15:p=0.9:s=5[normalized]")
|
||||
|
||||
# Apply a slight compression to even out volume levels
|
||||
filter_parts.append(
|
||||
"[normalized]acompressor=threshold=0.089:ratio=4:attack=20:release=250[aout]"
|
||||
)
|
||||
|
||||
filter_complex = ";".join(filter_parts)
|
||||
|
||||
# Calculate total duration in seconds for ffmpeg
|
||||
total_duration_s = total_duration_ms / 1000
|
||||
|
||||
args = self._get_global_args() + [
|
||||
"-y",
|
||||
*inputs,
|
||||
"-filter_complex",
|
||||
filter_complex,
|
||||
"-map",
|
||||
"[aout]",
|
||||
"-ar",
|
||||
str(sample_rate),
|
||||
"-t",
|
||||
f"{total_duration_s:.3f}",
|
||||
"-c:a",
|
||||
"pcm_s16le",
|
||||
output_path,
|
||||
]
|
||||
|
||||
self.execute_command(
|
||||
ExecuteCommandOptions(
|
||||
binary_name="ffmpeg", args=args, options={"sync": True}
|
||||
)
|
||||
)
|
||||
|
||||
return output_path
|
||||
except Exception as e:
|
||||
raise Exception(f"Audio assembly failed: {str(e)}")
|
||||
@@ -0,0 +1,264 @@
|
||||
{
|
||||
"$schema": "../../../schemas/tool-schemas/tool.json",
|
||||
"tool_id": "ffmpeg",
|
||||
"toolkit_id": "video_streaming",
|
||||
"name": "FFmpeg",
|
||||
"description": "A tool for video/audio processing, conversion, and manipulation. Use this for encoding, decoding, transcoding, muxing, demuxing, streaming, filtering, and playing media files. For analyzing or extracting metadata without modifying files, use ffprobe instead.",
|
||||
"icon_name": "film-line",
|
||||
"author": {
|
||||
"name": "Louis Grenard",
|
||||
"email": "louis@getleon.ai",
|
||||
"url": "https://twitter.com/grenlouis"
|
||||
},
|
||||
"binaries": {
|
||||
"linux-x86_64": "https://github.com/leon-ai/leon-binaries/releases/download/ffmpeg_7.0.2/ffmpeg_7.0.2-linux-x86_64",
|
||||
"linux-aarch64": "https://github.com/leon-ai/leon-binaries/releases/download/ffmpeg_7.0.2/ffmpeg_7.0.2-linux-aarch64",
|
||||
"macosx-x86_64": "https://github.com/leon-ai/leon-binaries/releases/download/ffmpeg_7.0.2/ffmpeg_7.0.2-macosx-x86_64",
|
||||
"macosx-arm64": "https://github.com/leon-ai/leon-binaries/releases/download/ffmpeg_7.0.2/ffmpeg_7.0.2-macosx-arm64",
|
||||
"win-amd64": "https://github.com/leon-ai/leon-binaries/releases/download/ffmpeg_7.0.2/ffmpeg_7.0.2-win-amd64.exe"
|
||||
},
|
||||
"functions": {
|
||||
"convertVideoFormat": {
|
||||
"description": "Convert a video file to a different format.",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"inputPath": {
|
||||
"type": "string"
|
||||
},
|
||||
"outputPath": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"inputPath",
|
||||
"outputPath"
|
||||
]
|
||||
}
|
||||
},
|
||||
"extractAudio": {
|
||||
"description": "Extract an audio track from a video file.",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"videoPath": {
|
||||
"type": "string"
|
||||
},
|
||||
"audioPath": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"videoPath",
|
||||
"audioPath"
|
||||
]
|
||||
}
|
||||
},
|
||||
"trimMedia": {
|
||||
"description": "Trim a media file to a specific time range.",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"inputPath": {
|
||||
"type": "string"
|
||||
},
|
||||
"outputPath": {
|
||||
"type": "string"
|
||||
},
|
||||
"startTime": {
|
||||
"type": "string"
|
||||
},
|
||||
"endTime": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"inputPath",
|
||||
"outputPath",
|
||||
"startTime",
|
||||
"endTime"
|
||||
]
|
||||
}
|
||||
},
|
||||
"resizeVideo": {
|
||||
"description": "Resize a video to a given width and height.",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"inputPath": {
|
||||
"type": "string"
|
||||
},
|
||||
"outputPath": {
|
||||
"type": "string"
|
||||
},
|
||||
"width": {
|
||||
"type": "number"
|
||||
},
|
||||
"height": {
|
||||
"type": "number"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"inputPath",
|
||||
"outputPath",
|
||||
"width",
|
||||
"height"
|
||||
]
|
||||
}
|
||||
},
|
||||
"combineVideoAndAudio": {
|
||||
"description": "Combine a video file with an audio file.",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"videoPath": {
|
||||
"type": "string"
|
||||
},
|
||||
"audioPath": {
|
||||
"type": "string"
|
||||
},
|
||||
"outputPath": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"videoPath",
|
||||
"audioPath",
|
||||
"outputPath"
|
||||
]
|
||||
}
|
||||
},
|
||||
"replaceVideoAudio": {
|
||||
"description": "Replace the audio track in a video file.",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"videoPath": {
|
||||
"type": "string"
|
||||
},
|
||||
"audioPath": {
|
||||
"type": "string"
|
||||
},
|
||||
"outputPath": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"videoPath",
|
||||
"audioPath",
|
||||
"outputPath"
|
||||
]
|
||||
}
|
||||
},
|
||||
"compressVideo": {
|
||||
"description": "Compress a video by specifying a target bitrate.",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"inputPath": {
|
||||
"type": "string"
|
||||
},
|
||||
"outputPath": {
|
||||
"type": "string"
|
||||
},
|
||||
"bitrate": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"inputPath",
|
||||
"outputPath",
|
||||
"bitrate"
|
||||
]
|
||||
}
|
||||
},
|
||||
"adjustTempo": {
|
||||
"description": "Adjust the tempo (speed) of an audio file.",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"inputPath": {
|
||||
"type": "string"
|
||||
},
|
||||
"outputPath": {
|
||||
"type": "string"
|
||||
},
|
||||
"speedFactor": {
|
||||
"type": "number"
|
||||
},
|
||||
"sampleRate": {
|
||||
"type": "number"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"inputPath",
|
||||
"outputPath",
|
||||
"speedFactor"
|
||||
]
|
||||
}
|
||||
},
|
||||
"mergeAudio": {
|
||||
"description": "Merge two audio files into one.",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"firstAudioPath": {
|
||||
"type": "string"
|
||||
},
|
||||
"secondAudioPath": {
|
||||
"type": "string"
|
||||
},
|
||||
"outputPath": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"firstAudioPath",
|
||||
"secondAudioPath",
|
||||
"outputPath"
|
||||
]
|
||||
}
|
||||
},
|
||||
"assembleAudioSegments": {
|
||||
"description": "Assemble audio segments into a single audio file with precise timing.",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"segments": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"path": {
|
||||
"type": "string"
|
||||
},
|
||||
"startMs": {
|
||||
"type": "number"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"path",
|
||||
"startMs"
|
||||
],
|
||||
"additionalProperties": false
|
||||
}
|
||||
},
|
||||
"outputPath": {
|
||||
"type": "string"
|
||||
},
|
||||
"totalDurationMs": {
|
||||
"type": "number"
|
||||
},
|
||||
"sampleRate": {
|
||||
"type": "number"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"segments",
|
||||
"outputPath",
|
||||
"totalDurationMs"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user