// @agenticmail/core — MediaManager
//
// Ported and adapted from a local media MCP server. Exposes eight
// media operations to AgenticMail agents:
//
//   tts_generate / tts_list_voices  — text-to-speech (Edge TTS)
//   image_edit                      — resize / crop / overlay / …
//   video_edit                      — trim / caption / cinematic ops
//   audio_edit                      — trim / convert / merge / fade
//   media_info                      — probe metadata
//   video_understand                — frames + transcript timeline
//   voice_clone                     — F5-TTS reference-voice synthesis
//
// # Design
//
// The work is done by external system binaries (ffmpeg, ffprobe,
// ImageMagick, whisper.cpp, Python). They are NOT bundled — every
// operation feature-detects the binary it needs via `requireBinary`
// and fails with an actionable install hint if it is missing. The
// server never crashes because media is unavailable; the toolset is
// opt-in and gracefully degrading.
//
// # Security
//
// Every binary is invoked with `execFile` and an ARGUMENT ARRAY — no
// shell, ever. There is no string interpolation of file paths or
// option values into a command line. Untrusted inputs are defended:
//
//   - Input file paths are validated (`validateInputPath`): no NUL or
//     control characters, no leading-dash (so a path can never be
//     parsed as a CLI flag — argument-injection mitigation), and the
//     file must exist.
//   - Output files are written ONLY inside an operator-configured
//     output directory; output names are generated by us, never
//     derived from caller input.
//   - Every execFile call carries a bounded `timeout` and `maxBuffer`.
//   - Numeric options are clamped to safe ranges before they reach a
//     filter string (a filter string is data passed in an arg slot,
//     not a shell command, but clamping keeps ffmpeg from being asked
//     to allocate absurd buffers).

import { execFile } from 'node:child_process';
import { promisify } from 'node:util';
import {
  mkdirSync, existsSync, statSync, unlinkSync, rmSync,
  writeFileSync, readFileSync, readdirSync,
} from 'node:fs';
import { join, basename, extname, dirname, isAbsolute } from 'node:path';
import { detectBinary, requireBinary, requireWhisperModel, getMediaCapabilities } from './binaries.js';
import type {
  TtsGenerateOptions, ImageEditOptions, VideoEditOptions, AudioEditOptions,
  VideoUnderstandOptions, VoiceCloneOptions, MediaFileResult, MediaInfoResult,
  MediaStreamInfo, VideoUnderstandResult, VideoTimelineEntry, MediaCapabilityReport,
} from './types.js';

const execFileAsync = promisify(execFile);

/** Default per-process timeouts (ms). Long video ops get a wider budget. */
const TIMEOUT_PROBE = 15_000;
const TIMEOUT_FAST = 120_000;
const TIMEOUT_LONG = 600_000;

/** Cap on a binary's captured stdout/stderr — 64 MiB. */
const MAX_BUFFER = 64 * 1024 * 1024;

/** Edge TTS voice presets — short alias → full Edge voice id. */
const VOICE_PRESETS: Record<string, string> = {
  guy: 'en-US-GuyNeural', jenny: 'en-US-JennyNeural', aria: 'en-US-AriaNeural',
  davis: 'en-US-DavisNeural', tony: 'en-US-TonyNeural', ana: 'en-US-AnaNeural',
  brian: 'en-US-BrianNeural', emma: 'en-US-EmmaNeural',
  ryan: 'en-GB-RyanNeural', sonia: 'en-GB-SoniaNeural',
  william: 'en-AU-WilliamNeural', natasha: 'en-AU-NatashaNeural',
};
const DEFAULT_VOICE = 'en-US-GuyNeural';

export interface MediaManagerOptions {
  /**
   * Directory media output files are written to. Created on first use.
   * Defaults to `<dataDir>/media` when a dataDir is given, otherwise to
   * `<os-tmp>/agenticmail-media`.
   */
  outputDir?: string;
  /** AgenticMail data directory — used to derive a default outputDir. */
  dataDir?: string;
}

/**
 * Validate a caller-supplied input file path. Untrusted: an agent (or
 * a compromised one) controls it. Rejects:
 *
 *   - empty / non-string values
 *   - NUL and other control characters (poisoned-path / log-injection)
 *   - a leading `-` so the path can never be mis-parsed as a CLI flag
 *     when it lands in an execFile argument array (argument injection)
 *
 * and asserts the file exists. Returns the path unchanged on success.
 */
function validateInputPath(path: unknown, label = 'input'): string {
  if (typeof path !== 'string' || path.length === 0) {
    throw new Error(`${label} file path is required`);
  }
  // eslint-disable-next-line no-control-regex
  if (/[\u0000-\u001f]/.test(path)) {
    throw new Error(`${label} file path contains invalid control characters`);
  }
  if (path.startsWith('-')) {
    throw new Error(
      `${label} file path may not start with "-" — pass an absolute path so it cannot be parsed as a command flag`,
    );
  }
  if (!existsSync(path)) {
    throw new Error(`${label} file not found: ${path}`);
  }
  return path;
}

/** Coerce a value to a finite number within [min, max]; falls back to `def`. */
function clampNumber(value: unknown, min: number, max: number, def: number): number {
  const n = typeof value === 'number' ? value : Number(value);
  if (!Number.isFinite(n)) return def;
  return Math.min(Math.max(n, min), max);
}

/**
 * Validate an output file extension — used when an action lets the
 * caller pick a container/format. Restricted to a known alphanumeric
 * set so it can never inject path separators or flag characters into
 * the generated output filename.
 */
function safeExtension(format: unknown, fallback: string): string {
  if (typeof format !== 'string') return fallback;
  const cleaned = format.trim().toLowerCase().replace(/^\./, '');
  if (/^[a-z0-9]{1,5}$/.test(cleaned)) return cleaned;
  return fallback;
}

export class MediaManager {
  private readonly outputDir: string;

  constructor(options: MediaManagerOptions = {}) {
    if (options.outputDir) {
      this.outputDir = options.outputDir;
    } else if (options.dataDir) {
      this.outputDir = join(options.dataDir, 'media');
    } else {
      // Lazily import os only for the default path.
      const tmp = process.env.TMPDIR || process.env.TEMP || '/tmp';
      this.outputDir = join(tmp, 'agenticmail-media');
    }
  }

  /** Ensure the output directory exists; returns it. */
  private ensureOutputDir(): string {
    if (!existsSync(this.outputDir)) {
      mkdirSync(this.outputDir, { recursive: true });
    }
    return this.outputDir;
  }

  /** Build an output path inside the managed output dir. */
  private outPath(prefix: string, ext: string): string {
    return join(this.ensureOutputDir(), `${prefix}-${Date.now()}-${Math.floor(Math.random() * 1e6)}.${ext}`);
  }

  /** Build a sub-directory inside the managed output dir. */
  private outDir(prefix: string): string {
    const dir = join(this.ensureOutputDir(), `${prefix}-${Date.now()}-${Math.floor(Math.random() * 1e6)}`);
    mkdirSync(dir, { recursive: true });
    return dir;
  }

  /** Stat a produced file into a {@link MediaFileResult} envelope. */
  private fileResult(path: string, extra: Record<string, unknown> = {}): MediaFileResult {
    const stat = statSync(path);
    return { ok: true, filePath: path, sizeBytes: stat.size, ...extra };
  }

  // ─── binary invocation helpers (execFile, arg arrays, no shell) ────

  /** Run ffmpeg with an argument array. */
  private async ffmpeg(args: string[], timeout = TIMEOUT_FAST): Promise<void> {
    const bin = requireBinary('ffmpeg');
    await execFileAsync(bin, args, { timeout, maxBuffer: MAX_BUFFER });
  }

  /** Run ImageMagick with an argument array (handles magick/convert). */
  private async magick(args: string[], timeout = TIMEOUT_FAST): Promise<{ stdout: string }> {
    const bin = requireBinary('imagemagick');
    // ImageMagick 6's `convert` takes the args directly; ImageMagick 7's
    // `magick` also accepts them as the implicit `magick convert` form.
    const { stdout } = await execFileAsync(bin, args, { timeout, maxBuffer: MAX_BUFFER });
    return { stdout: stdout.toString() };
  }

  /** Run an `identify`-style probe via the ImageMagick binary. */
  private async magickIdentify(args: string[]): Promise<string> {
    const bin = requireBinary('imagemagick');
    // ImageMagick 7: `magick identify ...`; ImageMagick 6: `identify` is
    // a separate binary but `convert` cannot identify, so use the
    // `magick identify` subcommand form which v7 supports. For v6 the
    // detected command is `convert`; fall back to `identify` arg form.
    const probeArgs = bin === 'magick' ? ['identify', ...args] : ['identify', ...args];
    const { stdout } = await execFileAsync(bin === 'convert' ? 'identify' : bin, probeArgs.slice(bin === 'convert' ? 1 : 0), {
      timeout: TIMEOUT_PROBE,
      maxBuffer: 4 * 1024 * 1024,
    });
    return stdout.toString();
  }

  /** Probe a media file with ffprobe, returning parsed JSON. */
  private async ffprobe(path: string): Promise<any> {
    const bin = requireBinary('ffprobe');
    const { stdout } = await execFileAsync(bin, [
      '-v', 'quiet', '-print_format', 'json', '-show_format', '-show_streams', path,
    ], { timeout: TIMEOUT_PROBE, maxBuffer: 8 * 1024 * 1024 });
    return JSON.parse(stdout.toString());
  }

  // ─── capabilities ──────────────────────────────────────────────────

  /** Return the media binary capability report (graceful-degradation surface). */
  capabilities(opts: { force?: boolean } = {}): MediaCapabilityReport {
    return getMediaCapabilities(opts);
  }

  // ─── tts_generate / tts_list_voices ────────────────────────────────

  /** List the built-in Edge TTS voice presets. */
  listVoices(): { presets: Array<{ name: string; full: string }>; default: string } {
    return {
      presets: Object.entries(VOICE_PRESETS).map(([name, full]) => ({ name, full })),
      default: DEFAULT_VOICE,
    };
  }

  /**
   * Synthesise speech with Edge TTS. node-edge-tts is an optional peer
   * dependency — when it is absent this throws a clear, actionable
   * error instead of crashing. The MP3 is transcoded to OGG/Opus when
   * ffmpeg is available (so it can be sent as a voice note); otherwise
   * the raw MP3 is returned.
   */
  async ttsGenerate(opts: TtsGenerateOptions): Promise<MediaFileResult> {
    if (!opts.text || typeof opts.text !== 'string') {
      throw new Error('text is required for tts_generate');
    }
    const edge = detectBinary('edge-tts');
    if (!edge.available) {
      throw new Error(
        `tts_generate needs the node-edge-tts package. ${edge.installHint ?? ''}`.trim(),
      );
    }

    // Dynamic import — node-edge-tts is an OPTIONAL peer dependency,
    // loaded only on demand. The specifier is built from a variable so
    // the type-checker does not try to resolve it at build time when
    // the package is not installed; tsup marks it `external` too.
    const edgeTtsModule = 'node-edge-tts';
    const mod: any = await import(/* @vite-ignore */ edgeTtsModule);
    const EdgeTTSClass = mod.EdgeTTS ?? mod.default?.EdgeTTS ?? mod.default;
    if (!EdgeTTSClass) {
      throw new Error('node-edge-tts is installed but exposes no EdgeTTS class');
    }

    const resolvedVoice = VOICE_PRESETS[opts.voice?.toLowerCase() ?? ''] || opts.voice || DEFAULT_VOICE;
    const ttsOpts: Record<string, unknown> = { voice: resolvedVoice, timeout: 30_000 };
    if (opts.rate) ttsOpts.rate = opts.rate;
    if (opts.pitch) ttsOpts.pitch = opts.pitch;

    const tts = new EdgeTTSClass(ttsOpts);
    const mp3Path = this.outPath('tts', 'mp3');
    await tts.ttsPromise(opts.text, mp3Path);

    // Best-effort OGG/Opus transcode for voice-note delivery.
    if (detectBinary('ffmpeg').available) {
      const oggPath = mp3Path.replace(/\.mp3$/, '.ogg');
      try {
        await this.ffmpeg([
          '-i', mp3Path, '-ac', '1', '-map', '0:a',
          '-codec:a', 'libopus', '-b:a', '64k', '-vbr', 'on', oggPath, '-y',
        ]);
        return this.fileResult(oggPath, { format: 'ogg' });
      } catch {
        /* fall through to mp3 */
      }
    }
    return this.fileResult(mp3Path, { format: 'mp3' });
  }

  // ─── image_edit ────────────────────────────────────────────────────

  /** Edit an image with ImageMagick. */
  async imageEdit(opts: ImageEditOptions): Promise<MediaFileResult> {
    const input = validateInputPath(opts.input);
    const ext = safeExtension(opts.format, extname(input).slice(1) || 'png');
    const out = this.outPath('img', ext);

    switch (opts.action) {
      case 'resize': {
        if (!opts.width && !opts.height) throw new Error('width or height is required for resize');
        const w = opts.width ? clampNumber(opts.width, 1, 30_000, 1) : null;
        const h = opts.height ? clampNumber(opts.height, 1, 30_000, 1) : null;
        const geom = w && h ? `${w}x${h}` : w ? `${w}x` : `x${h}`;
        await this.magick([input, '-resize', geom, out]);
        return this.fileResult(out);
      }
      case 'crop': {
        if (!opts.width || !opts.height) throw new Error('width and height are required for crop');
        const w = clampNumber(opts.width, 1, 30_000, 1);
        const h = clampNumber(opts.height, 1, 30_000, 1);
        const ox = clampNumber(opts.offsetX, 0, 30_000, 0);
        const oy = clampNumber(opts.offsetY, 0, 30_000, 0);
        await this.magick([input, '-crop', `${w}x${h}+${ox}+${oy}`, '+repage', out]);
        return this.fileResult(out);
      }
      case 'rotate': {
        const angle = clampNumber(opts.angle, -360, 360, 90);
        await this.magick([input, '-rotate', String(angle), out]);
        return this.fileResult(out);
      }
      case 'convert': {
        if (!opts.format) throw new Error('format is required for convert');
        await this.magick([input, out]);
        return this.fileResult(out, { format: ext });
      }
      case 'compress': {
        const q = clampNumber(opts.quality, 1, 100, 80);
        await this.magick([input, '-quality', String(q), out]);
        return this.fileResult(out);
      }
      case 'text_overlay': {
        if (!opts.text) throw new Error('text is required for text_overlay');
        const size = clampNumber(opts.fontSize, 1, 2000, 36);
        const color = typeof opts.fontColor === 'string' ? opts.fontColor : 'white';
        const gravity = typeof opts.position === 'string' ? opts.position : 'south';
        await this.magick([
          input, '-gravity', gravity, '-pointsize', String(size),
          '-fill', color, '-stroke', 'black', '-strokewidth', '1',
          '-annotate', '+0+20', opts.text, out,
        ]);
        return this.fileResult(out);
      }
      case 'flip': {
        const op = opts.direction === 'vertical' ? '-flip' : '-flop';
        await this.magick([input, op, out]);
        return this.fileResult(out);
      }
      case 'blur': {
        const r = clampNumber(opts.blurRadius, 0, 1000, 5);
        await this.magick([input, '-blur', `0x${r}`, out]);
        return this.fileResult(out);
      }
      case 'sharpen': {
        await this.magick([input, '-sharpen', '0x2', out]);
        return this.fileResult(out);
      }
      case 'grayscale': {
        await this.magick([input, '-colorspace', 'Gray', out]);
        return this.fileResult(out);
      }
      default:
        throw new Error(`Unknown image action: ${(opts as { action?: string }).action}`);
    }
  }

  // ─── audio_edit ────────────────────────────────────────────────────

  /** Edit audio with ffmpeg. */
  async audioEdit(opts: AudioEditOptions): Promise<MediaFileResult> {
    switch (opts.action) {
      case 'trim': {
        const input = validateInputPath(opts.input);
        const out = this.outPath('aud', safeExtension(null, extname(input).slice(1) || 'mp3'));
        const a = ['-i', input];
        if (opts.start) a.push('-ss', String(opts.start));
        if (opts.end) a.push('-to', String(opts.end));
        else if (opts.duration) a.push('-t', String(opts.duration));
        a.push('-c', 'copy', '-y', out);
        await this.ffmpeg(a);
        return this.fileResult(out);
      }
      case 'convert': {
        const input = validateInputPath(opts.input);
        if (!opts.format) throw new Error('format is required for convert');
        const out = this.outPath('aud', safeExtension(opts.format, 'mp3'));
        await this.ffmpeg(['-i', input, '-y', out]);
        return this.fileResult(out);
      }
      case 'merge': {
        const files = opts.files ?? [];
        if (files.length < 2) throw new Error('At least 2 files are required for merge');
        files.forEach((f, i) => validateInputPath(f, `files[${i}]`));
        const listFile = this.outPath('concat', 'txt');
        writeFileSync(listFile, files.map((f) => `file '${f.replace(/'/g, "'\\''")}'`).join('\n'));
        const out = this.outPath('aud', safeExtension(null, extname(files[0]).slice(1) || 'mp3'));
        try {
          await this.ffmpeg(['-f', 'concat', '-safe', '0', '-i', listFile, '-c', 'copy', '-y', out]);
        } finally {
          this.tryUnlink(listFile);
        }
        return this.fileResult(out, { merged: files.length });
      }
      case 'volume': {
        const input = validateInputPath(opts.input);
        if (!opts.volume) throw new Error('volume is required (e.g. "1.5" or "10dB")');
        const out = this.outPath('aud', safeExtension(null, extname(input).slice(1) || 'mp3'));
        await this.ffmpeg(['-i', input, '-af', `volume=${opts.volume}`, '-y', out]);
        return this.fileResult(out);
      }
      case 'speed': {
        const input = validateInputPath(opts.input);
        const factor = clampNumber(opts.speedFactor, 0.5, 100, 0);
        if (!factor) throw new Error('speedFactor is required for speed');
        const out = this.outPath('aud', safeExtension(null, extname(input).slice(1) || 'mp3'));
        await this.ffmpeg(['-i', input, '-af', `atempo=${factor}`, '-y', out]);
        return this.fileResult(out);
      }
      case 'extract': {
        const input = validateInputPath(opts.input);
        const out = this.outPath('aud', safeExtension(opts.format, 'mp3'));
        await this.ffmpeg(['-i', input, '-vn', '-y', out]);
        return this.fileResult(out);
      }
      case 'reverse': {
        const input = validateInputPath(opts.input);
        const out = this.outPath('aud', safeExtension(null, extname(input).slice(1) || 'mp3'));
        await this.ffmpeg(['-i', input, '-af', 'areverse', '-y', out]);
        return this.fileResult(out);
      }
      case 'fade': {
        const input = validateInputPath(opts.input);
        const dur = clampNumber(opts.fadeDuration, 0.1, 3600, 3);
        const probe = await this.ffprobe(input);
        const totalDur = parseFloat(probe.format?.duration || '0');
        const out = this.outPath('aud', safeExtension(null, extname(input).slice(1) || 'mp3'));
        let af: string;
        if (opts.fadeType === 'in') af = `afade=t=in:st=0:d=${dur}`;
        else if (opts.fadeType === 'out') af = `afade=t=out:st=${Math.max(0, totalDur - dur)}:d=${dur}`;
        else af = `afade=t=in:st=0:d=${dur},afade=t=out:st=${Math.max(0, totalDur - dur)}:d=${dur}`;
        await this.ffmpeg(['-i', input, '-af', af, '-y', out]);
        return this.fileResult(out);
      }
      default:
        throw new Error(`Unknown audio action: ${(opts as { action?: string }).action}`);
    }
  }

  // ─── media_info ────────────────────────────────────────────────────

  /** Probe a media file's metadata with ffprobe. */
  async mediaInfo(input: string): Promise<MediaInfoResult> {
    const path = validateInputPath(input);
    const info = await this.ffprobe(path);
    const streams: MediaStreamInfo[] = (info.streams || []).map((s: any) => ({
      type: s.codec_type,
      codec: s.codec_name,
      width: s.width,
      height: s.height,
      duration: s.duration,
      bitRate: s.bit_rate,
      sampleRate: s.sample_rate,
      channels: s.channels,
      fps: s.r_frame_rate,
    }));
    return {
      ok: true,
      file: basename(path),
      format: info.format?.format_long_name,
      duration: info.format?.duration,
      sizeBytes: parseInt(info.format?.size || '0', 10),
      bitRate: info.format?.bit_rate,
      streams,
    };
  }

  // ─── video_edit ────────────────────────────────────────────────────

  /** Edit a video with ffmpeg (+ ImageMagick for caption rendering). */
  async videoEdit(opts: VideoEditOptions): Promise<MediaFileResult> {
    // `concatenate` does not use a single `input` — handled specially.
    if (opts.action === 'concatenate') return this.videoConcatenate(opts);

    const input = validateInputPath(opts.input);
    const srcExt = safeExtension(null, extname(input).slice(1) || 'mp4');

    switch (opts.action) {
      case 'trim': {
        const out = this.outPath('vid', srcExt);
        const a = ['-i', input];
        if (opts.start) a.push('-ss', String(opts.start));
        if (opts.end) a.push('-to', String(opts.end));
        else if (opts.duration) a.push('-t', String(opts.duration));
        a.push('-c', 'copy', '-y', out);
        await this.ffmpeg(a);
        return this.fileResult(out);
      }
      case 'extract_frame': {
        const out = this.outPath('frame', 'png');
        const t = String(opts.timestamp ?? opts.start ?? '0');
        await this.ffmpeg(['-ss', t, '-i', input, '-frames:v', '1', '-y', out]);
        return this.fileResult(out);
      }
      case 'extract_frames': {
        const dir = this.outDir('frames');
        const interval = clampNumber(opts.interval, 0.01, 3600, 1);
        await this.ffmpeg(
          ['-i', input, '-vf', `fps=1/${interval}`, join(dir, 'frame-%04d.png'), '-y'],
          TIMEOUT_LONG,
        );
        return { ok: true, filePath: dir, sizeBytes: 0, outputDir: dir } as MediaFileResult;
      }
      case 'convert': {
        if (!opts.format) throw new Error('format is required for convert');
        const out = this.outPath('vid', safeExtension(opts.format, 'mp4'));
        await this.ffmpeg(['-i', input, '-y', out], TIMEOUT_LONG);
        return this.fileResult(out);
      }
      case 'gif': {
        const out = this.outPath('vid', 'gif');
        const w = clampNumber(opts.width, 1, 4096, 480);
        const fps = clampNumber(opts.fps, 1, 60, 10);
        const a = ['-i', input];
        if (opts.start) a.push('-ss', String(opts.start));
        if (opts.duration) a.push('-t', String(opts.duration));
        a.push('-vf', `fps=${fps},scale=${w}:-1:flags=lanczos`, '-y', out);
        await this.ffmpeg(a, TIMEOUT_LONG);
        return this.fileResult(out);
      }
      case 'compress': {
        const out = this.outPath('vid', srcExt);
        const crf = clampNumber(opts.crf, 0, 51, 28);
        const a = ['-i', input, '-c:v', 'libx264', '-crf', String(crf), '-preset', 'medium'];
        if (opts.fps) a.push('-r', String(clampNumber(opts.fps, 1, 240, 30)));
        a.push('-c:a', 'aac', '-y', out);
        await this.ffmpeg(a, TIMEOUT_LONG);
        return this.fileResult(out);
      }
      case 'resize': {
        const out = this.outPath('vid', srcExt);
        let scale: string;
        if (opts.width && opts.height) scale = `${clampNumber(opts.width, 1, 8192, 1)}:${clampNumber(opts.height, 1, 8192, 1)}`;
        else if (opts.width) scale = `${clampNumber(opts.width, 1, 8192, 1)}:-2`;
        else if (opts.height) scale = `-2:${clampNumber(opts.height, 1, 8192, 1)}`;
        else throw new Error('width or height is required for resize');
        await this.ffmpeg(['-i', input, '-vf', `scale=${scale}`, '-c:a', 'copy', '-y', out], TIMEOUT_LONG);
        return this.fileResult(out);
      }
      case 'add_audio': {
        const audio = validateInputPath(opts.audioPath, 'audioPath');
        const out = this.outPath('vid', srcExt);
        await this.ffmpeg([
          '-i', input, '-i', audio, '-c:v', 'copy', '-c:a', 'aac',
          '-map', '0:v:0', '-map', '1:a:0', '-shortest', '-y', out,
        ], TIMEOUT_LONG);
        return this.fileResult(out);
      }
      case 'remove_audio': {
        const out = this.outPath('vid', srcExt);
        await this.ffmpeg(['-i', input, '-c:v', 'copy', '-an', '-y', out]);
        return this.fileResult(out);
      }
      case 'speed': {
        const factor = clampNumber(opts.speedFactor, 0.25, 100, 0);
        if (!factor) throw new Error('speedFactor is required for speed');
        const out = this.outPath('vid', srcExt);
        const vf = `setpts=${(1 / factor).toFixed(4)}*PTS`;
        await this.ffmpeg(['-i', input, '-vf', vf, '-af', `atempo=${factor}`, '-y', out], TIMEOUT_LONG);
        return this.fileResult(out);
      }
      case 'color_grade':
        return this.videoColorGrade(input, opts);
      case 'transition':
        return this.videoTransition(input, opts);
      case 'text_overlay':
        return this.videoTextOverlay(input, opts);
      case 'picture_in_picture':
        return this.videoPictureInPicture(input, opts);
      case 'split_screen':
        return this.videoSplitScreen(input, opts);
      case 'ken_burns':
        return this.videoKenBurns(input, opts);
      case 'slow_motion':
        return this.videoSlowMotion(input, srcExt, opts);
      case 'watermark':
        return this.videoWatermark(input, srcExt, opts);
      case 'audio_mix':
        return this.videoAudioMix(input, srcExt, opts);
      case 'auto_caption':
        return this.videoAutoCaption(input, opts);
      default:
        throw new Error(`Unknown video action: ${(opts as { action?: string }).action}`);
    }
  }

  private async videoColorGrade(input: string, opts: VideoEditOptions): Promise<MediaFileResult> {
    const out = this.outPath('vid', safeExtension(null, extname(input).slice(1) || 'mp4'));
    let vf: string;
    if (opts.lutPath) {
      const lut = validateInputPath(opts.lutPath, 'lutPath');
      vf = `lut3d=${lut}`;
    } else {
      const presets: Record<string, string> = {
        warm: 'colorbalance=rs=0.15:gs=0.05:bs=-0.1:rm=0.1:gm=0.05:bm=-0.05,eq=contrast=1.05:saturation=1.1',
        cool: 'colorbalance=rs=-0.1:gs=0.0:bs=0.15:rm=-0.05:gm=0.02:bm=0.1,eq=contrast=1.05:saturation=1.05',
        vintage: 'colorbalance=rs=0.1:gs=0.05:bs=-0.15:rh=0.05:gh=-0.02:bh=-0.1,eq=contrast=1.1:saturation=0.8:gamma=1.1',
        cinematic: 'colorbalance=rs=0.02:gs=-0.05:bs=0.08:rm=0.0:gm=-0.03:bm=0.05,eq=contrast=1.15:saturation=0.85:brightness=-0.03',
        dramatic: 'colorbalance=rs=0.05:gs=-0.08:bs=0.1:rm=0.03:gm=-0.05:bm=0.07,eq=contrast=1.3:saturation=0.9:brightness=-0.05',
        bleach: 'eq=contrast=1.4:saturation=0.4:brightness=0.05:gamma=1.1',
        noir: 'eq=contrast=1.3:saturation=0.0:brightness=-0.05:gamma=0.9',
        vivid: 'eq=contrast=1.1:saturation=1.5:brightness=0.02',
        muted: 'eq=contrast=0.9:saturation=0.6:brightness=0.05:gamma=1.1',
        golden_hour: 'colorbalance=rs=0.2:gs=0.1:bs=-0.15:rm=0.15:gm=0.08:bm=-0.1,eq=contrast=1.05:saturation=1.15:brightness=0.03',
      };
      const preset = typeof opts.colorPreset === 'string' ? opts.colorPreset : 'cinematic';
      vf = presets[preset] ?? presets.cinematic;
    }
    await this.ffmpeg(['-i', input, '-vf', vf, '-c:a', 'copy', '-y', out], TIMEOUT_LONG);
    return this.fileResult(out, { preset: opts.lutPath ? 'custom LUT' : opts.colorPreset ?? 'cinematic' });
  }

  private async videoTransition(input: string, opts: VideoEditOptions): Promise<MediaFileResult> {
    const second = validateInputPath(opts.secondInput, 'secondInput');
    const out = this.outPath('vid', 'mp4');
    const tType = typeof opts.transitionType === 'string' ? opts.transitionType : 'fade';
    const tDur = clampNumber(opts.transitionDuration, 0.1, 30, 1);
    const probe1 = await this.ffprobe(input);
    const dur1 = parseFloat(probe1.format?.duration || '5');
    const offset = Math.max(0, dur1 - tDur);
    await this.ffmpeg([
      '-i', input, '-i', second,
      '-filter_complex',
      `[0:v]settb=AVTB[v0];[1:v]settb=AVTB[v1];[v0][v1]xfade=transition=${tType}:duration=${tDur}:offset=${offset}[vout];[0:a][1:a]acrossfade=d=${tDur}[aout]`,
      '-map', '[vout]', '-map', '[aout]',
      '-c:v', 'libx264', '-crf', '18', '-preset', 'medium', '-c:a', 'aac', '-y', out,
    ], TIMEOUT_LONG);
    return this.fileResult(out, { transition: tType, duration: tDur });
  }

  private async videoTextOverlay(input: string, opts: VideoEditOptions): Promise<MediaFileResult> {
    if (!opts.text) throw new Error('text is required for text_overlay');
    const out = this.outPath('vid', safeExtension(null, extname(input).slice(1) || 'mp4'));
    const probeV = await this.ffprobe(input);
    const vStream = (probeV.streams || []).find((s: any) => s.codec_type === 'video');
    const vw = vStream?.width || 1920;
    const vh = vStream?.height || 1080;
    const vDuration = parseFloat(probeV.format?.duration || '10');
    const fontSize = clampNumber(opts.fontSize, 1, 2000, 72);
    const fontColor = typeof opts.fontColor === 'string' ? opts.fontColor : 'white';
    const textPng = this.outPath('textoverlay', 'png');
    const posMap: Record<string, string> = {
      center: 'Center', top: 'North', bottom: 'South',
      'top-left': 'NorthWest', 'top-right': 'NorthEast',
      'bottom-left': 'SouthWest', 'bottom-right': 'SouthEast',
    };
    const gravity = posMap[opts.textPosition ?? 'center'] ?? 'Center';
    const magickArgs = [
      '-size', `${vw}x${vh}`, 'xc:none', '-gravity', gravity,
      '-pointsize', String(fontSize), '-fill', fontColor,
      '-stroke', 'black', '-strokewidth', '2',
    ];
    if (opts.textBg) magickArgs.push('-undercolor', opts.textBg);
    magickArgs.push('-annotate', '0', opts.text, textPng);
    await this.magick(magickArgs);

    const tStart = String(opts.textStart ?? '0');
    const tEnd = String(opts.textEnd ?? vDuration);
    try {
      await this.ffmpeg([
        '-i', input, '-i', textPng,
        '-filter_complex',
        `[1:v]format=rgba[txt];[0:v][txt]overlay=0:0:enable='between(t,${tStart},${tEnd})'[vout]`,
        '-map', '[vout]', '-map', '0:a?',
        '-c:v', 'libx264', '-crf', '18', '-c:a', 'copy', '-y', out,
      ], TIMEOUT_LONG);
    } finally {
      this.tryUnlink(textPng);
    }
    return this.fileResult(out);
  }

  private async videoPictureInPicture(input: string, opts: VideoEditOptions): Promise<MediaFileResult> {
    const second = validateInputPath(opts.secondInput, 'secondInput');
    const out = this.outPath('vid', 'mp4');
    const pipW = clampNumber(opts.pipWidth, 16, 4096, 320);
    const margin = 20;
    let overlayPos: string;
    switch (opts.pipPosition) {
      case 'top-left': overlayPos = `${margin}:${margin}`; break;
      case 'top-right': overlayPos = `main_w-overlay_w-${margin}:${margin}`; break;
      case 'bottom-left': overlayPos = `${margin}:main_h-overlay_h-${margin}`; break;
      default: overlayPos = `main_w-overlay_w-${margin}:main_h-overlay_h-${margin}`;
    }
    await this.ffmpeg([
      '-i', input, '-i', second,
      '-filter_complex', `[1:v]scale=${pipW}:-2[pip];[0:v][pip]overlay=${overlayPos}[vout]`,
      '-map', '[vout]', '-map', '0:a?',
      '-c:v', 'libx264', '-crf', '18', '-c:a', 'copy', '-shortest', '-y', out,
    ], TIMEOUT_LONG);
    return this.fileResult(out, { pipPosition: opts.pipPosition ?? 'bottom-right' });
  }

  private async videoSplitScreen(input: string, opts: VideoEditOptions): Promise<MediaFileResult> {
    const second = validateInputPath(opts.secondInput, 'secondInput');
    const out = this.outPath('vid', 'mp4');
    const dir = opts.splitDirection === 'vertical' ? 'vertical' : 'horizontal';
    const probeS = await this.ffprobe(input);
    const sStream = (probeS.streams || []).find((s: any) => s.codec_type === 'video');
    const sw = sStream?.width || 1920;
    const sh = sStream?.height || 1080;
    let filterComplex: string;
    if (dir === 'horizontal') {
      const halfW = Math.floor(sw / 2);
      filterComplex = `[0:v]scale=${halfW}:${sh}:force_original_aspect_ratio=decrease,pad=${halfW}:${sh}:(ow-iw)/2:(oh-ih)/2[left];[1:v]scale=${halfW}:${sh}:force_original_aspect_ratio=decrease,pad=${halfW}:${sh}:(ow-iw)/2:(oh-ih)/2[right];[left][right]hstack[vout]`;
    } else {
      const halfH = Math.floor(sh / 2);
      filterComplex = `[0:v]scale=${sw}:${halfH}:force_original_aspect_ratio=decrease,pad=${sw}:${halfH}:(ow-iw)/2:(oh-ih)/2[top];[1:v]scale=${sw}:${halfH}:force_original_aspect_ratio=decrease,pad=${sw}:${halfH}:(ow-iw)/2:(oh-ih)/2[bottom];[top][bottom]vstack[vout]`;
    }
    await this.ffmpeg([
      '-i', input, '-i', second, '-filter_complex', filterComplex,
      '-map', '[vout]', '-map', '0:a?',
      '-c:v', 'libx264', '-crf', '18', '-c:a', 'copy', '-shortest', '-y', out,
    ], TIMEOUT_LONG);
    return this.fileResult(out, { direction: dir });
  }

  private async videoKenBurns(input: string, opts: VideoEditOptions): Promise<MediaFileResult> {
    const out = this.outPath('vid', 'mp4');
    const dur = clampNumber(opts.zoomDuration, 0.5, 600, 5);
    const zoom = clampNumber(opts.zoomFactor, 1, 3, 1.5);
    const outputFps = clampNumber(opts.fps, 1, 60, 30);
    const totalFrames = Math.round(dur * outputFps);
    const direction = typeof opts.zoomDirection === 'string' ? opts.zoomDirection : 'zoom_in';
    let zp: string;
    switch (direction) {
      case 'zoom_out':
        zp = `zoompan=z='${zoom}-on*(${zoom}-1)/${totalFrames}':d=${totalFrames}:s=1920x1080:fps=${outputFps}`;
        break;
      case 'pan_left':
        zp = `zoompan=z='${zoom}':x='iw-iw/${zoom}-on*(iw-iw/${zoom})/${totalFrames}':y='(ih-ih/${zoom})/2':d=${totalFrames}:s=1920x1080:fps=${outputFps}`;
        break;
      case 'pan_right':
        zp = `zoompan=z='${zoom}':x='on*(iw-iw/${zoom})/${totalFrames}':y='(ih-ih/${zoom})/2':d=${totalFrames}:s=1920x1080:fps=${outputFps}`;
        break;
      case 'pan_up':
        zp = `zoompan=z='${zoom}':x='(iw-iw/${zoom})/2':y='ih-ih/${zoom}-on*(ih-ih/${zoom})/${totalFrames}':d=${totalFrames}:s=1920x1080:fps=${outputFps}`;
        break;
      case 'pan_down':
        zp = `zoompan=z='${zoom}':x='(iw-iw/${zoom})/2':y='on*(ih-ih/${zoom})/${totalFrames}':d=${totalFrames}:s=1920x1080:fps=${outputFps}`;
        break;
      default:
        zp = `zoompan=z='1+on*(${zoom}-1)/${totalFrames}':d=${totalFrames}:s=1920x1080:fps=${outputFps}`;
    }
    await this.ffmpeg([
      '-loop', '1', '-i', input, '-vf', zp, '-t', String(dur),
      '-c:v', 'libx264', '-pix_fmt', 'yuv420p', '-y', out,
    ], TIMEOUT_LONG);
    return this.fileResult(out, { direction, duration: dur, zoomFactor: zoom });
  }

  private async videoSlowMotion(input: string, srcExt: string, opts: VideoEditOptions): Promise<MediaFileResult> {
    const out = this.outPath('vid', srcExt);
    const factor = clampNumber(opts.speedFactor, 0.1, 1, 0.5);
    const targetFps = clampNumber(opts.fps, 1, 240, 60);
    await this.ffmpeg([
      '-i', input,
      '-vf', `minterpolate=fps=${targetFps}:mi_mode=mci:mc_mode=aobmc:me_mode=bidir:vsbmc=1,setpts=${(1 / factor).toFixed(4)}*PTS`,
      '-af', `atempo=${factor}`,
      '-c:v', 'libx264', '-crf', '18', '-preset', 'slow', '-c:a', 'aac', '-y', out,
    ], TIMEOUT_LONG);
    return this.fileResult(out, { speedFactor: factor, interpolatedFps: targetFps });
  }

  private async videoWatermark(input: string, srcExt: string, opts: VideoEditOptions): Promise<MediaFileResult> {
    const wmPath = validateInputPath(opts.watermarkPath ?? opts.secondInput, 'watermarkPath');
    const out = this.outPath('vid', srcExt);
    const opacity = clampNumber(opts.overlayOpacity, 0, 1, 0.7);
    const scale = clampNumber(opts.overlayScale, 0.01, 1, 0.2);
    const margin = 20;
    let overlayExpr: string;
    switch (opts.watermarkPosition) {
      case 'top-left': overlayExpr = `${margin}:${margin}`; break;
      case 'top-right': overlayExpr = `main_w-overlay_w-${margin}:${margin}`; break;
      case 'bottom-left': overlayExpr = `${margin}:main_h-overlay_h-${margin}`; break;
      case 'center': overlayExpr = `(main_w-overlay_w)/2:(main_h-overlay_h)/2`; break;
      default: overlayExpr = `main_w-overlay_w-${margin}:main_h-overlay_h-${margin}`;
    }
    const probeWm = await this.ffprobe(input);
    const wmStream = (probeWm.streams || []).find((s: any) => s.codec_type === 'video');
    const wmTargetW = Math.round((wmStream?.width || 1920) * scale);
    await this.ffmpeg([
      '-i', input, '-i', wmPath,
      '-filter_complex',
      `[1:v]scale=${wmTargetW}:-2,format=rgba,colorchannelmixer=aa=${opacity}[wm];[0:v][wm]overlay=${overlayExpr}[vout]`,
      '-map', '[vout]', '-map', '0:a?',
      '-c:v', 'libx264', '-crf', '18', '-c:a', 'copy', '-y', out,
    ], TIMEOUT_LONG);
    return this.fileResult(out, { watermarkPosition: opts.watermarkPosition ?? 'bottom-right', opacity, scale });
  }

  private async videoConcatenate(opts: VideoEditOptions): Promise<MediaFileResult> {
    const files = opts.files ?? [];
    if (files.length < 2) throw new Error('At least 2 files are required for concatenate');
    files.forEach((f, i) => validateInputPath(f, `files[${i}]`));
    const out = this.outPath('vid', 'mp4');
    const listFile = this.outPath('concat', 'txt');
    writeFileSync(listFile, files.map((f) => `file '${f.replace(/'/g, "'\\''")}'`).join('\n'));
    try {
      try {
        // Fast path — concat demuxer, lossless, for same-format clips.
        await this.ffmpeg(['-f', 'concat', '-safe', '0', '-i', listFile, '-c', 'copy', '-y', out], TIMEOUT_LONG);
      } catch {
        // Fallback — re-encode via filter_complex for mixed formats.
        const inputs = files.flatMap((f) => ['-i', f]);
        const filterParts = files.map((_, i) => `[${i}:v:0][${i}:a:0]`).join('');
        await this.ffmpeg([
          ...inputs,
          '-filter_complex', `${filterParts}concat=n=${files.length}:v=1:a=1[vout][aout]`,
          '-map', '[vout]', '-map', '[aout]',
          '-c:v', 'libx264', '-crf', '18', '-c:a', 'aac', '-y', out,
        ], TIMEOUT_LONG);
      }
    } finally {
      this.tryUnlink(listFile);
    }
    return this.fileResult(out, { clips: files.length });
  }

  private async videoAudioMix(input: string, srcExt: string, opts: VideoEditOptions): Promise<MediaFileResult> {
    const audio = validateInputPath(opts.audioPath, 'audioPath');
    const out = this.outPath('vid', srcExt);
    const bgVol = typeof opts.bgVolume === 'string' ? opts.bgVolume : '0.3';
    const fgVol = typeof opts.fgVolume === 'string' ? opts.fgVolume : '1.0';
    await this.ffmpeg([
      '-i', input, '-i', audio,
      '-filter_complex',
      `[0:a]volume=${fgVol}[fg];[1:a]volume=${bgVol}[bg];[fg][bg]amix=inputs=2:duration=first:dropout_transition=2[aout]`,
      '-map', '0:v', '-map', '[aout]',
      '-c:v', 'copy', '-c:a', 'aac', '-y', out,
    ], TIMEOUT_LONG);
    return this.fileResult(out, { fgVolume: fgVol, bgVolume: bgVol });
  }

  /**
   * Burn dynamic word-chunked captions onto a video. Needs ffmpeg,
   * ImageMagick, and whisper.cpp (with a model file). Mirrors the
   * source MCP's CapCut-style caption renderer.
   */
  private async videoAutoCaption(input: string, opts: VideoEditOptions): Promise<MediaFileResult> {
    const model = requireWhisperModel(opts.whisperModel);
    const whisper = requireBinary('whisper');
    requireBinary('imagemagick');

    const out = this.outPath('captioned', 'mp4');
    const probeC = await this.ffprobe(input);
    const cStream = (probeC.streams || []).find((s: any) => s.codec_type === 'video');
    const vW = cStream?.width || 1080;
    const vH = cStream?.height || 1920;
    const totalDur = parseFloat(probeC.format?.duration || '60');

    // 1. Extract a 16 kHz mono WAV for whisper.
    const wavPath = this.outPath('caption-audio', 'wav');
    await this.ffmpeg(['-i', input, '-ar', '16000', '-ac', '1', '-c:a', 'pcm_s16le', '-y', wavPath], TIMEOUT_FAST);

    // 2. Word-level transcription.
    const srtBase = this.outPath('caption-srt', 'tmp');
    const srtStem = srtBase.replace(/\.tmp$/, '');
    await execFileAsync(whisper, [
      '-m', model, '-f', wavPath,
      '--max-len', '1', '--split-on-word',
      '--output-srt', '--output-file', srtStem,
    ], { timeout: TIMEOUT_LONG, maxBuffer: MAX_BUFFER });

    const words = this.parseSrt(srtStem);
    if (words.length === 0) throw new Error('No speech found in the video');

    // 3. Group words into 2-4 word chunks.
    const chunks: Array<{ text: string; s: number; e: number; wc: number }> = [];
    let ci = 0;
    while (ci < words.length) {
      const sz = [3, 2, 4, 3, 2, 3][chunks.length % 6];
      const slice = words.slice(ci, ci + sz);
      if (slice.length > 0) {
        chunks.push({
          text: slice.map((w) => w.text).join(' '),
          s: slice[0].start,
          e: slice[slice.length - 1].end,
          wc: slice.length,
        });
      }
      ci += sz;
    }

    // 4. Render caption PNGs.
    const capColor = typeof opts.captionColor === 'string' ? opts.captionColor : 'white';
    const maxTextW = vW - 80;
    const baseFont = clampNumber(opts.captionFontSize, 8, 400, Math.max(48, Math.round(vW / 16)));
    const cornerRadius = Math.round(baseFont * 0.35);
    const bgColors = [
      'rgba(255,215,0,0.85)', 'rgba(0,200,120,0.85)', 'rgba(255,100,100,0.85)',
      'rgba(100,150,255,0.85)', 'rgba(255,140,0,0.85)', 'rgba(200,100,255,0.85)',
    ];
    const third = totalDur / 3;
    const getPosition = (t: number): { gravity: string; yOff: number } => {
      if (t < third) return { gravity: 'South', yOff: Math.round(vH * 0.22) };
      if (t < third * 2) return { gravity: 'Center', yOff: 0 };
      return { gravity: 'North', yOff: Math.round(vH * 0.06) };
    };

    const captionDir = this.outDir('captions');
    const overlays: Array<{ png: string; start: number; end: number }> = [];
    for (let i = 0; i < chunks.length; i++) {
      const chunk = chunks[i];
      const pos = getPosition(chunk.s);
      const bg = bgColors[i % bgColors.length];
      const sizeMult = chunk.wc <= 2 ? 1.4 : chunk.wc <= 3 ? 1.1 : 1.0;
      const fontSize = Math.round(baseFont * sizeMult);
      const txtPng = join(captionDir, `txt-${i}.png`);
      const bgPng = join(captionDir, `bg-${i}.png`);
      const finalPng = join(captionDir, `c-${String(i).padStart(4, '0')}.png`);

      await this.magick([
        '-size', `${maxTextW}x`, '-background', 'none', '-gravity', 'Center',
        '-font', 'Helvetica-Bold', '-pointsize', String(fontSize),
        '-fill', capColor, '-stroke', 'black', '-strokewidth', '2',
        `caption:${chunk.text}`, '-trim', '+repage', txtPng,
      ]);
      const dims = (await this.magickIdentify(['-format', '%wx%h', txtPng])).trim();
      const [tw, th] = dims.split('x').map(Number);
      const pw = (tw || 100) + 40;
      const ph = (th || 50) + 24;
      await this.magick([
        '-size', `${pw}x${ph}`, 'xc:none', '-fill', bg,
        '-draw', `roundrectangle 0,0 ${pw - 1},${ph - 1} ${cornerRadius},${cornerRadius}`,
        txtPng, '-gravity', 'Center', '-composite', bgPng,
      ]);
      await this.magick([
        '-size', `${vW}x${vH}`, 'xc:none', bgPng,
        '-gravity', pos.gravity, '-geometry', `+0+${pos.yOff}`, '-composite', finalPng,
      ]);
      this.tryUnlink(txtPng);
      this.tryUnlink(bgPng);
      overlays.push({ png: finalPng, start: chunk.s, end: chunk.e });
    }

    // 5. Overlay captions in batches of 8.
    const batchSize = 8;
    let currentInput = input;
    for (let b = 0; b < overlays.length; b += batchSize) {
      const batchEnd = Math.min(b + batchSize, overlays.length);
      const batch = overlays.slice(b, batchEnd);
      const isLast = batchEnd >= overlays.length;
      const batchOut = isLast ? out : this.outPath('caption-batch', 'mp4');
      const inputs = ['-i', currentInput];
      batch.forEach((o) => inputs.push('-i', o.png));
      let filterComplex = '';
      let prevLabel = '0:v';
      batch.forEach((o, i) => {
        const outLabel = i === batch.length - 1 ? 'vout' : `v${i}`;
        filterComplex += `[${prevLabel}][${i + 1}:v]overlay=0:0:enable='between(t,${o.start.toFixed(3)},${o.end.toFixed(3)})'[${outLabel}];`;
        prevLabel = outLabel;
      });
      filterComplex = filterComplex.slice(0, -1);
      await this.ffmpeg([
        ...inputs, '-filter_complex', filterComplex,
        '-map', '[vout]', '-map', '0:a?',
        '-c:v', 'libx264', '-crf', isLast ? '18' : '10',
        '-preset', isLast ? 'medium' : 'ultrafast',
        '-c:a', isLast ? 'aac' : 'copy', ...(isLast ? ['-b:a', '128k'] : []),
        '-y', batchOut,
      ], TIMEOUT_LONG);
      if (currentInput !== input) this.tryUnlink(currentInput);
      currentInput = batchOut;
    }

    // 6. Cleanup.
    this.tryUnlink(wavPath);
    this.tryUnlinkSrt(srtStem);
    this.tryRmDir(captionDir);

    return this.fileResult(out, { chunks: chunks.length, captionPosition: 'dynamic (bottom → center → top)' });
  }

  // ─── video_understand ──────────────────────────────────────────────

  /**
   * Analyse a video — extract frames at intervals and (when a whisper
   * model is given) transcribe the audio — and return a merged
   * timeline of what is shown and said.
   */
  async videoUnderstand(opts: VideoUnderstandOptions): Promise<VideoUnderstandResult> {
    const input = validateInputPath(opts.input);
    const probe = await this.ffprobe(input);
    const vStream = (probe.streams || []).find((s: any) => s.codec_type === 'video');
    const totalDur = parseFloat(probe.format?.duration || '0');
    const vW = vStream?.width || 0;
    const vH = vStream?.height || 0;
    const rotation = parseInt(vStream?.tags?.rotate || '0', 10);

    const interval = clampNumber(opts.frameInterval, 0.1, 3600, 3);
    const maxFrames = clampNumber(opts.maxFrames, 1, 500, 30);
    const frameCount = Math.min(maxFrames, Math.ceil(totalDur / interval) || 1);

    // 1. Frames.
    const frameDir = this.outDir('understand');
    const frames: Array<{ time: number; path: string }> = [];
    for (let i = 0; i < frameCount; i++) {
      const t = i * interval;
      if (t >= totalDur && totalDur > 0) break;
      const framePath = join(frameDir, `frame-${String(i).padStart(3, '0')}.jpg`);
      await this.ffmpeg(['-ss', String(t), '-i', input, '-frames:v', '1', '-q:v', '3', '-y', framePath], TIMEOUT_FAST);
      frames.push({ time: t, path: framePath });
    }

    // 2. Optional transcription — only when a whisper model is supplied
    //    AND whisper.cpp is installed. Absence degrades gracefully:
    //    the timeline is still returned, just without spoken text.
    const transcript: Array<{ start: number; end: number; text: string }> = [];
    if (opts.whisperModel && existsSync(opts.whisperModel) && detectBinary('whisper').available) {
      const whisper = requireBinary('whisper');
      const wavPath = this.outPath('understand-audio', 'wav');
      await this.ffmpeg(['-i', input, '-ar', '16000', '-ac', '1', '-c:a', 'pcm_s16le', '-y', wavPath], TIMEOUT_FAST);
      const srtStem = this.outPath('understand-srt', 'tmp').replace(/\.tmp$/, '');
      try {
        await execFileAsync(whisper, [
          '-m', opts.whisperModel, '-f', wavPath,
          '--output-srt', '--output-file', srtStem,
        ], { timeout: TIMEOUT_LONG, maxBuffer: MAX_BUFFER });
        for (const w of this.parseSrt(srtStem)) {
          transcript.push({ start: w.start, end: w.end, text: w.text });
        }
      } catch {
        /* transcription failed — keep the visual timeline */
      } finally {
        this.tryUnlink(wavPath);
        this.tryUnlinkSrt(srtStem);
      }
    }

    // 3. Merge frames + transcript into a timeline.
    const timeline: VideoTimelineEntry[] = frames.map((f) => {
      const speech = transcript.filter((s) => s.start <= f.time + interval && s.end >= f.time);
      const spoken = speech.map((s) => s.text).join(' ').trim();
      return {
        timeSeconds: f.time,
        timeDisplay: `${Math.floor(f.time / 60)}:${String(Math.floor(f.time % 60)).padStart(2, '0')}`,
        framePath: f.path,
        spokenText: spoken || (transcript.length ? '(silence)' : '(transcription unavailable)'),
      };
    });

    return {
      ok: true,
      video: basename(input),
      duration: totalDur,
      resolution: rotation ? `${vH}x${vW} (rotated ${rotation})` : `${vW}x${vH}`,
      totalFramesExtracted: frames.length,
      transcriptSegments: transcript.length,
      timeline,
      frameDir,
      hint: 'Read the frame images to see what happens visually at each timestamp; combine with spokenText to understand the video before editing.',
    };
  }

  // ─── voice_clone ───────────────────────────────────────────────────

  /**
   * Synthesise speech in a reference voice with F5-TTS. Needs a Python
   * interpreter that has the `f5-tts` and `soundfile` packages. The
   * reference audio + transcript MUST be supplied by the caller — no
   * built-in voice profile. The Python is run via execFile with an
   * argument array; the script and its inputs are written to a temp
   * file and passed by path, so no caller value is interpolated into a
   * command line.
   */
  async voiceClone(opts: VoiceCloneOptions): Promise<MediaFileResult> {
    if (!opts.text || typeof opts.text !== 'string') {
      throw new Error('text is required for voice_clone');
    }
    const refAudio = validateInputPath(opts.refAudio, 'refAudio');
    if (!opts.refText || typeof opts.refText !== 'string') {
      throw new Error('refText is required for voice_clone (the transcript of the reference audio)');
    }
    const pythonBin = opts.pythonBin && isAbsolute(opts.pythonBin)
      ? validateInputPath(opts.pythonBin, 'pythonBin')
      : requireBinary('python');
    const device = typeof opts.device === 'string' && /^[a-z0-9]+$/i.test(opts.device) ? opts.device : 'cpu';

    const outWav = this.outPath('voiceclone', 'wav');

    // The script reads its inputs from a JSON sidecar file (passed by
    // path as an argv element) rather than having them interpolated
    // into the source — this keeps untrusted text fully out of any
    // code/command string.
    const paramsFile = this.outPath('voiceclone-params', 'json');
    writeFileSync(paramsFile, JSON.stringify({
      ref_file: refAudio,
      ref_text: opts.refText,
      gen_text: opts.text,
      out_path: outWav,
      device,
    }));
    const pyScript = [
      'import json, sys, soundfile as sf',
      'from f5_tts.api import F5TTS',
      'p = json.load(open(sys.argv[1]))',
      'tts = F5TTS(device=p["device"])',
      'wav, sr, _ = tts.infer(ref_file=p["ref_file"], ref_text=p["ref_text"], gen_text=p["gen_text"])',
      'sf.write(p["out_path"], wav, sr)',
      'print("ok")',
    ].join('\n');

    try {
      await execFileAsync(pythonBin, ['-c', pyScript, paramsFile], {
        timeout: TIMEOUT_LONG,
        maxBuffer: MAX_BUFFER,
      });
    } finally {
      this.tryUnlink(paramsFile);
    }

    // Best-effort OGG/Opus transcode for voice-note delivery.
    if (detectBinary('ffmpeg').available) {
      const outOgg = outWav.replace(/\.wav$/, '.ogg');
      try {
        await this.ffmpeg([
          '-i', outWav, '-ac', '1', '-codec:a', 'libopus',
          '-b:a', '64k', '-vbr', 'on', outOgg, '-y',
        ]);
        if (existsSync(outOgg)) return this.fileResult(outOgg, { format: 'ogg' });
      } catch {
        /* fall through to wav */
      }
    }
    return this.fileResult(outWav, { format: 'wav' });
  }

  // ─── shared helpers ────────────────────────────────────────────────

  /** Parse a whisper-produced SRT (located by stem) into timed segments. */
  private parseSrt(srtStem: string): Array<{ start: number; end: number; text: string }> {
    let srtFile = `${srtStem}.srt`;
    if (!existsSync(srtFile)) {
      const dir = dirname(srtStem);
      const stem = basename(srtStem);
      try {
        const candidates = readdirSync(dir).filter((f) => f.includes(stem) && f.endsWith('.srt'));
        if (candidates.length > 0) srtFile = join(dir, candidates[0]);
      } catch {
        /* no dir / no candidates */
      }
    }
    if (!existsSync(srtFile)) return [];
    const out: Array<{ start: number; end: number; text: string }> = [];
    const content = readFileSync(srtFile, 'utf8');
    for (const block of content.trim().split(/\n\n+/)) {
      const lines = block.trim().split('\n');
      if (lines.length < 3) continue;
      const m = lines[1].match(/(\d{2}):(\d{2}):(\d{2}),(\d{3})\s*-->\s*(\d{2}):(\d{2}):(\d{2}),(\d{3})/);
      if (!m) continue;
      const start = +m[1] * 3600 + +m[2] * 60 + +m[3] + +m[4] / 1000;
      const end = +m[5] * 3600 + +m[6] * 60 + +m[7] + +m[8] / 1000;
      const text = lines.slice(2).join(' ').trim();
      if (text && end > start) out.push({ start, end, text });
    }
    return out;
  }

  /** Unlink a file, swallowing any error (cleanup best-effort). */
  private tryUnlink(path: string): void {
    try { unlinkSync(path); } catch { /* already gone */ }
  }

  /** Remove the SRT(s) produced for a given stem. */
  private tryUnlinkSrt(srtStem: string): void {
    this.tryUnlink(`${srtStem}.srt`);
    try {
      const dir = dirname(srtStem);
      const stem = basename(srtStem);
      for (const f of readdirSync(dir)) {
        if (f.includes(stem) && f.endsWith('.srt')) this.tryUnlink(join(dir, f));
      }
    } catch { /* nothing to clean */ }
  }

  /** Recursively remove a directory, swallowing errors. */
  private tryRmDir(dir: string): void {
    try { rmSync(dir, { recursive: true, force: true }); } catch { /* ignore */ }
  }
}
