(a: z.infer<typeof TransformArgs>)
| 77 | isReadOnly = false; isDestructive = true; argsSchema = TransformArgs; |
| 78 | |
| 79 | async execute(a: z.infer<typeof TransformArgs>): Promise<ToolResult> { |
| 80 | // ---- concat: join an ordered list of clips into one final video ---- |
| 81 | // Uses the concat demuxer. Default -c copy is lossless + fast and works when |
| 82 | // all clips share codec/resolution (the typical same-preset AI-generated case); |
| 83 | // reencode:true normalizes mismatched sources. |
| 84 | if (a.operation === 'concat') { |
| 85 | const inputs = a.inputs ?? []; |
| 86 | if (inputs.length < 2) { |
| 87 | return { content: 'concat requires "inputs" with at least 2 clip paths, in play order.', isError: true }; |
| 88 | } |
| 89 | const listLines = inputs.map(f => `file '${path.resolve(f).replace(/'/g, "'\\''")}'`); |
| 90 | const listPath = path.join(os.tmpdir(), `qodex-concat-${Date.now()}-${Math.random().toString(36).slice(2)}.txt`); |
| 91 | try { |
| 92 | await fs.writeFile(listPath, listLines.join('\n') + '\n', 'utf8'); |
| 93 | const cargs: string[] = []; |
| 94 | if (a.overwrite) cargs.push('-y'); |
| 95 | cargs.push('-f', 'concat', '-safe', '0', '-i', listPath); |
| 96 | if (a.reencode) cargs.push('-c:v', 'libx264', '-crf', '18', '-preset', 'veryfast', '-pix_fmt', 'yuv420p'); |
| 97 | else cargs.push('-c', 'copy'); |
| 98 | cargs.push(a.output); |
| 99 | const r = await runProcess('ffmpeg', cargs, { timeoutMs: (a.timeout_seconds ?? 600) * 1000 }); |
| 100 | if (r.notFound) return { content: notInstalledMessage('ffmpeg', FFMPEG_HINT), isError: true }; |
| 101 | if (r.timedOut) return { content: 'ffmpeg concat timed out. For many/long clips use background_job_start.', isError: true }; |
| 102 | if (!r.ok) { |
| 103 | const hint = a.reencode ? '' : '\nIf clips differ in codec/resolution, retry with reencode:true (or normalize them first with resize/convert).'; |
| 104 | return { content: `ffmpeg concat failed:\n${r.stderr.trim().slice(-1500)}${hint}`, isError: true }; |
| 105 | } |
| 106 | return { content: `✓ concat ${inputs.length} clips → ${a.output}` }; |
| 107 | } finally { |
| 108 | try { await fs.unlink(listPath); } catch { /* ignore temp cleanup */ } |
| 109 | } |
| 110 | } |
| 111 | |
| 112 | if (!a.input) return { content: `Operation "${a.operation}" requires "input".`, isError: true }; |
| 113 | const input: string = a.input; |
| 114 | const args: string[] = []; |
| 115 | if (a.overwrite) args.push('-y'); |
| 116 | |
| 117 | switch (a.operation) { |
| 118 | case 'convert': |
| 119 | args.push('-i', input, a.output); |
| 120 | break; |
| 121 | case 'trim': |
| 122 | if (!a.start) return { content: 'trim requires "start".', isError: true }; |
| 123 | args.push('-i', input, '-ss', a.start); |
| 124 | if (a.end) args.push('-to', a.end); |
| 125 | args.push('-c', 'copy', a.output); |
| 126 | break; |
| 127 | case 'extract_frame': |
| 128 | args.push('-ss', a.start ?? '0', '-i', input, '-frames:v', '1', a.output); |
| 129 | break; |
| 130 | case 'resize': { |
| 131 | if (!a.width && !a.height) return { content: 'resize requires width and/or height.', isError: true }; |
| 132 | const w = a.width ?? -1, h = a.height ?? -1; |
| 133 | args.push('-i', input, '-vf', `scale=${w}:${h}`, a.output); |
| 134 | break; |
| 135 | } |
| 136 | case 'extract_audio': |
nothing calls this directly
no test coverage detected