174 lines
5.0 KiB
TypeScript
174 lines
5.0 KiB
TypeScript
import { execFileSync, spawn } from 'node:child_process';
|
|
|
|
type ToolResult = {
|
|
content: Array<{ type: string; text: string }>;
|
|
isError?: boolean;
|
|
};
|
|
|
|
type OnUpdate = (partial: { content: Array<{ type: string; text: string }> }) => void;
|
|
|
|
function update(onUpdate: OnUpdate | undefined, text: string): void {
|
|
onUpdate?.({ content: [{ type: 'text', text }] });
|
|
}
|
|
|
|
function ensureFfmpeg(): boolean {
|
|
try {
|
|
execFileSync('ffmpeg', ['-version'], { stdio: 'ignore' });
|
|
return true;
|
|
} catch {
|
|
try {
|
|
execFileSync('sudo', ['apt-get', 'update'], { stdio: 'ignore', timeout: 60_000 });
|
|
execFileSync('sudo', ['apt-get', 'install', '-y', 'ffmpeg'], { stdio: 'ignore', timeout: 120_000 });
|
|
execFileSync('ffmpeg', ['-version'], { stdio: 'ignore' });
|
|
return true;
|
|
} catch {
|
|
return false;
|
|
}
|
|
}
|
|
}
|
|
|
|
function parseArgs(argsString: string): string[] {
|
|
const args: string[] = [];
|
|
let current = '';
|
|
let inSingle = false;
|
|
let inDouble = false;
|
|
|
|
for (let i = 0; i < argsString.length; i++) {
|
|
const ch = argsString[i]!;
|
|
|
|
if (ch === "'" && !inDouble) {
|
|
inSingle = !inSingle;
|
|
} else if (ch === '"' && !inSingle) {
|
|
inDouble = !inDouble;
|
|
} else if (ch === ' ' && !inSingle && !inDouble) {
|
|
if (current.length > 0) {
|
|
args.push(current);
|
|
current = '';
|
|
}
|
|
} else {
|
|
current += ch;
|
|
}
|
|
}
|
|
|
|
if (current.length > 0) args.push(current);
|
|
return args;
|
|
}
|
|
|
|
export async function execute(
|
|
_toolCallId: string,
|
|
params: { command: 'ffmpeg' | 'ffprobe'; args: string },
|
|
_signal: AbortSignal | undefined,
|
|
onUpdate?: OnUpdate,
|
|
): Promise<ToolResult> {
|
|
const { command, args: argsString } = params;
|
|
|
|
if (!argsString || argsString.trim().length === 0) {
|
|
return {
|
|
content: [{ type: 'text', text: 'No arguments provided. See tool documentation for usage examples.' }],
|
|
isError: true,
|
|
};
|
|
}
|
|
|
|
update(onUpdate, 'Checking ffmpeg installation...');
|
|
|
|
if (!ensureFfmpeg()) {
|
|
return {
|
|
content: [{ type: 'text', text: 'Failed to install ffmpeg. Try manually: sudo apt-get update && sudo apt-get install -y ffmpeg' }],
|
|
isError: true,
|
|
};
|
|
}
|
|
|
|
const args = parseArgs(argsString);
|
|
|
|
// For ffprobe, run synchronously and return output
|
|
if (command === 'ffprobe') {
|
|
update(onUpdate, `Running ffprobe...`);
|
|
return new Promise((resolve) => {
|
|
const proc = spawn('ffprobe', args, { stdio: ['ignore', 'pipe', 'pipe'] });
|
|
let stdout = '';
|
|
let stderr = '';
|
|
|
|
proc.stdout.on('data', (chunk: Buffer) => { stdout += chunk.toString(); });
|
|
proc.stderr.on('data', (chunk: Buffer) => { stderr += chunk.toString(); });
|
|
|
|
proc.on('close', (code) => {
|
|
if (code !== 0) {
|
|
resolve({
|
|
content: [{ type: 'text', text: `ffprobe failed (exit ${code}):\n${stderr.trim()}` }],
|
|
isError: true,
|
|
});
|
|
} else {
|
|
// ffprobe prints info to stderr by default, stdout for -print_format
|
|
const output = stdout.trim() || stderr.trim();
|
|
resolve({
|
|
content: [{ type: 'text', text: output || 'No output' }],
|
|
});
|
|
}
|
|
});
|
|
|
|
proc.on('error', (err) => {
|
|
resolve({
|
|
content: [{ type: 'text', text: `ffprobe error: ${err.message}` }],
|
|
isError: true,
|
|
});
|
|
});
|
|
});
|
|
}
|
|
|
|
// For ffmpeg, stream progress
|
|
update(onUpdate, `Running ffmpeg...`);
|
|
|
|
return new Promise((resolve) => {
|
|
const proc = spawn('ffmpeg', args, { stdio: ['ignore', 'pipe', 'pipe'] });
|
|
let stdout = '';
|
|
let stderr = '';
|
|
let progressBuf = '';
|
|
|
|
proc.stdout.on('data', (chunk: Buffer) => {
|
|
stdout += chunk.toString();
|
|
});
|
|
|
|
proc.stderr.on('data', (chunk: Buffer) => {
|
|
const text = chunk.toString();
|
|
stderr += text;
|
|
|
|
// Parse ffmpeg progress from stderr (time= field)
|
|
progressBuf += text;
|
|
const lines = progressBuf.split('\r');
|
|
progressBuf = lines.pop() ?? '';
|
|
for (const line of lines) {
|
|
const timeMatch = line.match(/time=(\d{2}:\d{2}:\d{2}\.\d{2})/);
|
|
const speedMatch = line.match(/speed=\s*([\d.]+x)/);
|
|
if (timeMatch) {
|
|
const progress = `Time: ${timeMatch[1]}${speedMatch ? ` | Speed: ${speedMatch[1]}` : ''}`;
|
|
update(onUpdate, progress);
|
|
}
|
|
}
|
|
});
|
|
|
|
proc.on('close', (code) => {
|
|
if (code !== 0) {
|
|
// Extract the last meaningful error line from stderr
|
|
const errLines = stderr.trim().split('\n');
|
|
const lastLines = errLines.slice(-10).join('\n');
|
|
resolve({
|
|
content: [{ type: 'text', text: `ffmpeg failed (exit ${code}):\n${lastLines}` }],
|
|
isError: true,
|
|
});
|
|
} else {
|
|
const output = stdout.trim();
|
|
resolve({
|
|
content: [{ type: 'text', text: output ? `Done.\n\n${output}` : 'Done.' }],
|
|
});
|
|
}
|
|
});
|
|
|
|
proc.on('error', (err) => {
|
|
resolve({
|
|
content: [{ type: 'text', text: `ffmpeg error: ${err.message}` }],
|
|
isError: true,
|
|
});
|
|
});
|
|
});
|
|
}
|