src / tools / mediaTools.ts
src / tools / mediaTools.ts
/**
* Media/Video Processing Tools
* Provides video transcoding, audio extraction, and media information retrieval.
*/
import { execFile } from 'child_process';
import fs from 'fs/promises';
import path from 'path';
import { promisify } from 'util';
import { z } from 'zod';
import { validatePath as validateWorkspacePath } from '../pathSecurity';
const execFileAsync = promisify(execFile);
// Path sandboxing utility - ensures paths stay within workspace
function validatePath(filePath: string, workspaceRoot: string): string {
return validateWorkspacePath(workspaceRoot, workspaceRoot, filePath);
}
// Progress callback type
type ProgressCallback = (progress: { current: number; total?: number }) => void;
// Zod schemas for validation
const videoTranscodeSchema = z.object({
input_path: z.string().min(1),
output_path: z.string().min(1),
preset: z.enum(['ultrafast', 'superfast', 'veryfast', 'faster', 'fast', 'medium', 'slow', 'slower', 'veryslow']).optional(),
});
const audioExtractSchema = z.object({
video_path: z.string().min(1),
output_format: z.enum(['mp3', 'wav', 'aac', 'flac', 'ogg']),
});
const mediaInfoGetSchema = z.object({
file_path: z.string().min(1),
});
const gifAnimateFromVideoSchema = z.object({
input_path: z.string().min(1),
frames: z.number().int().positive().optional(),
fps: z.number().int().positive().optional(),
});
/**
* Transcode a video file to a different format.
*/
export async function video_transcode(
input_path: string,
output_path: string,
preset?: 'ultrafast' | 'superfast' | 'veryfast' | 'faster' | 'fast' | 'medium' | 'slow' | 'slower' | 'veryslow',
workspaceRoot: string = process.cwd(),
progressCallback?: ProgressCallback
): Promise<{ success: boolean; duration: number; size: number }> {
const validated = videoTranscodeSchema.parse({ input_path, output_path, preset });
// Validate paths are within workspace
const inputPath = validatePath(validated.input_path, workspaceRoot);
const outputPath = validatePath(validated.output_path, workspaceRoot);
try {
await fs.access(inputPath);
} catch (error) {
throw new Error(`Input file not found: ${inputPath}`);
}
// Ensure output directory exists
await fs.mkdir(path.dirname(outputPath), { recursive: true });
if (progressCallback) {
progressCallback({ current: 1, total: 3 });
}
try {
// Get input video duration for progress tracking
let duration = 0;
try {
const ffprobeResult = await execFileAsync('ffprobe', [
'-v', 'error',
'-show_entries', 'format=duration',
'-of', 'default=noprint_wrappers=1:nokey=1',
inputPath
], { timeout: 30000 });
duration = parseFloat(ffprobeResult.stdout.trim()) || 0;
} catch {
// If we can't get duration, continue without progress tracking
}
if (progressCallback) {
progressCallback({ current: 2, total: 3 });
}
// Build ffmpeg command
const args = [
'-i', inputPath,
'-c:v', 'libx264',
'-preset', preset || 'medium',
'-crf', '23',
'-c:a', 'aac',
'-b:a', '128k',
'-movflags', '+faststart',
'-y', // Overwrite output file
outputPath
];
await execFileAsync('ffmpeg', args, { timeout: 300000 });
if (progressCallback) {
progressCallback({ current: 3, total: 3 });
}
// Get output file size
const stats = await fs.stat(outputPath);
return {
success: true,
duration: duration,
size: stats.size,
};
} catch (error) {
throw new Error(`Failed to transcode video: ${error instanceof Error ? error.message : String(error)}`);
}
}
/**
* Extract audio from a video file.
*/
export async function audio_extract(
video_path: string,
output_format: 'mp3' | 'wav' | 'aac' | 'flac' | 'ogg',
workspaceRoot: string = process.cwd()
): Promise<{ audio_file: string; format: string; bitrate: number }> {
const validated = audioExtractSchema.parse({ video_path, output_format });
// Validate path is within workspace
const inputPath = validatePath(validated.video_path, workspaceRoot);
try {
await fs.access(inputPath);
} catch (error) {
throw new Error(`Video file not found: ${inputPath}`);
}
// Generate output filename based on format
const baseName = path.basename(validated.video_path, path.extname(validated.video_path));
const outputPath = path.join(path.dirname(inputPath), `${baseName}_audio.${output_format}`);
try {
await fs.mkdir(path.dirname(outputPath), { recursive: true });
} catch (error) {
throw new Error(`Failed to create output directory: ${error instanceof Error ? error.message : String(error)}`);
}
try {
// Build ffmpeg command for audio extraction
const args = [
'-i', inputPath,
'-vn', // Disable video
'-acodec', output_format === 'mp3' ? 'libmp3lame' :
output_format === 'wav' ? 'pcm_s16le' :
output_format === 'aac' ? 'aac' :
output_format === 'flac' ? 'flac' :
'libvorbis',
'-b:a', '192k', // Default bitrate
'-y', // Overwrite output file
outputPath
];
await execFileAsync('ffmpeg', args, { timeout: 60000 });
const stats = await fs.stat(outputPath);
return {
audio_file: outputPath,
format: output_format,
bitrate: 192,
};
} catch (error) {
throw new Error(`Failed to extract audio: ${error instanceof Error ? error.message : String(error)}`);
}
}
/**
* Get media information about a file.
*/
export async function media_info_get(
file_path: string,
workspaceRoot: string = process.cwd()
): Promise<{ duration: number; resolution: { width: number; height: number }; codec: string; fps: number; file_size: number }> {
const validated = mediaInfoGetSchema.parse({ file_path });
// Validate path is within workspace
const filePath = validatePath(validated.file_path, workspaceRoot);
try {
await fs.access(filePath);
} catch (error) {
throw new Error(`File not found: ${filePath}`);
}
try {
// Get duration using ffprobe
let duration = 0;
try {
const ffprobeResult = await execFileAsync('ffprobe', [
'-v', 'error',
'-show_entries', 'format=duration',
'-of', 'default=noprint_wrappers=1:nokey=1',
filePath
], { timeout: 30000 });
duration = parseFloat(ffprobeResult.stdout.trim()) || 0;
} catch {
// Default to 0 if ffprobe fails
}
// Get video stream information using ffprobe
let width = 0, height = 0, codec = 'unknown', fps = 0;
try {
const ffprobeResult = await execFileAsync('ffprobe', [
'-v', 'error',
'-select_streams', 'v:0',
'-show_entries', 'stream=width,height,codec_name,r_frame_rate',
'-of', 'json',
filePath
], { timeout: 30000 });
const streamInfo = JSON.parse(ffprobeResult.stdout);
if (streamInfo.streams && streamInfo.streams.length > 0) {
width = streamInfo.streams[0].width || 0;
height = streamInfo.streams[0].height || 0;
codec = streamInfo.streams[0].codec_name || 'unknown';
// Parse frame rate
if (streamInfo.streams[0].r_frame_rate) {
const [num, den] = streamInfo.streams[0].r_frame_rate.split('/').map(Number);
fps = num / den;
}
}
} catch {
// Default values if ffprobe fails
}
// Get file size
const stats = await fs.stat(filePath);
return {
duration: duration,
resolution: { width, height },
codec: codec,
fps: fps,
file_size: stats.size,
};
} catch (error) {
throw new Error(`Failed to get media info: ${error instanceof Error ? error.message : String(error)}`);
}
}
/**
* Create an animated GIF from a video.
*/
export async function gif_animate_from_video(
input_path: string,
frames?: number,
fps?: number,
workspaceRoot: string = process.cwd()
): Promise<{ gif_path: string; frame_count: number }> {
const validated = gifAnimateFromVideoSchema.parse({ input_path, frames, fps });
// Validate path is within workspace
const inputPath = validatePath(validated.input_path, workspaceRoot);
try {
await fs.access(inputPath);
} catch (error) {
throw new Error(`Input file not found: ${inputPath}`);
}
// Generate output filename
const baseName = path.basename(validated.input_path, path.extname(validated.input_path));
const outputPath = path.join(path.dirname(inputPath), `${baseName}_animated.gif`);
try {
await fs.mkdir(path.dirname(outputPath), { recursive: true });
} catch (error) {
throw new Error(`Failed to create output directory: ${error instanceof Error ? error.message : String(error)}`);
}
// Get video duration for frame calculation
let duration = 0;
try {
const ffprobeResult = await execFileAsync('ffprobe', [
'-v', 'error',
'-show_entries', 'format=duration',
'-of', 'default=noprint_wrappers=1:nokey=1',
inputPath
], { timeout: 30000 });
duration = parseFloat(ffprobeResult.stdout.trim()) || 60; // Default to 60 seconds if can't get duration
} catch {
duration = 60;
}
try {
const args = [
'-i', inputPath,
'-vf', `fps=${fps || 10},scale=-1:360:flags=lanczos`,
'-t', String(frames ? frames / (fps || 10) : duration), // Limit by frames if specified
'-loop', '0', // Loop forever
outputPath
];
await execFileAsync('ffmpeg', args, { timeout: 300000 });
const frameCount = frames || Math.floor(duration * (fps || 10));
return {
gif_path: outputPath,
frame_count: frameCount,
};
} catch (error) {
throw new Error(`Failed to create GIF animation: ${error instanceof Error ? error.message : String(error)}`);
}
}
/**
* Media/Video Processing Tools
* Provides video transcoding, audio extraction, and media information retrieval.
*/
import { execFile } from 'child_process';
import fs from 'fs/promises';
import path from 'path';
import { promisify } from 'util';
import { z } from 'zod';
import { validatePath as validateWorkspacePath } from '../pathSecurity';
const execFileAsync = promisify(execFile);
// Path sandboxing utility - ensures paths stay within workspace
function validatePath(filePath: string, workspaceRoot: string): string {
return validateWorkspacePath(workspaceRoot, workspaceRoot, filePath);
}
// Progress callback type
type ProgressCallback = (progress: { current: number; total?: number }) => void;
// Zod schemas for validation
const videoTranscodeSchema = z.object({
input_path: z.string().min(1),
output_path: z.string().min(1),
preset: z.enum(['ultrafast', 'superfast', 'veryfast', 'faster', 'fast', 'medium', 'slow', 'slower', 'veryslow']).optional(),
});
const audioExtractSchema = z.object({
video_path: z.string().min(1),
output_format: z.enum(['mp3', 'wav', 'aac', 'flac', 'ogg']),
});
const mediaInfoGetSchema = z.object({
file_path: z.string().min(1),
});
const gifAnimateFromVideoSchema = z.object({
input_path: z.string().min(1),
frames: z.number().int().positive().optional(),
fps: z.number().int().positive().optional(),
});
/**
* Transcode a video file to a different format.
*/
export async function video_transcode(
input_path: string,
output_path: string,
preset?: 'ultrafast' | 'superfast' | 'veryfast' | 'faster' | 'fast' | 'medium' | 'slow' | 'slower' | 'veryslow',
workspaceRoot: string = process.cwd(),
progressCallback?: ProgressCallback
): Promise<{ success: boolean; duration: number; size: number }> {
const validated = videoTranscodeSchema.parse({ input_path, output_path, preset });
// Validate paths are within workspace
const inputPath = validatePath(validated.input_path, workspaceRoot);
const outputPath = validatePath(validated.output_path, workspaceRoot);
try {
await fs.access(inputPath);
} catch (error) {
throw new Error(`Input file not found: ${inputPath}`);
}
// Ensure output directory exists
await fs.mkdir(path.dirname(outputPath), { recursive: true });
if (progressCallback) {
progressCallback({ current: 1, total: 3 });
}
try {
// Get input video duration for progress tracking
let duration = 0;
try {
const ffprobeResult = await execFileAsync('ffprobe', [
'-v', 'error',
'-show_entries', 'format=duration',
'-of', 'default=noprint_wrappers=1:nokey=1',
inputPath
], { timeout: 30000 });
duration = parseFloat(ffprobeResult.stdout.trim()) || 0;
} catch {
// If we can't get duration, continue without progress tracking
}
if (progressCallback) {
progressCallback({ current: 2, total: 3 });
}
// Build ffmpeg command
const args = [
'-i', inputPath,
'-c:v', 'libx264',
'-preset', preset || 'medium',
'-crf', '23',
'-c:a', 'aac',
'-b:a', '128k',
'-movflags', '+faststart',
'-y', // Overwrite output file
outputPath
];
await execFileAsync('ffmpeg', args, { timeout: 300000 });
if (progressCallback) {
progressCallback({ current: 3, total: 3 });
}
// Get output file size
const stats = await fs.stat(outputPath);
return {
success: true,
duration: duration,
size: stats.size,
};
} catch (error) {
throw new Error(`Failed to transcode video: ${error instanceof Error ? error.message : String(error)}`);
}
}
/**
* Extract audio from a video file.
*/
export async function audio_extract(
video_path: string,
output_format: 'mp3' | 'wav' | 'aac' | 'flac' | 'ogg',
workspaceRoot: string = process.cwd()
): Promise<{ audio_file: string; format: string; bitrate: number }> {
const validated = audioExtractSchema.parse({ video_path, output_format });
// Validate path is within workspace
const inputPath = validatePath(validated.video_path, workspaceRoot);
try {
await fs.access(inputPath);
} catch (error) {
throw new Error(`Video file not found: ${inputPath}`);
}
// Generate output filename based on format
const baseName = path.basename(validated.video_path, path.extname(validated.video_path));
const outputPath = path.join(path.dirname(inputPath), `${baseName}_audio.${output_format}`);
try {
await fs.mkdir(path.dirname(outputPath), { recursive: true });
} catch (error) {
throw new Error(`Failed to create output directory: ${error instanceof Error ? error.message : String(error)}`);
}
try {
// Build ffmpeg command for audio extraction
const args = [
'-i', inputPath,
'-vn', // Disable video
'-acodec', output_format === 'mp3' ? 'libmp3lame' :
output_format === 'wav' ? 'pcm_s16le' :
output_format === 'aac' ? 'aac' :
output_format === 'flac' ? 'flac' :
'libvorbis',
'-b:a', '192k', // Default bitrate
'-y', // Overwrite output file
outputPath
];
await execFileAsync('ffmpeg', args, { timeout: 60000 });
const stats = await fs.stat(outputPath);
return {
audio_file: outputPath,
format: output_format,
bitrate: 192,
};
} catch (error) {
throw new Error(`Failed to extract audio: ${error instanceof Error ? error.message : String(error)}`);
}
}
/**
* Get media information about a file.
*/
export async function media_info_get(
file_path: string,
workspaceRoot: string = process.cwd()
): Promise<{ duration: number; resolution: { width: number; height: number }; codec: string; fps: number; file_size: number }> {
const validated = mediaInfoGetSchema.parse({ file_path });
// Validate path is within workspace
const filePath = validatePath(validated.file_path, workspaceRoot);
try {
await fs.access(filePath);
} catch (error) {
throw new Error(`File not found: ${filePath}`);
}
try {
// Get duration using ffprobe
let duration = 0;
try {
const ffprobeResult = await execFileAsync('ffprobe', [
'-v', 'error',
'-show_entries', 'format=duration',
'-of', 'default=noprint_wrappers=1:nokey=1',
filePath
], { timeout: 30000 });
duration = parseFloat(ffprobeResult.stdout.trim()) || 0;
} catch {
// Default to 0 if ffprobe fails
}
// Get video stream information using ffprobe
let width = 0, height = 0, codec = 'unknown', fps = 0;
try {
const ffprobeResult = await execFileAsync('ffprobe', [
'-v', 'error',
'-select_streams', 'v:0',
'-show_entries', 'stream=width,height,codec_name,r_frame_rate',
'-of', 'json',
filePath
], { timeout: 30000 });
const streamInfo = JSON.parse(ffprobeResult.stdout);
if (streamInfo.streams && streamInfo.streams.length > 0) {
width = streamInfo.streams[0].width || 0;
height = streamInfo.streams[0].height || 0;
codec = streamInfo.streams[0].codec_name || 'unknown';
// Parse frame rate
if (streamInfo.streams[0].r_frame_rate) {
const [num, den] = streamInfo.streams[0].r_frame_rate.split('/').map(Number);
fps = num / den;
}
}
} catch {
// Default values if ffprobe fails
}
// Get file size
const stats = await fs.stat(filePath);
return {
duration: duration,
resolution: { width, height },
codec: codec,
fps: fps,
file_size: stats.size,
};
} catch (error) {
throw new Error(`Failed to get media info: ${error instanceof Error ? error.message : String(error)}`);
}
}
/**
* Create an animated GIF from a video.
*/
export async function gif_animate_from_video(
input_path: string,
frames?: number,
fps?: number,
workspaceRoot: string = process.cwd()
): Promise<{ gif_path: string; frame_count: number }> {
const validated = gifAnimateFromVideoSchema.parse({ input_path, frames, fps });
// Validate path is within workspace
const inputPath = validatePath(validated.input_path, workspaceRoot);
try {
await fs.access(inputPath);
} catch (error) {
throw new Error(`Input file not found: ${inputPath}`);
}
// Generate output filename
const baseName = path.basename(validated.input_path, path.extname(validated.input_path));
const outputPath = path.join(path.dirname(inputPath), `${baseName}_animated.gif`);
try {
await fs.mkdir(path.dirname(outputPath), { recursive: true });
} catch (error) {
throw new Error(`Failed to create output directory: ${error instanceof Error ? error.message : String(error)}`);
}
// Get video duration for frame calculation
let duration = 0;
try {
const ffprobeResult = await execFileAsync('ffprobe', [
'-v', 'error',
'-show_entries', 'format=duration',
'-of', 'default=noprint_wrappers=1:nokey=1',
inputPath
], { timeout: 30000 });
duration = parseFloat(ffprobeResult.stdout.trim()) || 60; // Default to 60 seconds if can't get duration
} catch {
duration = 60;
}
try {
const args = [
'-i', inputPath,
'-vf', `fps=${fps || 10},scale=-1:360:flags=lanczos`,
'-t', String(frames ? frames / (fps || 10) : duration), // Limit by frames if specified
'-loop', '0', // Loop forever
outputPath
];
await execFileAsync('ffmpeg', args, { timeout: 300000 });
const frameCount = frames || Math.floor(duration * (fps || 10));
return {
gif_path: outputPath,
frame_count: frameCount,
};
} catch (error) {
throw new Error(`Failed to create GIF animation: ${error instanceof Error ? error.message : String(error)}`);
}
}