ffmpeg-runner.mjs 3.3 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384
  1. // fmode-ffmpeg runner — resolve and invoke the bundled ffmpeg/ffprobe binaries.
  2. //
  3. // The skill directory is copied into .claude/skills/ WITHOUT node_modules, so we
  4. // never `import 'ffmpeg-static'` directly. Instead we shell out to the published
  5. // package via `npx --yes fmode-ffmpeg ...`, which resolves the platform binary
  6. // from the package's own dependencies. Resolved paths are cached per process.
  7. //
  8. // You can override resolution with the FFMPEG_PATH / FFPROBE_PATH env vars, which
  9. // is also the fast path when ffmpeg is already on the machine.
  10. import { spawn, spawnSync } from 'node:child_process';
  11. import { existsSync } from 'node:fs';
  12. const NPX_PKG = 'fmode-ffmpeg@latest';
  13. let _ffmpeg = null;
  14. let _ffprobe = null;
  15. function npxResolve(subcommand) {
  16. const res = spawnSync('npx', ['--yes', NPX_PKG, subcommand], { encoding: 'utf8' });
  17. if (res.status === 0) {
  18. const out = (res.stdout || '').trim().split('\n').pop().trim();
  19. if (out && existsSync(out)) return out;
  20. }
  21. return null;
  22. }
  23. /** Absolute path to a usable ffmpeg binary. Falls back to "ffmpeg" on PATH. */
  24. export function ffmpegPath() {
  25. if (_ffmpeg) return _ffmpeg;
  26. if (process.env.FFMPEG_PATH && existsSync(process.env.FFMPEG_PATH)) {
  27. return (_ffmpeg = process.env.FFMPEG_PATH);
  28. }
  29. return (_ffmpeg = npxResolve('which') || 'ffmpeg');
  30. }
  31. /** Absolute path to a usable ffprobe binary. Falls back to "ffprobe" on PATH. */
  32. export function ffprobePath() {
  33. if (_ffprobe) return _ffprobe;
  34. if (process.env.FFPROBE_PATH && existsSync(process.env.FFPROBE_PATH)) {
  35. return (_ffprobe = process.env.FFPROBE_PATH);
  36. }
  37. return (_ffprobe = npxResolve('which-ffprobe') || 'ffprobe');
  38. }
  39. /**
  40. * Run ffmpeg with the given argument array. Resolves with
  41. * { code, stdout, stderr }. Does not throw on non-zero exit; inspect `code`.
  42. */
  43. export function runFfmpeg(args, { inherit = false } = {}) {
  44. return new Promise((resolve, reject) => {
  45. const child = spawn(ffmpegPath(), args, inherit ? { stdio: 'inherit' } : {});
  46. let stdout = '';
  47. let stderr = '';
  48. if (!inherit) {
  49. child.stdout.on('data', d => { stdout += d; });
  50. child.stderr.on('data', d => { stderr += d; });
  51. }
  52. child.on('error', reject);
  53. child.on('close', code => resolve({ code, stdout, stderr }));
  54. });
  55. }
  56. /** Run ffprobe and parse JSON output (`-of json` is added automatically). */
  57. export async function probeMedia(input, extraArgs = []) {
  58. const args = ['-v', 'quiet', '-print_format', 'json', '-show_format', '-show_streams', ...extraArgs, input];
  59. const res = await new Promise((resolve, reject) => {
  60. const child = spawn(ffprobePath(), args);
  61. let stdout = '';
  62. let stderr = '';
  63. child.stdout.on('data', d => { stdout += d; });
  64. child.stderr.on('data', d => { stderr += d; });
  65. child.on('error', reject);
  66. child.on('close', code => resolve({ code, stdout, stderr }));
  67. });
  68. if (res.code !== 0) throw new Error('ffprobe failed: ' + res.stderr);
  69. return JSON.parse(res.stdout);
  70. }
  71. /** Convenience: media duration in milliseconds (rounded), or null if unknown. */
  72. export async function probeDurationMs(input) {
  73. const info = await probeMedia(input);
  74. const secs = info && info.format && info.format.duration ? parseFloat(info.format.duration) : NaN;
  75. return Number.isFinite(secs) ? Math.round(secs * 1000) : null;
  76. }