Files
platform/seed/tools/ffmpeg/index.ts
T

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