chore: import upstream snapshot with attribution

This commit is contained in:
wehub-resource-sync
2026-07-13 12:44:08 +08:00
commit 983960e2dd
1244 changed files with 281996 additions and 0 deletions
+3
View File
@@ -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)}")
+264
View File
@@ -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"
]
}
}
}
}
@@ -0,0 +1,3 @@
from .src.python.ffprobe_tool import FfprobeTool
__all__ = ["FfprobeTool"]
@@ -0,0 +1 @@
{}
@@ -0,0 +1,345 @@
import { Tool } from '@sdk/base-tool'
import { ToolkitConfig } from '@sdk/toolkit-config'
const DEFAULT_SETTINGS: Record<string, unknown> = {}
const REQUIRED_SETTINGS: string[] = []
/**
* Represents the overall format information of a media file.
*/
interface MediaFormatInfo {
filename: string
nb_streams: number
format_name: string
format_long_name: string
start_time: string
duration: string
size: string
bit_rate: string
probe_score: number
tags?: { [key: string]: string }
}
/**
* Represents detailed information about a single stream within a media file.
*/
interface StreamInfo {
index: number
codec_name: string
codec_long_name: string
codec_type: 'video' | 'audio' | 'subtitle' | 'data'
width?: number // For video streams
height?: number // For video streams
r_frame_rate?: string // For video streams
sample_rate?: string // For audio streams
channels?: number // For audio streams
[key: string]: unknown // Other properties
}
/**
* Represents information for a single frame in a video.
*/
interface FrameInfo {
media_type: 'video' | 'audio'
stream_index: number
key_frame: 0 | 1
pts: number
pts_time: string
dts: number
dts_time: string
duration: number
duration_time: string
size: string
pos: string
[key: string]: unknown
}
export default class FfprobeTool extends Tool {
private static readonly TOOLKIT = 'video_streaming'
private readonly config: ReturnType<typeof ToolkitConfig.load>
constructor() {
super()
this.config = ToolkitConfig.load(FfprobeTool.TOOLKIT, this.toolName)
const toolSettings = ToolkitConfig.loadToolSettings(
FfprobeTool.TOOLKIT,
this.toolName,
DEFAULT_SETTINGS
)
this.settings = toolSettings
this.requiredSettings = REQUIRED_SETTINGS
this.checkRequiredSettings(this.toolName)
}
get toolName(): string {
return 'ffprobe'
}
get toolkit(): string {
return FfprobeTool.TOOLKIT
}
get description(): string {
return this.config['description']
}
/**
* Retrieves general format information about a media file.
* @param filePath - The path to the input media file.
* @returns A promise that resolves with the media's format information.
*/
async getMediaFormatInfo(filePath: string): Promise<MediaFormatInfo> {
try {
const result = await this.executeCommand({
binaryName: 'ffprobe',
args: [
'-hide_banner',
'-v',
'quiet',
'-print_format',
'json',
'-show_format',
filePath
],
options: { sync: true }
})
const data = JSON.parse(result)
const formatData = data.format || {}
return {
filename: formatData.filename || '',
nb_streams: formatData.nb_streams || 0,
format_name: formatData.format_name || '',
format_long_name: formatData.format_long_name || '',
start_time: formatData.start_time || '',
duration: formatData.duration || '',
size: formatData.size || '',
bit_rate: formatData.bit_rate || '',
probe_score: formatData.probe_score || 0,
tags: formatData.tags
}
} catch (error: unknown) {
throw new Error(
`Failed to get media format info: ${(error as Error).message}`
)
}
}
/**
* Lists all streams contained within a media file.
* @param filePath - The path to the input media file.
* @returns A promise that resolves with an array of stream information objects.
*/
async listStreams(filePath: string): Promise<StreamInfo[]> {
try {
const result = await this.executeCommand({
binaryName: 'ffprobe',
args: [
'-hide_banner',
'-v',
'quiet',
'-print_format',
'json',
'-show_streams',
filePath
],
options: { sync: true }
})
const data = JSON.parse(result)
const streamsData = data.streams || []
return streamsData.map(
(streamData: Record<string, unknown>): StreamInfo => ({
index: (streamData['index'] as number) || 0,
codec_name: (streamData['codec_name'] as string) || '',
codec_long_name: (streamData['codec_long_name'] as string) || '',
codec_type:
(streamData['codec_type'] as StreamInfo['codec_type']) || 'data',
width: streamData['width'] as number,
height: streamData['height'] as number,
r_frame_rate: streamData['r_frame_rate'] as string,
sample_rate: streamData['sample_rate'] as string,
channels: streamData['channels'] as number,
...streamData // Include all other properties
})
)
} catch (error: unknown) {
throw new Error(`Failed to list streams: ${(error as Error).message}`)
}
}
/**
* Retrieves detailed information for all video streams in a file.
* @param filePath - The path to the input media file.
* @returns A promise that resolves with an array of video stream information objects.
*/
async getVideoSteamInfo(filePath: string): Promise<StreamInfo[]> {
try {
const allStreams = await this.listStreams(filePath)
return allStreams.filter((stream) => stream.codec_type === 'video')
} catch (error: unknown) {
throw new Error(
`Failed to get video stream info: ${(error as Error).message}`
)
}
}
/**
* Retrieves detailed information for all audio streams in a file.
* @param filePath - The path to the input media file.
* @returns A promise that resolves with an array of audio stream information objects.
*/
async getAudioStreamInfo(filePath: string): Promise<StreamInfo[]> {
try {
const allStreams = await this.listStreams(filePath)
return allStreams.filter((stream) => stream.codec_type === 'audio')
} catch (error: unknown) {
throw new Error(
`Failed to get audio stream info: ${(error as Error).message}`
)
}
}
/**
* Counts the total number of frames in the primary video stream of a file.
* @param filePath - The path to the input video file.
* @returns A promise that resolves with the total frame count.
*/
async countFrames(filePath: string): Promise<number> {
try {
try {
// Try to get nb_frames first
const result = await this.executeCommand({
binaryName: 'ffprobe',
args: [
'-hide_banner',
'-v',
'error',
'-select_streams',
'v:0',
'-count_frames',
'-show_entries',
'stream=nb_frames',
'-of',
'csv=p=0',
filePath
],
options: { sync: true }
})
const frameCountStr = result.trim()
if (frameCountStr && frameCountStr !== 'N/A') {
return parseInt(frameCountStr, 10)
}
} catch {
// Ignore error, fallback to manual counting
}
// Fallback: count frames manually if nb_frames is not available
const result = await this.executeCommand({
binaryName: 'ffprobe',
args: [
'-hide_banner',
'-v',
'error',
'-select_streams',
'v:0',
'-show_entries',
'frame=n',
'-of',
'csv=p=0',
filePath
],
options: { sync: true }
})
const lines = result.trim().split('\n')
return lines.filter((line) => line.trim()).length
} catch (error: unknown) {
throw new Error(`Failed to count frames: ${(error as Error).message}`)
}
}
/**
* Retrieves detailed, frame-by-frame information from a video stream.
* @param filePath - The path to the input video file.
* @returns A promise that resolves with an array of frame information objects.
*/
async getFramesInfo(filePath: string): Promise<FrameInfo[]> {
try {
const result = await this.executeCommand({
binaryName: 'ffprobe',
args: [
'-hide_banner',
'-v',
'quiet',
'-print_format',
'json',
'-show_frames',
'-select_streams',
'v:0',
filePath
],
options: { sync: true }
})
const data = JSON.parse(result)
const framesData = data.frames || []
return framesData.map(
(frameData: Record<string, unknown>): FrameInfo => ({
media_type:
(frameData['media_type'] as FrameInfo['media_type']) || 'video',
stream_index: (frameData['stream_index'] as number) || 0,
key_frame: (frameData['key_frame'] as FrameInfo['key_frame']) || 0,
pts: (frameData['pts'] as number) || 0,
pts_time: (frameData['pts_time'] as string) || '',
dts: (frameData['dts'] as number) || 0,
dts_time: (frameData['dts_time'] as string) || '',
duration: (frameData['duration'] as number) || 0,
duration_time: (frameData['duration_time'] as string) || '',
size: (frameData['size'] as string) || '',
pos: (frameData['pos'] as string) || '',
...frameData // Include all other properties
})
)
} catch (error: unknown) {
throw new Error(`Failed to get frames info: ${(error as Error).message}`)
}
}
/**
* Get the duration of an audio/video file in milliseconds.
* @param filePath - The path to the audio or video file
* @returns A promise that resolves with the duration in milliseconds
*/
async getDuration(filePath: string): Promise<number> {
try {
const result = await this.executeCommand({
binaryName: 'ffprobe',
args: [
'-v',
'error',
'-show_entries',
'format=duration',
'-of',
'default=noprint_wrappers=1:nokey=1',
filePath
],
options: { sync: true }
})
// Parse the duration from stdout (just the number in seconds)
const durationSeconds = parseFloat(result.trim())
if (!isNaN(durationSeconds) && durationSeconds > 0) {
return Math.round(durationSeconds * 1_000)
}
throw new Error('Could not parse duration from ffprobe output')
} catch (error: unknown) {
throw new Error(`Failed to get duration: ${(error as Error).message}`)
}
}
}
@@ -0,0 +1 @@
export { default } from './ffprobe-tool'
@@ -0,0 +1,347 @@
import json
from typing import Dict, Any, List, 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 MediaFormatInfo:
def __init__(self, data: Dict[str, Any]):
self.filename: str = data.get("filename", "")
self.nb_streams: int = data.get("nb_streams", 0)
self.format_name: str = data.get("format_name", "")
self.format_long_name: str = data.get("format_long_name", "")
self.start_time: str = data.get("start_time", "")
self.duration: str = data.get("duration", "")
self.size: str = data.get("size", "")
self.bit_rate: str = data.get("bit_rate", "")
self.probe_score: int = data.get("probe_score", 0)
self.tags: Optional[Dict[str, str]] = data.get("tags")
def to_dict(self) -> Dict[str, Any]:
return {
"filename": self.filename,
"nb_streams": self.nb_streams,
"format_name": self.format_name,
"format_long_name": self.format_long_name,
"start_time": self.start_time,
"duration": self.duration,
"size": self.size,
"bit_rate": self.bit_rate,
"probe_score": self.probe_score,
"tags": self.tags,
}
class StreamInfo:
def __init__(self, data: Dict[str, Any]):
self.index: int = data.get("index", 0)
self.codec_name: str = data.get("codec_name", "")
self.codec_long_name: str = data.get("codec_long_name", "")
self.codec_type: str = data.get("codec_type", "")
self.width: Optional[int] = data.get("width")
self.height: Optional[int] = data.get("height")
self.r_frame_rate: Optional[str] = data.get("r_frame_rate")
self.sample_rate: Optional[str] = data.get("sample_rate")
self.channels: Optional[int] = data.get("channels")
# Store all other properties
self._data = data
def __getitem__(self, key: str) -> Any:
return self._data.get(key)
def to_dict(self) -> Dict[str, Any]:
return self._data
class FrameInfo:
def __init__(self, data: Dict[str, Any]):
self.media_type: str = data.get("media_type", "")
self.stream_index: int = data.get("stream_index", 0)
self.key_frame: int = data.get("key_frame", 0)
self.pts: int = data.get("pts", 0)
self.pts_time: str = data.get("pts_time", "")
self.dts: int = data.get("dts", 0)
self.dts_time: str = data.get("dts_time", "")
self.duration: int = data.get("duration", 0)
self.duration_time: str = data.get("duration_time", "")
self.size: str = data.get("size", "")
self.pos: str = data.get("pos", "")
# Store all other properties
self._data = data
def __getitem__(self, key: str) -> Any:
return self._data.get(key)
def to_dict(self) -> Dict[str, Any]:
return self._data
class FfprobeTool(BaseTool):
TOOLKIT = "video_streaming"
def __init__(self):
super().__init__()
# Load configuration from central toolkits directory
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 "ffprobe"
@property
def toolkit(self) -> str:
return self.TOOLKIT
@property
def description(self) -> str:
return self.config["description"]
def get_media_format_info(self, file_path: str) -> MediaFormatInfo:
"""
Retrieves general format information about a media file.
Args:
file_path: The path to the input media file.
Returns:
The media's format information.
"""
try:
result = self.execute_command(
ExecuteCommandOptions(
binary_name="ffprobe",
args=[
"-hide_banner",
"-v",
"quiet",
"-print_format",
"json",
"-show_format",
file_path,
],
options={"sync": True},
)
)
data = json.loads(result)
format_data = data.get("format", {})
return MediaFormatInfo(format_data)
except Exception as e:
raise Exception(f"Failed to get media format info: {str(e)}")
def list_streams(self, file_path: str) -> List[StreamInfo]:
"""
Lists all streams contained within a media file.
Args:
file_path: The path to the input media file.
Returns:
An array of stream information objects.
"""
try:
result = self.execute_command(
ExecuteCommandOptions(
binary_name="ffprobe",
args=[
"-hide_banner",
"-v",
"quiet",
"-print_format",
"json",
"-show_streams",
file_path,
],
options={"sync": True},
)
)
data = json.loads(result)
streams_data = data.get("streams", [])
return [StreamInfo(stream_data) for stream_data in streams_data]
except Exception as e:
raise Exception(f"Failed to list streams: {str(e)}")
def get_video_stream_info(self, file_path: str) -> List[StreamInfo]:
"""
Retrieves detailed information for all video streams in a file.
Args:
file_path: The path to the input media file.
Returns:
An array of video stream information objects.
"""
try:
all_streams = self.list_streams(file_path)
return [stream for stream in all_streams if stream.codec_type == "video"]
except Exception as e:
raise Exception(f"Failed to get video stream info: {str(e)}")
def get_audio_stream_info(self, file_path: str) -> List[StreamInfo]:
"""
Retrieves detailed information for all audio streams in a file.
Args:
file_path: The path to the input media file.
Returns:
An array of audio stream information objects.
"""
try:
all_streams = self.list_streams(file_path)
return [stream for stream in all_streams if stream.codec_type == "audio"]
except Exception as e:
raise Exception(f"Failed to get audio stream info: {str(e)}")
def count_frames(self, file_path: str) -> int:
"""
Counts the total number of frames in the primary video stream of a file.
Args:
file_path: The path to the input video file.
Returns:
The total frame count.
"""
try:
try:
# Try to get nb_frames first
result = self.execute_command(
ExecuteCommandOptions(
binary_name="ffprobe",
args=[
"-hide_banner",
"-v",
"error",
"-select_streams",
"v:0",
"-count_frames",
"-show_entries",
"stream=nb_frames",
"-of",
"csv=p=0",
file_path,
],
options={"sync": True},
)
)
frame_count_str = result.strip()
if frame_count_str and frame_count_str != "N/A":
return int(frame_count_str)
except:
# Ignore error, fallback to manual counting
pass
# Fallback: count frames manually if nb_frames is not available
result = self.execute_command(
ExecuteCommandOptions(
binary_name="ffprobe",
args=[
"-hide_banner",
"-v",
"error",
"-select_streams",
"v:0",
"-show_entries",
"frame=n",
"-of",
"csv=p=0",
file_path,
],
options={"sync": True},
)
)
lines = result.strip().split("\n")
return len([line for line in lines if line.strip()])
except Exception as e:
raise Exception(f"Failed to count frames: {str(e)}")
def get_frames_info(self, file_path: str) -> List[FrameInfo]:
"""
Retrieves detailed, frame-by-frame information from a video stream.
Args:
file_path: The path to the input video file.
Returns:
An array of frame information objects.
"""
try:
result = self.execute_command(
ExecuteCommandOptions(
binary_name="ffprobe",
args=[
"-hide_banner",
"-v",
"quiet",
"-print_format",
"json",
"-show_frames",
"-select_streams",
"v:0",
file_path,
],
options={"sync": True},
)
)
data = json.loads(result)
frames_data = data.get("frames", [])
return [FrameInfo(frame_data) for frame_data in frames_data]
except Exception as e:
raise Exception(f"Failed to get frames info: {str(e)}")
def get_duration(self, file_path: str) -> int:
"""
Get the duration of an audio/video file in milliseconds.
Args:
file_path: The path to the audio or video file
Returns:
The duration in milliseconds
"""
try:
result = self.execute_command(
ExecuteCommandOptions(
binary_name="ffprobe",
args=[
"-v",
"error",
"-show_entries",
"format=duration",
"-of",
"default=noprint_wrappers=1:nokey=1",
file_path,
],
options={"sync": True},
)
)
# Parse the duration from stdout (just the number in seconds)
duration_seconds = float(result.strip())
if duration_seconds > 0:
return round(duration_seconds * 1000)
raise Exception("Could not parse duration from ffprobe output")
except Exception as e:
raise Exception(f"Failed to get duration: {str(e)}")
+120
View File
@@ -0,0 +1,120 @@
{
"$schema": "../../../schemas/tool-schemas/tool.json",
"tool_id": "ffprobe",
"toolkit_id": "video_streaming",
"name": "FFprobe",
"description": "A tool for analyzing and extracting metadata from video and audio files. Use this for inspecting media properties like duration, bitrate, codec information, stream details, and format data without modifying the files. For processing or converting media, use ffmpeg instead.",
"icon_name": "radar-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/ffprobe_7.0.2/ffprobe_7.0.2-linux-x86_64",
"linux-aarch64": "https://github.com/leon-ai/leon-binaries/releases/download/ffprobe_7.0.2/ffprobe_7.0.2-linux-aarch64",
"macosx-x86_64": "https://github.com/leon-ai/leon-binaries/releases/download/ffprobe_7.0.2/ffprobe_7.0.2-macosx-x86_64",
"macosx-arm64": "https://github.com/leon-ai/leon-binaries/releases/download/ffprobe_7.0.2/ffprobe_7.0.2-macosx-arm64",
"win-amd64": "https://github.com/leon-ai/leon-binaries/releases/download/ffprobe_7.0.2/ffprobe_7.0.2-win-amd64.exe"
},
"functions": {
"getMediaFormatInfo": {
"description": "Get format information for a media file.",
"parameters": {
"type": "object",
"properties": {
"filePath": {
"type": "string"
}
},
"required": [
"filePath"
]
}
},
"listStreams": {
"description": "List all streams for a media file.",
"parameters": {
"type": "object",
"properties": {
"filePath": {
"type": "string"
}
},
"required": [
"filePath"
]
}
},
"getVideoSteamInfo": {
"description": "Get video stream information for a media file.",
"parameters": {
"type": "object",
"properties": {
"filePath": {
"type": "string"
}
},
"required": [
"filePath"
]
}
},
"getAudioStreamInfo": {
"description": "Get audio stream information for a media file.",
"parameters": {
"type": "object",
"properties": {
"filePath": {
"type": "string"
}
},
"required": [
"filePath"
]
}
},
"countFrames": {
"description": "Count the number of frames in a media file.",
"parameters": {
"type": "object",
"properties": {
"filePath": {
"type": "string"
}
},
"required": [
"filePath"
]
}
},
"getFramesInfo": {
"description": "Get frame information for a media file.",
"parameters": {
"type": "object",
"properties": {
"filePath": {
"type": "string"
}
},
"required": [
"filePath"
]
}
},
"getDuration": {
"description": "Get the duration (in seconds) of a media file.",
"parameters": {
"type": "object",
"properties": {
"filePath": {
"type": "string"
}
},
"required": [
"filePath"
]
}
}
}
}
+15
View File
@@ -0,0 +1,15 @@
{
"$schema": "../../schemas/toolkit-schemas/toolkit.json",
"name": "Video Streaming",
"description": "Tools for video processing and streaming.",
"icon_name": "video-line",
"context_files": [
"GPU_COMPUTE.md",
"STORAGE.md"
],
"tools": [
"ffmpeg",
"ffprobe",
"ytdlp"
]
}
+3
View File
@@ -0,0 +1,3 @@
from .src.python.ytdlp_tool import YtdlpTool
__all__ = ["YtdlpTool"]
@@ -0,0 +1 @@
{}
@@ -0,0 +1 @@
export { default } from './ytdlp-tool'
@@ -0,0 +1,5 @@
--retries 3
--sleep-interval 0.5
--max-sleep-interval 2
--extractor-args youtube:player_client=default,-web_safari
--js-runtimes node
@@ -0,0 +1,703 @@
import { existsSync, mkdirSync, readdirSync, statSync } from 'node:fs'
import { basename, dirname, extname, join } from 'node:path'
import { Tool, type ProgressCallback } from '@sdk/base-tool'
import { ToolkitConfig } from '@sdk/toolkit-config'
const DEFAULT_SETTINGS: Record<string, unknown> = {}
const REQUIRED_SETTINGS: string[] = []
const DOWNLOAD_DESTINATION_PATTERN = /Destination:\s+(.+)$/
const ALREADY_DOWNLOADED_PATTERN =
/\[download\]\s+(.+)\s+has already been downloaded/
const MERGED_FILE_PATTERN = /\[Merger\]\s+Merging formats into\s+"(.+)"$/
const SUBTITLE_DESTINATION_PATTERN =
/Writing (?:video subtitles|video automatic captions) to:\s+(.+)$/
const DOWNLOAD_PROGRESS_PATTERN =
/\[download\]\s+(\d+\.?\d*)%\s+of\s+(?:~?\s*)([\d.]+\w+)\s+at\s+([\d.]+\w+\/s)\s+ETA\s+([\d:]+)/
const YTDLP_EXT_TEMPLATE = '%(ext)s'
const YTDLP_TITLE_TEMPLATE = '%(title)s'
const YTDLP_PLAYLIST_INDEX_TEMPLATE = '%(playlist_index)s'
const SUBTITLE_FORMAT = 'srt/best'
const SUBTITLE_CONVERT_FORMAT = 'srt'
const IGNORED_MEDIA_OUTPUT_EXTENSIONS = new Set([
'.part',
'.ytdl',
'.tmp',
'.temp',
'.jpg',
'.jpeg',
'.png',
'.webp',
'.json'
])
const LANGUAGE_CODE_SEPARATOR = ','
const SUBTITLE_OUTPUT_TYPE = 'subtitle'
const TYPED_OUTPUT_SEPARATOR = ':'
const SUBTITLE_METADATA_LANGUAGE_FIELDS = [
'language',
'language_code',
'original_language'
]
const IGNORED_SUBTITLE_LANGUAGE_CODES = new Set(['live_chat'])
interface OutputTarget {
directoryPath: string
outputTemplate: string
predictedFilePath?: string
}
interface VideoMetadata {
language?: unknown
language_code?: unknown
original_language?: unknown
subtitles?: unknown
automatic_captions?: unknown
}
export default class YtdlpTool extends Tool {
private static readonly TOOLKIT = 'video_streaming'
private readonly config: ReturnType<typeof ToolkitConfig.load>
constructor() {
super()
// Load configuration from central toolkits directory
// Use class name for tool config name
this.config = ToolkitConfig.load(YtdlpTool.TOOLKIT, this.toolName)
const toolSettings = ToolkitConfig.loadToolSettings(
YtdlpTool.TOOLKIT,
this.toolName,
DEFAULT_SETTINGS
)
this.settings = toolSettings
this.requiredSettings = REQUIRED_SETTINGS
this.checkRequiredSettings(this.toolName)
}
get toolName(): string {
return 'ytdlp'
}
get toolkit(): string {
return YtdlpTool.TOOLKIT
}
get description(): string {
return this.config['description']
}
private getConfigArgs(): string[] {
const configPath = join(this.getToolDir(import.meta.url), 'yt-dlp.conf')
return ['--config-locations', configPath]
}
/**
* Resolves media output using yt-dlp filename templates.
*/
private static resolveMediaOutputTarget(
outputPath: string,
expectedExtension?: string
): OutputTarget {
const extension = extname(outputPath)
const looksLikeFile = extension !== ''
const existingFileLikeDirectory =
looksLikeFile && existsSync(outputPath) && statSync(outputPath).isDirectory()
if (!looksLikeFile) {
return {
directoryPath: outputPath,
outputTemplate: join(
outputPath,
`${YTDLP_TITLE_TEMPLATE}.${YTDLP_EXT_TEMPLATE}`
)
}
}
const stem = basename(outputPath, extension)
const directoryPath = existingFileLikeDirectory ? outputPath : dirname(outputPath)
const outputTemplate = join(directoryPath, `${stem}.${YTDLP_EXT_TEMPLATE}`)
const predictedFilePath = expectedExtension
? join(directoryPath, `${stem}.${expectedExtension}`)
: undefined
return { directoryPath, outputTemplate, predictedFilePath }
}
/**
* Resolves subtitle output using yt-dlp's typed output template.
*/
private static resolveSubtitleOutputTarget(
outputPath: string,
languageCode: string
): OutputTarget {
const extension = extname(outputPath)
const looksLikeFile = extension !== ''
const primaryLanguageCode = this.getPrimaryLanguageCode(languageCode)
const existingFileLikeDirectory =
looksLikeFile && existsSync(outputPath) && statSync(outputPath).isDirectory()
if (!looksLikeFile) {
return {
directoryPath: outputPath,
outputTemplate: join(
outputPath,
`${YTDLP_TITLE_TEMPLATE}.${YTDLP_EXT_TEMPLATE}`
)
}
}
const requestedStem = basename(outputPath, extension)
const stem = this.stripSubtitleLanguageSuffix(
requestedStem,
primaryLanguageCode
)
const directoryPath = existingFileLikeDirectory ? outputPath : dirname(outputPath)
return {
directoryPath,
outputTemplate: join(directoryPath, `${stem}.${YTDLP_EXT_TEMPLATE}`),
predictedFilePath: join(
directoryPath,
`${stem}.${primaryLanguageCode}.${SUBTITLE_CONVERT_FORMAT}`
)
}
}
/**
* Returns the first language code when yt-dlp receives a language list.
*/
private static getPrimaryLanguageCode(languageCode: string): string {
return languageCode.split(LANGUAGE_CODE_SEPARATOR)[0]?.trim() || languageCode
}
/**
* Removes a trailing subtitle language suffix from a requested file stem.
*/
private static stripSubtitleLanguageSuffix(
stem: string,
languageCode: string
): string {
const suffix = `.${languageCode}`
return stem.endsWith(suffix) ? stem.slice(0, -suffix.length) : stem
}
/**
* Builds a typed yt-dlp output template, e.g. "subtitle:path.%(ext)s".
*/
private static buildTypedOutputTemplate(type: string, template: string): string {
return `${type}${TYPED_OUTPUT_SEPARATOR}${template}`
}
/**
* Parses file paths reported by yt-dlp.
*/
private static parseOutputFilePath(output: string): string | null {
let parsedPath: string | null = null
for (const line of output.split('\n')) {
const match =
line.match(DOWNLOAD_DESTINATION_PATTERN) ||
line.match(ALREADY_DOWNLOADED_PATTERN) ||
line.match(MERGED_FILE_PATTERN) ||
line.match(SUBTITLE_DESTINATION_PATTERN)
if (match?.[1]) {
parsedPath = match[1].trim()
}
}
return parsedPath
}
private static asRecord(value: unknown): Record<string, unknown> | null {
if (!value || typeof value !== 'object' || Array.isArray(value)) {
return null
}
return value as Record<string, unknown>
}
private static getMetadataLanguageCodes(value: unknown): string[] {
const record = this.asRecord(value)
if (!record) {
return []
}
return Object.keys(record)
.map((languageCode) => languageCode.trim())
.filter((languageCode) =>
Boolean(languageCode) &&
!IGNORED_SUBTITLE_LANGUAGE_CODES.has(languageCode)
)
}
private static getVideoLanguageCandidates(metadata: VideoMetadata): string[] {
const languageCodes = SUBTITLE_METADATA_LANGUAGE_FIELDS
.map((field) => metadata[field as keyof VideoMetadata])
.filter((value): value is string => typeof value === 'string')
.map((value) => value.trim())
.filter(Boolean)
return [...new Set(languageCodes)]
}
private static selectMatchingLanguage(
requestedLanguageCode: string,
availableLanguageCodes: string[]
): string | null {
const normalizedRequested = requestedLanguageCode.trim().toLowerCase()
if (!normalizedRequested) {
return null
}
return (
availableLanguageCodes.find((languageCode) => {
const normalizedLanguageCode = languageCode.toLowerCase()
return (
normalizedLanguageCode === normalizedRequested ||
normalizedLanguageCode.startsWith(`${normalizedRequested}-`) ||
normalizedRequested.startsWith(`${normalizedLanguageCode}-`)
)
}) || null
)
}
private static selectSubtitleLanguage(
metadata: VideoMetadata,
requestedLanguageCode?: string
): string {
const subtitleLanguageCodes = this.getMetadataLanguageCodes(
metadata.subtitles
)
const automaticCaptionLanguageCodes = this.getMetadataLanguageCodes(
metadata.automatic_captions
)
const requested = requestedLanguageCode?.trim()
if (requested) {
return (
this.selectMatchingLanguage(requested, subtitleLanguageCodes) ||
this.selectMatchingLanguage(requested, automaticCaptionLanguageCodes) ||
requested
)
}
for (const languageCode of this.getVideoLanguageCandidates(metadata)) {
const matchingLanguageCode =
this.selectMatchingLanguage(languageCode, subtitleLanguageCodes) ||
this.selectMatchingLanguage(languageCode, automaticCaptionLanguageCodes)
if (matchingLanguageCode) {
return matchingLanguageCode
}
}
if (subtitleLanguageCodes.length > 0) {
return subtitleLanguageCodes[0]!
}
if (automaticCaptionLanguageCodes.length > 0) {
return automaticCaptionLanguageCodes[0]!
}
throw new Error(
'No subtitles or automatic captions are available for this video.'
)
}
/**
* Finds the newest file created or updated in the output directory.
*/
private static findNewestOutputFile(
directoryPath: string,
startedAtMs?: number
): string | null {
if (!existsSync(directoryPath) || !statSync(directoryPath).isDirectory()) {
return null
}
const minModifiedTime = startedAtMs ? startedAtMs - 2_000 : 0
let newestPath: string | null = null
let newestModifiedTime = 0
for (const entry of readdirSync(directoryPath, { withFileTypes: true })) {
if (!entry.isFile()) {
continue
}
const candidatePath = join(directoryPath, entry.name)
if (IGNORED_MEDIA_OUTPUT_EXTENSIONS.has(extname(candidatePath))) {
continue
}
const stats = statSync(candidatePath)
if (stats.mtimeMs < minModifiedTime) {
continue
}
if (stats.mtimeMs >= newestModifiedTime) {
newestPath = candidatePath
newestModifiedTime = stats.mtimeMs
}
}
return newestPath
}
/**
* Resolves a media path from yt-dlp output or a deterministic file target.
*/
private static resolveDownloadedMediaPath(
output: string,
target: OutputTarget,
startedAtMs?: number
): string {
if (target.predictedFilePath && existsSync(target.predictedFilePath)) {
return target.predictedFilePath
}
const parsedPath = this.parseOutputFilePath(output)
if (parsedPath && existsSync(parsedPath)) {
return parsedPath
}
const newestOutputFile = this.findNewestOutputFile(
target.directoryPath,
startedAtMs
)
if (newestOutputFile) {
return newestOutputFile
}
if (parsedPath) {
return parsedPath
}
throw new Error('yt-dlp completed but no output file path could be resolved')
}
/**
* Resolves a subtitle path and ensures a subtitle file was created.
*/
private static resolveDownloadedSubtitlePath(
output: string,
target: OutputTarget
): string {
const parsedPath = this.parseOutputFilePath(output)
for (const candidate of [target.predictedFilePath, parsedPath]) {
if (candidate && existsSync(candidate) && statSync(candidate).isFile()) {
return candidate
}
}
throw new Error('yt-dlp completed but no subtitle file was created')
}
/**
* Loads video metadata to select the best available subtitle language.
*/
private async getVideoMetadata(videoUrl: string): Promise<VideoMetadata> {
const output = await this.executeCommand({
binaryName: 'yt-dlp',
args: [...this.getConfigArgs(), videoUrl, '--dump-single-json', '--skip-download'],
options: { sync: true }
})
return JSON.parse(output.trim()) as VideoMetadata
}
/**
* Downloads a single video from the provided URL.
* @param videoUrl The URL of the video to download.
* @param outputPath The directory where the video will be saved.
* @returns A promise that resolves with the file path of the downloaded video.
*/
async downloadVideo(videoUrl: string, outputPath: string): Promise<string> {
try {
const target = YtdlpTool.resolveMediaOutputTarget(outputPath)
mkdirSync(target.directoryPath, { recursive: true })
const commandStartedAtMs = Date.now()
const result = await this.executeCommand({
binaryName: 'yt-dlp',
args: [...this.getConfigArgs(), videoUrl, '-o', target.outputTemplate],
options: { sync: true }
})
return YtdlpTool.resolveDownloadedMediaPath(
result,
target,
commandStartedAtMs
)
} catch (error: unknown) {
throw new Error(`Video download failed: ${(error as Error).message}`)
}
}
/**
* Downloads the audio track from a video and saves it as an audio file.
* @param videoUrl The URL of the video.
* @param outputPath The directory to save the audio file in.
* @param audioFormat The desired audio format (e.g., 'mp3', 'm4a', 'wav').
* @returns A promise that resolves with the file path of the extracted audio.
*/
async downloadAudioOnly(
videoUrl: string,
outputPath: string,
audioFormat: string
): Promise<string> {
try {
const target = YtdlpTool.resolveMediaOutputTarget(outputPath, audioFormat)
mkdirSync(target.directoryPath, { recursive: true })
const commandStartedAtMs = Date.now()
const result = await this.executeCommand({
binaryName: 'yt-dlp',
args: [
...this.getConfigArgs(),
videoUrl,
'-x',
'--audio-format',
audioFormat,
'-o',
target.outputTemplate
],
options: { sync: true }
})
return YtdlpTool.resolveDownloadedMediaPath(
result,
target,
commandStartedAtMs
)
} catch (error: unknown) {
throw new Error(`Audio download failed: ${(error as Error).message}`)
}
}
/**
* Downloads all videos from a given playlist URL.
* @param playlistUrl The URL of the playlist.
* @param outputPath The directory where the playlist videos will be saved.
* @returns A promise that resolves with the path to the directory containing the downloaded videos.
*/
async downloadPlaylist(
playlistUrl: string,
outputPath: string
): Promise<string> {
try {
// Ensure output directory exists
mkdirSync(outputPath, { recursive: true })
// Run yt-dlp for playlist
const outputTemplate = join(
outputPath,
`${YTDLP_PLAYLIST_INDEX_TEMPLATE} - ${YTDLP_TITLE_TEMPLATE}.${YTDLP_EXT_TEMPLATE}`
)
await this.executeCommand({
binaryName: 'yt-dlp',
args: [...this.getConfigArgs(), playlistUrl, '-o', outputTemplate],
options: { sync: true }
})
return outputPath
} catch (error: unknown) {
throw new Error(`Playlist download failed: ${(error as Error).message}`)
}
}
/**
* Downloads a video in a specific quality or resolution.
* @param videoUrl The URL of the video to download.
* @param outputPath The directory where the video will be saved.
* @param quality The desired quality string (e.g., 'best', '720p', '1080p').
* @param onProgress The callback function for progress reporting.
* @returns A promise that resolves with the file path of the downloaded video.
*/
async downloadVideoByQuality(
videoUrl: string,
outputPath: string,
quality: string,
onProgress?: ProgressCallback
): Promise<string> {
try {
// Convert quality to yt-dlp format
let formatSelector: string
if (quality === 'best') {
formatSelector = 'best'
} else if (quality === 'worst') {
formatSelector = 'worst'
} else if (quality.endsWith('p')) {
// For resolution like 720p, 1080p
const height = quality.slice(0, -1)
formatSelector = `best[height<=${height}]`
} else {
formatSelector = quality
}
const target = YtdlpTool.resolveMediaOutputTarget(outputPath)
mkdirSync(target.directoryPath, { recursive: true })
const commandStartedAtMs = Date.now()
let downloadedFilePath = ''
const result = await this.executeCommand({
binaryName: 'yt-dlp',
args: [
...this.getConfigArgs(),
videoUrl,
'-f',
formatSelector,
'-o',
target.outputTemplate,
'--newline'
],
options: { sync: false },
onProgress,
onOutput: (output, isError) => {
const lines = output.split('\n')
for (const line of lines) {
// Parse download progress
if (!isError && line.includes('[download]')) {
const progressMatch = line.match(DOWNLOAD_PROGRESS_PATTERN)
if (
progressMatch &&
progressMatch[1] &&
progressMatch[2] &&
progressMatch[3] &&
progressMatch[4] &&
onProgress
) {
onProgress({
percentage: parseFloat(progressMatch[1]),
size: progressMatch[2],
speed: progressMatch[3],
eta: progressMatch[4],
status: 'downloading'
})
}
}
const pathMatch = YtdlpTool.parseOutputFilePath(line)
if (pathMatch) {
downloadedFilePath = pathMatch
}
// Check for download completion
if (!isError && line.includes('[download] 100%') && onProgress) {
onProgress({
percentage: 100,
status: 'completed'
})
}
}
}
})
return YtdlpTool.resolveDownloadedMediaPath(
[downloadedFilePath, result].filter(Boolean).join('\n'),
target,
commandStartedAtMs
)
} catch (error: unknown) {
throw new Error(
`Quality-specific video download failed: ${(error as Error).message}`
)
}
}
/**
* Downloads the subtitles for a video.
* @param videoUrl The URL of the video.
* @param outputPath The directory to save the subtitle file in.
* @param languageCode Optional language code for the desired subtitles (e.g., 'en', 'fr').
* @returns A promise that resolves with the file path of the downloaded subtitle file.
*/
async downloadSubtitles(
videoUrl: string,
outputPath: string,
languageCode?: string
): Promise<string> {
try {
const resolvedLanguageCode = YtdlpTool.selectSubtitleLanguage(
await this.getVideoMetadata(videoUrl),
languageCode
)
const target = YtdlpTool.resolveSubtitleOutputTarget(
outputPath,
resolvedLanguageCode
)
mkdirSync(target.directoryPath, { recursive: true })
const result = await this.executeCommand({
binaryName: 'yt-dlp',
args: [
...this.getConfigArgs(),
videoUrl,
'--write-subs',
'--write-auto-subs',
'--sub-langs',
resolvedLanguageCode,
'--sub-format',
SUBTITLE_FORMAT,
'--convert-subs',
SUBTITLE_CONVERT_FORMAT,
'--skip-download',
'-o',
YtdlpTool.buildTypedOutputTemplate(
SUBTITLE_OUTPUT_TYPE,
target.outputTemplate
)
],
options: { sync: true }
})
return YtdlpTool.resolveDownloadedSubtitlePath(result, target)
} catch (error: unknown) {
throw new Error(`Subtitle download failed: ${(error as Error).message}`)
}
}
/**
* Downloads a video and embeds its thumbnail as cover art.
* @param videoUrl The URL of the video.
* @param outputPath The directory where the video will be saved.
* @returns A promise that resolves with the file path of the video with the embedded thumbnail.
*/
async downloadVideoWithThumbnail(
videoUrl: string,
outputPath: string
): Promise<string> {
try {
const target = YtdlpTool.resolveMediaOutputTarget(outputPath)
mkdirSync(target.directoryPath, { recursive: true })
const commandStartedAtMs = Date.now()
const result = await this.executeCommand({
binaryName: 'yt-dlp',
args: [
...this.getConfigArgs(),
videoUrl,
'--embed-thumbnail',
'--write-thumbnail',
'-o',
target.outputTemplate
],
options: { sync: true }
})
return YtdlpTool.resolveDownloadedMediaPath(
result,
target,
commandStartedAtMs
)
} catch (error: unknown) {
throw new Error(
`Video with thumbnail download failed: ${(error as Error).message}`
)
}
}
}
@@ -0,0 +1,5 @@
--retries 3
--sleep-interval 0.5
--max-sleep-interval 2
--extractor-args youtube:player_client=default,-web_safari
--js-runtimes node
@@ -0,0 +1,698 @@
import json
import os
import re
import time
from typing import Any, Optional, TypedDict
from bridges.python.src.sdk.base_tool import (
BaseTool,
ExecuteCommandOptions,
ProgressCallback,
)
from bridges.python.src.sdk.toolkit_config import ToolkitConfig
DEFAULT_SETTINGS = {}
REQUIRED_SETTINGS = []
DOWNLOAD_DESTINATION_PATTERN = re.compile(r"Destination:\s+(.+)$")
ALREADY_DOWNLOADED_PATTERN = re.compile(
r"\[download\]\s+(.+)\s+has already been downloaded"
)
MERGED_FILE_PATTERN = re.compile(r'\[Merger\]\s+Merging formats into\s+"(.+)"$')
SUBTITLE_DESTINATION_PATTERN = re.compile(
r"Writing (?:video subtitles|video automatic captions) to:\s+(.+)$"
)
DOWNLOAD_PROGRESS_PATTERN = re.compile(
r"\[download\]\s+(\d+\.?\d*)%\s+of\s+(?:~?\s*)([\d.]+\w+)\s+at\s+([\d.]+\w+/s)\s+ETA\s+([\d:]+)"
)
YTDLP_EXT_TEMPLATE = "%(ext)s"
YTDLP_TITLE_TEMPLATE = "%(title)s"
YTDLP_PLAYLIST_INDEX_TEMPLATE = "%(playlist_index)s"
SUBTITLE_FORMAT = "srt/best"
SUBTITLE_CONVERT_FORMAT = "srt"
IGNORED_MEDIA_OUTPUT_EXTENSIONS = {
".part",
".ytdl",
".tmp",
".temp",
".jpg",
".jpeg",
".png",
".webp",
".json",
}
LANGUAGE_CODE_SEPARATOR = ","
SUBTITLE_OUTPUT_TYPE = "subtitle"
TYPED_OUTPUT_SEPARATOR = ":"
SUBTITLE_METADATA_LANGUAGE_FIELDS = [
"language",
"language_code",
"original_language",
]
IGNORED_SUBTITLE_LANGUAGE_CODES = {"live_chat"}
class OutputTarget(TypedDict, total=False):
directory_path: str
output_template: str
predicted_file_path: str
class VideoMetadata(TypedDict, total=False):
language: Any
language_code: Any
original_language: Any
subtitles: Any
automatic_captions: Any
class YtdlpTool(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 "ytdlp"
@property
def toolkit(self) -> str:
return self.TOOLKIT
@property
def description(self) -> str:
return self.config["description"]
def _get_config_args(self):
config_path = os.path.join(self._get_tool_dir(__file__), "yt-dlp.conf")
return ["--config-locations", config_path]
@staticmethod
def _resolve_media_output_target(
output_path: str, expected_extension: Optional[str] = None
) -> OutputTarget:
"""
Resolve media output using yt-dlp filename templates.
"""
extension = os.path.splitext(output_path)[1]
looks_like_file = extension != ""
existing_file_like_directory = (
looks_like_file
and os.path.exists(output_path)
and os.path.isdir(output_path)
)
if not looks_like_file:
return {
"directory_path": output_path,
"output_template": os.path.join(
output_path, f"{YTDLP_TITLE_TEMPLATE}.{YTDLP_EXT_TEMPLATE}"
),
}
stem = os.path.splitext(os.path.basename(output_path))[0]
directory_path = (
output_path
if existing_file_like_directory
else os.path.dirname(output_path) or "."
)
output_template = os.path.join(directory_path, f"{stem}.{YTDLP_EXT_TEMPLATE}")
predicted_file_path = (
os.path.join(directory_path, f"{stem}.{expected_extension}")
if expected_extension
else None
)
target: OutputTarget = {
"directory_path": directory_path,
"output_template": output_template,
}
if predicted_file_path:
target["predicted_file_path"] = predicted_file_path
return target
@classmethod
def _resolve_subtitle_output_target(
cls, output_path: str, language_code: str
) -> OutputTarget:
"""
Resolve subtitle output using yt-dlp's typed output template.
"""
extension = os.path.splitext(output_path)[1]
looks_like_file = extension != ""
primary_language_code = cls._get_primary_language_code(language_code)
existing_file_like_directory = (
looks_like_file
and os.path.exists(output_path)
and os.path.isdir(output_path)
)
if not looks_like_file:
return {
"directory_path": output_path,
"output_template": os.path.join(
output_path, f"{YTDLP_TITLE_TEMPLATE}.{YTDLP_EXT_TEMPLATE}"
),
}
requested_stem = os.path.splitext(os.path.basename(output_path))[0]
stem = cls._strip_subtitle_language_suffix(
requested_stem, primary_language_code
)
directory_path = (
output_path
if existing_file_like_directory
else os.path.dirname(output_path) or "."
)
return {
"directory_path": directory_path,
"output_template": os.path.join(
directory_path, f"{stem}.{YTDLP_EXT_TEMPLATE}"
),
"predicted_file_path": os.path.join(
directory_path,
f"{stem}.{primary_language_code}.{SUBTITLE_CONVERT_FORMAT}",
),
}
@staticmethod
def _get_primary_language_code(language_code: str) -> str:
"""
Return the first language code when yt-dlp receives a language list.
"""
return (
language_code.split(LANGUAGE_CODE_SEPARATOR)[0].strip()
or language_code
)
@staticmethod
def _strip_subtitle_language_suffix(stem: str, language_code: str) -> str:
"""
Remove a trailing subtitle language suffix from a requested file stem.
"""
suffix = f".{language_code}"
return stem[: -len(suffix)] if stem.endswith(suffix) else stem
@staticmethod
def _build_typed_output_template(type_name: str, template: str) -> str:
"""
Build a typed yt-dlp output template, e.g. "subtitle:path.%(ext)s".
"""
return f"{type_name}{TYPED_OUTPUT_SEPARATOR}{template}"
@staticmethod
def _parse_output_file_path(output: str) -> Optional[str]:
"""
Parse file paths reported by yt-dlp.
"""
parsed_path = None
for line in output.split("\n"):
match = (
DOWNLOAD_DESTINATION_PATTERN.search(line)
or ALREADY_DOWNLOADED_PATTERN.search(line)
or MERGED_FILE_PATTERN.search(line)
or SUBTITLE_DESTINATION_PATTERN.search(line)
)
if match and match.group(1):
parsed_path = match.group(1).strip()
return parsed_path
@staticmethod
def _get_metadata_language_codes(value: Any) -> list[str]:
"""
Return language codes from a yt-dlp subtitle metadata object.
"""
if not isinstance(value, dict):
return []
return [
language_code
for language_code in (str(key).strip() for key in value.keys())
if language_code and language_code not in IGNORED_SUBTITLE_LANGUAGE_CODES
]
@staticmethod
def _get_video_language_candidates(metadata: VideoMetadata) -> list[str]:
"""
Return language hints exposed by yt-dlp video metadata.
"""
language_codes = [
str(metadata[field]).strip()
for field in SUBTITLE_METADATA_LANGUAGE_FIELDS
if isinstance(metadata.get(field), str)
]
return list(dict.fromkeys(filter(None, language_codes)))
@staticmethod
def _select_matching_language(
requested_language_code: str,
available_language_codes: list[str],
) -> Optional[str]:
"""
Match exact language codes and simple regional variants.
"""
normalized_requested = requested_language_code.strip().lower()
if not normalized_requested:
return None
for language_code in available_language_codes:
normalized_language_code = language_code.lower()
if (
normalized_language_code == normalized_requested
or normalized_language_code.startswith(f"{normalized_requested}-")
or normalized_requested.startswith(f"{normalized_language_code}-")
):
return language_code
return None
@classmethod
def _select_subtitle_language(
cls,
metadata: VideoMetadata,
requested_language_code: Optional[str] = None,
) -> str:
"""
Select the best available subtitle language.
"""
subtitle_language_codes = cls._get_metadata_language_codes(
metadata.get("subtitles")
)
automatic_caption_language_codes = cls._get_metadata_language_codes(
metadata.get("automatic_captions")
)
requested = requested_language_code.strip() if requested_language_code else ""
if requested:
return (
cls._select_matching_language(requested, subtitle_language_codes)
or cls._select_matching_language(
requested, automatic_caption_language_codes
)
or requested
)
for language_code in cls._get_video_language_candidates(metadata):
matching_language_code = cls._select_matching_language(
language_code, subtitle_language_codes
) or cls._select_matching_language(
language_code, automatic_caption_language_codes
)
if matching_language_code:
return matching_language_code
if subtitle_language_codes:
return subtitle_language_codes[0]
if automatic_caption_language_codes:
return automatic_caption_language_codes[0]
raise Exception("No subtitles or automatic captions are available for this video.")
@staticmethod
def _find_newest_output_file(
directory_path: str, started_at_ms: Optional[float] = None
) -> Optional[str]:
"""
Find the newest file created or updated in the output directory.
"""
if not os.path.isdir(directory_path):
return None
min_modified_time = ((started_at_ms - 2000) / 1000) if started_at_ms else 0
newest_path = None
newest_modified_time = 0.0
for entry_name in os.listdir(directory_path):
candidate_path = os.path.join(directory_path, entry_name)
if not os.path.isfile(candidate_path):
continue
if os.path.splitext(candidate_path)[1] in IGNORED_MEDIA_OUTPUT_EXTENSIONS:
continue
modified_time = os.path.getmtime(candidate_path)
if modified_time < min_modified_time:
continue
if modified_time >= newest_modified_time:
newest_path = candidate_path
newest_modified_time = modified_time
return newest_path
@classmethod
def _resolve_downloaded_media_path(
cls,
output: str,
target: OutputTarget,
started_at_ms: Optional[float] = None,
) -> str:
"""
Resolve a media path from yt-dlp output or a deterministic file target.
"""
predicted_file_path = target.get("predicted_file_path")
if predicted_file_path and os.path.exists(predicted_file_path):
return predicted_file_path
parsed_path = cls._parse_output_file_path(output)
if parsed_path and os.path.exists(parsed_path):
return parsed_path
newest_output_file = cls._find_newest_output_file(
target["directory_path"], started_at_ms
)
if newest_output_file:
return newest_output_file
if parsed_path:
return parsed_path
raise Exception("yt-dlp completed but no output file path could be resolved")
@classmethod
def _resolve_downloaded_subtitle_path(
cls, output: str, target: OutputTarget
) -> str:
"""
Resolve a subtitle path and ensure a subtitle file was created.
"""
parsed_path = cls._parse_output_file_path(output)
for candidate in [target.get("predicted_file_path"), parsed_path]:
if candidate and os.path.isfile(candidate):
return candidate
raise Exception("yt-dlp completed but no subtitle file was created")
def _get_video_metadata(self, video_url: str) -> VideoMetadata:
"""
Load video metadata to select the best available subtitle language.
"""
args = self._get_config_args() + [
video_url,
"--dump-single-json",
"--skip-download",
]
output = self.execute_command(
ExecuteCommandOptions(
binary_name="yt-dlp", args=args, options={"sync": True}
)
)
return json.loads(output.strip())
def download_video(self, video_url: str, output_path: str) -> str:
"""
Downloads a single video from the provided URL.
Args:
video_url: The URL of the video to download
output_path: The directory or file path where the video will be saved
Returns:
The file path of the downloaded video
"""
try:
target = self._resolve_media_output_target(output_path)
os.makedirs(target["directory_path"], exist_ok=True)
command_started_at_ms = time.time() * 1000
args = self._get_config_args() + [
video_url,
"-o",
target["output_template"],
]
result = self.execute_command(
ExecuteCommandOptions(
binary_name="yt-dlp", args=args, options={"sync": True}
)
)
return self._resolve_downloaded_media_path(
result, target, command_started_at_ms
)
except Exception as e:
raise Exception(f"Video download failed: {str(e)}")
def download_audio_only(
self, video_url: str, output_path: str, audio_format: str
) -> str:
"""
Downloads the audio track from a video and saves it as an audio file.
Args:
video_url: The URL of the video.
output_path: The directory or file path where the audio will be saved.
audio_format: The desired audio format (e.g., 'mp3', 'm4a', 'wav').
Returns:
The file path of the extracted audio.
"""
try:
target = self._resolve_media_output_target(output_path, audio_format)
os.makedirs(target["directory_path"], exist_ok=True)
command_started_at_ms = time.time() * 1000
args = self._get_config_args() + [
video_url,
"-x",
"--audio-format",
audio_format,
"-o",
target["output_template"],
]
result = self.execute_command(
ExecuteCommandOptions(
binary_name="yt-dlp", args=args, options={"sync": True}
)
)
return self._resolve_downloaded_media_path(
result, target, command_started_at_ms
)
except Exception as e:
raise Exception(f"Audio download failed: {str(e)}")
def download_playlist(self, playlist_url: str, output_path: str) -> str:
"""
Downloads all videos from a given playlist URL.
Args:
playlist_url: The URL of the playlist.
output_path: The directory where the playlist videos will be saved.
Returns:
The path to the directory containing the downloaded videos.
"""
try:
os.makedirs(output_path, exist_ok=True)
output_template = os.path.join(
output_path,
f"{YTDLP_PLAYLIST_INDEX_TEMPLATE} - {YTDLP_TITLE_TEMPLATE}.{YTDLP_EXT_TEMPLATE}",
)
args = self._get_config_args() + [playlist_url, "-o", output_template]
self.execute_command(
ExecuteCommandOptions(
binary_name="yt-dlp", args=args, options={"sync": True}
)
)
return output_path
except Exception as e:
raise Exception(f"Playlist download failed: {str(e)}")
def download_video_by_quality(
self,
video_url: str,
output_path: str,
quality: str,
on_progress: Optional[ProgressCallback] = None,
) -> str:
"""
Downloads a video in a specific quality or resolution.
Args:
video_url: The URL of the video to download.
output_path: The directory or file path where the video will be saved.
quality: The desired quality string (e.g., 'best', '720p', '1080p').
on_progress: The callback function for progress reporting.
Returns:
The file path of the downloaded video.
"""
try:
if quality == "best":
format_selector = "best"
elif quality == "worst":
format_selector = "worst"
elif quality.endswith("p"):
# For resolution like 720p, 1080p.
height = quality[:-1]
format_selector = f"best[height<={height}]"
else:
format_selector = quality
target = self._resolve_media_output_target(output_path)
os.makedirs(target["directory_path"], exist_ok=True)
command_started_at_ms = time.time() * 1000
downloaded_file_path = ""
def handle_output(output: str, is_error: bool):
nonlocal downloaded_file_path
for line in output.split("\n"):
if not is_error and "[download]" in line:
progress_match = DOWNLOAD_PROGRESS_PATTERN.search(line)
if progress_match and on_progress:
on_progress(
{
"percentage": float(progress_match.group(1)),
"size": progress_match.group(2),
"speed": progress_match.group(3),
"eta": progress_match.group(4),
"status": "downloading",
}
)
path_match = self._parse_output_file_path(line)
if path_match:
downloaded_file_path = path_match
if not is_error and "[download] 100%" in line and on_progress:
on_progress({"percentage": 100, "status": "completed"})
args = self._get_config_args() + [
video_url,
"-f",
format_selector,
"-o",
target["output_template"],
"--newline",
]
result = self.execute_command(
ExecuteCommandOptions(
binary_name="yt-dlp",
args=args,
options={"sync": False},
on_progress=on_progress,
on_output=handle_output,
)
)
return self._resolve_downloaded_media_path(
"\n".join([downloaded_file_path, result]),
target,
command_started_at_ms,
)
except Exception as e:
raise Exception(f"Quality-specific video download failed: {str(e)}")
def download_subtitles(
self,
video_url: str,
output_path: str,
language_code: Optional[str] = None,
) -> str:
"""
Downloads the subtitles for a video.
Args:
video_url: The URL of the video.
output_path: The directory or file path where the subtitle will be saved.
language_code: Optional language code for the desired subtitles (e.g., 'en', 'fr').
Returns:
The file path of the downloaded subtitle file.
"""
try:
resolved_language_code = self._select_subtitle_language(
self._get_video_metadata(video_url), language_code
)
target = self._resolve_subtitle_output_target(
output_path, resolved_language_code
)
os.makedirs(target["directory_path"], exist_ok=True)
args = self._get_config_args() + [
video_url,
"--write-subs",
"--write-auto-subs",
"--sub-langs",
resolved_language_code,
"--sub-format",
SUBTITLE_FORMAT,
"--convert-subs",
SUBTITLE_CONVERT_FORMAT,
"--skip-download",
"-o",
self._build_typed_output_template(
SUBTITLE_OUTPUT_TYPE, target["output_template"]
),
]
result = self.execute_command(
ExecuteCommandOptions(
binary_name="yt-dlp", args=args, options={"sync": True}
)
)
return self._resolve_downloaded_subtitle_path(result, target)
except Exception as e:
raise Exception(f"Subtitle download failed: {str(e)}")
def download_video_with_thumbnail(self, video_url: str, output_path: str) -> str:
"""
Downloads a video and embeds its thumbnail as cover art.
Args:
video_url: The URL of the video.
output_path: The directory or file path where the video will be saved.
Returns:
The file path of the video with the embedded thumbnail.
"""
try:
target = self._resolve_media_output_target(output_path)
os.makedirs(target["directory_path"], exist_ok=True)
command_started_at_ms = time.time() * 1000
args = self._get_config_args() + [
video_url,
"--embed-thumbnail",
"--write-thumbnail",
"-o",
target["output_template"],
]
result = self.execute_command(
ExecuteCommandOptions(
binary_name="yt-dlp", args=args, options={"sync": True}
)
)
return self._resolve_downloaded_media_path(
result, target, command_started_at_ms
)
except Exception as e:
raise Exception(f"Video download with thumbnail failed: {str(e)}")
+162
View File
@@ -0,0 +1,162 @@
{
"$schema": "../../../schemas/tool-schemas/tool.json",
"tool_id": "ytdlp",
"toolkit_id": "video_streaming",
"name": "YT-DLP",
"description": "A tool for downloading videos and audio from various streaming platforms using yt-dlp.",
"icon_name": "download-cloud-2-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/yt-dlp-v2026.07.04/yt-dlp_2026.07.04-linux-x86_64",
"linux-aarch64": "https://github.com/leon-ai/leon-binaries/releases/download/yt-dlp-v2026.07.04/yt-dlp_2026.07.04-linux-aarch64",
"macosx-x86_64": "https://github.com/leon-ai/leon-binaries/releases/download/yt-dlp-v2026.07.04/yt-dlp_2026.07.04-macosx-x86_64",
"macosx-arm64": "https://github.com/leon-ai/leon-binaries/releases/download/yt-dlp-v2026.07.04/yt-dlp_2026.07.04-macosx-arm64",
"win-amd64": "https://github.com/leon-ai/leon-binaries/releases/download/yt-dlp-v2026.07.04/yt-dlp_2026.07.04-win-amd64.exe"
},
"functions": {
"downloadVideo": {
"description": "Download a single video from a URL.",
"parameters": {
"type": "object",
"properties": {
"videoUrl": {
"type": "string"
},
"outputPath": {
"type": "string"
}
},
"required": [
"videoUrl",
"outputPath"
]
}
},
"downloadAudioOnly": {
"description": "Download audio-only from a video URL.",
"parameters": {
"type": "object",
"properties": {
"videoUrl": {
"type": "string"
},
"outputPath": {
"type": "string"
},
"audioFormat": {
"type": "string"
}
},
"required": [
"videoUrl",
"outputPath",
"audioFormat"
]
}
},
"downloadPlaylist": {
"description": "Download all videos from a playlist URL.",
"parameters": {
"type": "object",
"properties": {
"playlistUrl": {
"type": "string"
},
"outputPath": {
"type": "string"
}
},
"required": [
"playlistUrl",
"outputPath"
]
}
},
"downloadVideoByQuality": {
"description": "Download a video at a specified quality.",
"parameters": {
"type": "object",
"properties": {
"videoUrl": {
"type": "string"
},
"outputPath": {
"type": "string"
},
"quality": {
"type": "string"
},
"onProgress": {
"type": "object",
"properties": {
"percentage": {
"type": "number"
},
"status": {
"type": "string"
},
"eta": {
"type": "string"
},
"speed": {
"type": "string"
},
"size": {
"type": "string"
}
},
"additionalProperties": false
}
},
"required": [
"videoUrl",
"outputPath",
"quality"
]
}
},
"downloadSubtitles": {
"description": "Download subtitles for a video. Pass languageCode only when the owner requested a specific language; otherwise omit it so the tool auto-selects from available subtitles.",
"parameters": {
"type": "object",
"properties": {
"videoUrl": {
"type": "string"
},
"outputPath": {
"type": "string"
},
"languageCode": {
"type": "string"
}
},
"required": [
"videoUrl",
"outputPath"
]
}
},
"downloadVideoWithThumbnail": {
"description": "Download a video and embed its thumbnail.",
"parameters": {
"type": "object",
"properties": {
"videoUrl": {
"type": "string"
},
"outputPath": {
"type": "string"
}
},
"required": [
"videoUrl",
"outputPath"
]
}
}
}
}