fmode-ffmpeg.js 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299
  1. #!/usr/bin/env node
  2. const fs = require('fs');
  3. const os = require('os');
  4. const path = require('path');
  5. const { spawnSync } = require('child_process');
  6. const SKILL_NAME = 'fmode-ffmpeg';
  7. const SOURCE_ROOT = path.resolve(__dirname, '..');
  8. const SKILL_SOURCE = path.join(SOURCE_ROOT, 'skills', SKILL_NAME);
  9. const WORKSPACE_ROOT = process.cwd();
  10. const GLOBAL_TARGET = path.join(os.homedir(), '.claude', 'skills', SKILL_NAME);
  11. const WORKSPACE_TARGET = path.join(WORKSPACE_ROOT, '.claude', 'skills', SKILL_NAME);
  12. const WORKSPACE_SKILLS_ROOT = path.join(WORKSPACE_ROOT, '.claude', 'skills');
  13. const GLOBAL_SKILLS_ROOT = path.join(os.homedir(), '.claude', 'skills');
  14. const INSTALL_COMMANDS = new Set(['install', 'workspace', 'install-workspace', 'check', 'smoke', 'path', 'help']);
  15. const BINARY_COMMANDS = new Set(['which', 'which-ffprobe', 'exec', 'run', 'probe', 'version']);
  16. function expandHome(value) {
  17. return String(value || '').replace(/^~(?=$|[\\/])/, os.homedir());
  18. }
  19. // ---------------------------------------------------------------------------
  20. // ffmpeg / ffprobe binary resolution (with integrity verification + repair)
  21. // ---------------------------------------------------------------------------
  22. const VERSION_BANNER = /(?:ffmpeg|ffprobe) version/i;
  23. function truthyEnv(value) {
  24. return value != null && value !== '' && value !== '0'
  25. && String(value).toLowerCase() !== 'false';
  26. }
  27. // An existence check is not enough: an incomplete/corrupt ffmpeg-static download
  28. // can leave a wrong-sized or non-executable file on disk that fails at launch
  29. // (e.g. Windows "not a valid Win32 application" / EFTYPE). Verify the binary
  30. // actually runs before trusting it.
  31. function isRunnable(binPath) {
  32. if (!binPath || !fs.existsSync(binPath)) return false;
  33. const result = spawnSync(binPath, ['-version'], {
  34. encoding: 'utf8', timeout: 20000, shell: false,
  35. });
  36. if (result.error || result.status !== 0) return false;
  37. return VERSION_BANNER.test(`${result.stdout || ''}${result.stderr || ''}`);
  38. }
  39. function staticModuleInfo(moduleName) {
  40. try {
  41. const mod = require(moduleName);
  42. const binPath = typeof mod === 'string' ? mod : (mod && (mod.path || mod));
  43. const pkgDir = path.dirname(require.resolve(`${moduleName}/package.json`));
  44. return { binPath, pkgDir };
  45. } catch (_) {
  46. return null;
  47. }
  48. }
  49. // ffmpeg-static downloads its binary on install via `install.js`, caching the
  50. // download with @derhuerst/http-basic. install.js short-circuits when the binary
  51. // file already exists, and a corrupt cache entry would just be re-copied, so a
  52. // real repair must remove BOTH the binary and that download cache first.
  53. function repairFfmpegStatic(info) {
  54. if (!info || !info.pkgDir) return false;
  55. const installJs = path.join(info.pkgDir, 'install.js');
  56. if (!fs.existsSync(installJs)) return false;
  57. // install.js short-circuits if the binary file still exists, so it MUST be
  58. // gone before we re-run it. fs.rmSync({force:true}) has been observed to
  59. // silently no-op on a file on some Windows setups, so use unlinkSync and, if
  60. // that fails (e.g. AV/lock), move the corrupt file aside as a fallback.
  61. if (info.binPath && fs.existsSync(info.binPath)) {
  62. try {
  63. fs.unlinkSync(info.binPath);
  64. } catch (_) {
  65. try { fs.renameSync(info.binPath, `${info.binPath}.corrupt-${Date.now()}`); }
  66. catch (_) { return false; }
  67. }
  68. if (fs.existsSync(info.binPath)) return false; // could not clear it; bail to fallback
  69. }
  70. try {
  71. const envPaths = require(require.resolve('env-paths', { paths: [info.pkgDir] }));
  72. const cacheDir = envPaths('ffmpeg-static').cache;
  73. if (cacheDir) fs.rmSync(cacheDir, { recursive: true, force: true });
  74. } catch (_) { /* best-effort cache clear */ }
  75. // The download can hang on a flaky network, so bound it; otherwise resolution
  76. // would block indefinitely. Redirect the installer's stdout to our stderr so
  77. // `which` stdout stays clean.
  78. const timeoutMs = Number(process.env.FMODE_FFMPEG_REPAIR_TIMEOUT_MS) || 180000;
  79. const result = spawnSync(process.execPath, [installJs], {
  80. cwd: info.pkgDir, stdio: ['ignore', 2, 2], shell: false,
  81. env: process.env, timeout: timeoutMs,
  82. });
  83. return !result.error && result.status === 0;
  84. }
  85. function resolveFfmpeg() {
  86. const envPath = process.env.FFMPEG_PATH;
  87. if (envPath && fs.existsSync(envPath)) return envPath;
  88. const info = staticModuleInfo('ffmpeg-static');
  89. if (info && info.binPath) {
  90. if (isRunnable(info.binPath)) return info.binPath;
  91. if (!truthyEnv(process.env.FMODE_FFMPEG_NO_REPAIR)) {
  92. console.error('fmode-ffmpeg: bundled ffmpeg failed to launch (corrupt/incomplete download); re-downloading once...');
  93. if (repairFfmpegStatic(info)) {
  94. const fresh = staticModuleInfo('ffmpeg-static');
  95. if (fresh && isRunnable(fresh.binPath)) return fresh.binPath;
  96. }
  97. console.error('fmode-ffmpeg: bundled ffmpeg still unavailable after re-download. Falling back to system ffmpeg on PATH; set FFMPEG_PATH to a working ffmpeg to override.');
  98. }
  99. }
  100. return 'ffmpeg';
  101. }
  102. function resolveFfprobe() {
  103. const envPath = process.env.FFPROBE_PATH;
  104. if (envPath && fs.existsSync(envPath)) return envPath;
  105. const info = staticModuleInfo('ffprobe-static');
  106. if (info && info.binPath) {
  107. if (isRunnable(info.binPath)) return info.binPath;
  108. // ffprobe-static ships the binary inside the package (no download step to
  109. // retry), so a corrupt copy can only be fixed by reinstalling the package.
  110. console.error('fmode-ffmpeg: bundled ffprobe failed to launch (corrupt install). Falling back to system ffprobe on PATH; set FFPROBE_PATH to override, or reinstall fmode-ffmpeg.');
  111. }
  112. return 'ffprobe';
  113. }
  114. function runBinary(binary, passthrough) {
  115. const result = spawnSync(binary, passthrough, { stdio: 'inherit', shell: false });
  116. if (result.error) {
  117. console.error(`fmode-ffmpeg: failed to launch ${binary}: ${result.error.message}`);
  118. process.exit(1);
  119. }
  120. process.exit(result.status == null ? 1 : result.status);
  121. }
  122. // Everything after the subcommand, dropping a single leading "--" separator.
  123. function passthroughArgs(argv) {
  124. const rest = argv.slice(1);
  125. if (rest[0] === '--') return rest.slice(1);
  126. return rest;
  127. }
  128. // ---------------------------------------------------------------------------
  129. // Skill installer (mirrors fmode-vision)
  130. // ---------------------------------------------------------------------------
  131. function parseArgs(argv) {
  132. const first = argv[0] && !argv[0].startsWith('--') ? argv[0] : 'install';
  133. const args = { command: first, target: GLOBAL_TARGET, smoke: false, force: false, help: false };
  134. if (first === 'workspace' || first === 'install-workspace') {
  135. args.command = 'install';
  136. args.target = WORKSPACE_TARGET;
  137. }
  138. for (let i = first === argv[0] ? 1 : 0; i < argv.length; i++) {
  139. const token = argv[i];
  140. if (token === '--target' && argv[i + 1]) args.target = argv[++i];
  141. else if (token.startsWith('--target=')) args.target = token.slice('--target='.length);
  142. else if (token === '--workspace') args.target = WORKSPACE_TARGET;
  143. else if (token === '--global') args.target = GLOBAL_TARGET;
  144. else if (token === '--smoke') args.smoke = true;
  145. else if (token === '--force') args.force = true;
  146. else if (token === '--help' || token === '-h') args.help = true;
  147. }
  148. args.target = path.resolve(expandHome(args.target));
  149. return args;
  150. }
  151. function printHelp() {
  152. console.log([
  153. 'fmode-ffmpeg — bundled ffmpeg/ffprobe runner + Claude Code skill installer',
  154. '',
  155. 'Run ffmpeg / ffprobe (no system install needed):',
  156. ' npx fmode-ffmpeg@latest which # print bundled ffmpeg binary path',
  157. ' npx fmode-ffmpeg@latest which-ffprobe # print bundled ffprobe binary path',
  158. ' npx fmode-ffmpeg@latest exec -- -i in.mp4 out.mp3 # run ffmpeg with passthrough args',
  159. ' npx fmode-ffmpeg@latest probe -- -show_format in.mp4 # run ffprobe with passthrough args',
  160. ' npx fmode-ffmpeg@latest version # print ffmpeg -version',
  161. '',
  162. 'Install the Claude Code skill:',
  163. ' npx fmode-ffmpeg@latest workspace [--smoke] # install into ./.claude/skills/fmode-ffmpeg',
  164. ' npx fmode-ffmpeg@latest install [--smoke] # install into ~/.claude/skills/fmode-ffmpeg',
  165. ' npx fmode-ffmpeg@latest install --target <dir> [--force]',
  166. ' npx fmode-ffmpeg@latest check',
  167. ' npx fmode-ffmpeg@latest smoke',
  168. ' npx fmode-ffmpeg@latest path',
  169. '',
  170. 'Options:',
  171. ' --workspace Install into ./.claude/skills/fmode-ffmpeg',
  172. ' --global Install into ~/.claude/skills/fmode-ffmpeg (default)',
  173. ' --target <dir> Install into a custom directory',
  174. ' --force Allow overwriting a custom target',
  175. ' --smoke Run smoke checks after install',
  176. ' --help, -h Show help'
  177. ].join('\n'));
  178. }
  179. function ensureDir(dirPath) { fs.mkdirSync(dirPath, { recursive: true }); }
  180. function isInside(parentDir, childDir) {
  181. const relative = path.relative(path.resolve(parentDir), path.resolve(childDir));
  182. return relative === '' || (!!relative && !relative.startsWith('..') && !path.isAbsolute(relative));
  183. }
  184. function canOverwriteTarget(targetDir, force) {
  185. return force
  186. || path.resolve(targetDir) === path.resolve(GLOBAL_TARGET)
  187. || isInside(WORKSPACE_SKILLS_ROOT, targetDir)
  188. || isInside(GLOBAL_SKILLS_ROOT, targetDir);
  189. }
  190. function copyDirRecursive(source, destination) {
  191. const stat = fs.statSync(source);
  192. if (stat.isDirectory()) {
  193. ensureDir(destination);
  194. for (const child of fs.readdirSync(source)) {
  195. if (child === 'node_modules' || child === 'outputs' || child === '.git') continue;
  196. copyDirRecursive(path.join(source, child), path.join(destination, child));
  197. }
  198. return;
  199. }
  200. ensureDir(path.dirname(destination));
  201. fs.copyFileSync(source, destination);
  202. }
  203. function installSkill(target, force) {
  204. if (!fs.existsSync(SKILL_SOURCE)) {
  205. throw new Error(`Skill source missing: ${SKILL_SOURCE}`);
  206. }
  207. if (fs.existsSync(target)) {
  208. if (!canOverwriteTarget(target, force)) {
  209. throw new Error(`Refusing to overwrite custom target without --force: ${target}`);
  210. }
  211. fs.rmSync(target, { recursive: true, force: true });
  212. }
  213. ensureDir(target);
  214. copyDirRecursive(SKILL_SOURCE, target);
  215. }
  216. function checkSkill(target) {
  217. const required = ['SKILL.md', 'scripts/ffmpeg-runner.mjs'];
  218. const missing = required.filter(entry => !fs.existsSync(path.join(target, entry)));
  219. if (missing.length) {
  220. throw new Error(`Install target is missing required files: ${missing.join(', ')}`);
  221. }
  222. return { status: 'ok', skill: SKILL_NAME, target, required };
  223. }
  224. function runSmoke() {
  225. const result = spawnSync(process.execPath, ['scripts/smoke.js'], { cwd: SOURCE_ROOT, stdio: 'inherit', shell: false });
  226. if (result.status !== 0) throw new Error('smoke failed');
  227. }
  228. function printNextSteps(target) {
  229. const workspaceMode = isInside(WORKSPACE_SKILLS_ROOT, target);
  230. console.log('');
  231. console.log('Install complete.');
  232. console.log(`Skill installed at: ${target}`);
  233. console.log('');
  234. if (workspaceMode) {
  235. console.log('Project-level skill is ready. Restart the VSCode Claude Code session if it was open.');
  236. } else {
  237. console.log('User-level skill is ready for all Claude Code workspaces.');
  238. }
  239. console.log('');
  240. console.log('ffmpeg is bundled (ffmpeg-static); no system install needed.');
  241. console.log('Try this prompt in Claude Code:');
  242. console.log(' 把 input.mp4 转成 16kHz 单声道 wav 音频。');
  243. }
  244. function main() {
  245. const argv = process.argv.slice(2);
  246. const command = argv[0] && !argv[0].startsWith('--') ? argv[0] : 'install';
  247. // Binary passthrough commands (handled before the installer arg parser).
  248. if (BINARY_COMMANDS.has(command)) {
  249. if (command === 'which') { console.log(resolveFfmpeg()); return; }
  250. if (command === 'which-ffprobe') { console.log(resolveFfprobe()); return; }
  251. if (command === 'version') { runBinary(resolveFfmpeg(), ['-version']); return; }
  252. if (command === 'exec' || command === 'run') { runBinary(resolveFfmpeg(), passthroughArgs(argv)); return; }
  253. if (command === 'probe') { runBinary(resolveFfprobe(), passthroughArgs(argv)); return; }
  254. }
  255. const args = parseArgs(argv);
  256. if (args.help || args.command === 'help') { printHelp(); return; }
  257. if (args.command === 'path') { console.log(args.target); return; }
  258. if (args.command === 'install') {
  259. installSkill(args.target, args.force);
  260. console.log(JSON.stringify(checkSkill(args.target), null, 2));
  261. if (args.smoke) runSmoke();
  262. printNextSteps(args.target);
  263. return;
  264. }
  265. if (args.command === 'check') { console.log(JSON.stringify(checkSkill(args.target), null, 2)); return; }
  266. if (args.command === 'smoke') { runSmoke(); return; }
  267. printHelp();
  268. process.exitCode = 1;
  269. }
  270. try { main(); }
  271. catch (error) { console.error(`fmode-ffmpeg failed: ${error.message}`); process.exit(1); }