ffmpeg-converter.js 22 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691
  1. /**
  2. * @fileoverview FFmpeg video format conversion utilities
  3. * @author GrabZilla Development Team
  4. * @version 2.1.0
  5. * @since 2024-01-01
  6. */
  7. /**
  8. * FFMPEG CONVERTER MODULE
  9. *
  10. * Handles video format conversion using local ffmpeg binary
  11. *
  12. * Features:
  13. * - H.264, ProRes, DNxHR format conversion
  14. * - Audio-only extraction functionality
  15. * - Conversion progress tracking and status updates
  16. * - Format-specific encoding parameters and quality settings
  17. *
  18. * Dependencies:
  19. * - Local ffmpeg binary in binaries/ directory
  20. * - Node.js child_process for subprocess management
  21. * - Path utilities for file handling
  22. *
  23. * Requirements: 3.2, 3.3, 4.1, 4.2
  24. */
  25. const { spawn } = require('child_process');
  26. const path = require('path');
  27. const fs = require('fs');
  28. const gpuDetector = require('./gpu-detector');
  29. /**
  30. * FFmpeg Converter Class
  31. *
  32. * Manages video format conversion operations with progress tracking,
  33. * GPU hardware acceleration, and comprehensive error handling
  34. */
  35. class FFmpegConverter {
  36. constructor() {
  37. this.activeConversions = new Map();
  38. this.conversionId = 0;
  39. this.gpuCapabilities = null;
  40. this.initGPU();
  41. }
  42. /**
  43. * Initialize GPU detection
  44. * @private
  45. */
  46. async initGPU() {
  47. try {
  48. this.gpuCapabilities = await gpuDetector.detect();
  49. console.log('✅ FFmpegConverter GPU initialized:', this.gpuCapabilities.type || 'Software only');
  50. } catch (error) {
  51. console.warn('⚠️ GPU initialization failed:', error.message);
  52. }
  53. }
  54. /**
  55. * Get path to ffmpeg binary based on platform
  56. * @returns {string} Path to ffmpeg executable
  57. * @private
  58. */
  59. getBinaryPath() {
  60. const binariesPath = path.join(__dirname, '../../binaries');
  61. const extension = process.platform === 'win32' ? '.exe' : '';
  62. return path.join(binariesPath, `ffmpeg${extension}`);
  63. }
  64. /**
  65. * Check if ffmpeg binary is available
  66. * @returns {boolean} True if ffmpeg binary exists
  67. */
  68. isAvailable() {
  69. const ffmpegPath = this.getBinaryPath();
  70. return fs.existsSync(ffmpegPath);
  71. }
  72. /**
  73. * Get FFmpeg encoding arguments for specific format
  74. * @param {string} format - Target format (H264, ProRes, DNxHR, Audio only)
  75. * @param {string} quality - Video quality setting
  76. * @param {boolean} useGPU - Whether to use GPU acceleration (default: true)
  77. * @returns {Array<string>} FFmpeg arguments array
  78. * @private
  79. */
  80. getEncodingArgs(format, quality, useGPU = true) {
  81. const args = [];
  82. switch (format) {
  83. case 'H264':
  84. // Try GPU encoding first if available and requested
  85. if (useGPU && this.gpuCapabilities?.hasGPU) {
  86. args.push(...this.getGPUEncodingArgs(quality));
  87. } else {
  88. // Software encoding fallback
  89. args.push(
  90. '-c:v', 'libx264',
  91. '-preset', 'medium',
  92. '-crf', this.getH264CRF(quality),
  93. '-c:a', 'aac',
  94. '-b:a', '128k'
  95. );
  96. }
  97. break;
  98. case 'ProRes':
  99. args.push(
  100. '-c:v', 'prores_ks',
  101. '-profile:v', this.getProResProfile(quality),
  102. '-c:a', 'pcm_s16le'
  103. );
  104. break;
  105. case 'DNxHR':
  106. args.push(
  107. '-c:v', 'dnxhd',
  108. '-profile:v', this.getDNxHRProfile(quality),
  109. '-c:a', 'pcm_s16le'
  110. );
  111. break;
  112. case 'Audio only':
  113. args.push(
  114. '-vn', // No video
  115. '-c:a', 'aac',
  116. '-b:a', '192k'
  117. );
  118. break;
  119. default:
  120. throw new Error(`Unsupported format: ${format}`);
  121. }
  122. return args;
  123. }
  124. /**
  125. * Get H.264 CRF value based on quality setting
  126. * @param {string} quality - Video quality (720p, 1080p, etc.)
  127. * @returns {string} CRF value for H.264 encoding
  128. * @private
  129. */
  130. getH264CRF(quality) {
  131. const crfMap = {
  132. '4K': '18', // High quality for 4K
  133. '1440p': '20', // High quality for 1440p
  134. '1080p': '23', // Balanced quality for 1080p
  135. '720p': '25', // Good quality for 720p
  136. '480p': '28' // Acceptable quality for 480p
  137. };
  138. return crfMap[quality] || '23';
  139. }
  140. /**
  141. * Get GPU-accelerated encoding arguments
  142. * @param {string} quality - Video quality setting
  143. * @returns {Array<string>} GPU encoding arguments
  144. * @private
  145. */
  146. getGPUEncodingArgs(quality) {
  147. const args = [];
  148. const gpu = this.gpuCapabilities;
  149. if (!gpu || !gpu.hasGPU) {
  150. throw new Error('GPU not available');
  151. }
  152. switch (gpu.type) {
  153. case 'videotoolbox':
  154. // Apple VideoToolbox (macOS)
  155. args.push(
  156. '-c:v', 'h264_videotoolbox',
  157. '-b:v', this.getVideotoolboxBitrate(quality),
  158. '-profile:v', 'high',
  159. '-allow_sw', '1', // Allow software fallback if needed
  160. '-c:a', 'aac',
  161. '-b:a', '128k'
  162. );
  163. break;
  164. case 'nvenc':
  165. // NVIDIA NVENC
  166. args.push(
  167. '-c:v', 'h264_nvenc',
  168. '-preset', 'p4', // Quality preset (p1=fastest to p7=slowest)
  169. '-cq', this.getNvencCQ(quality),
  170. '-b:v', '0', // Use CQ mode (constant quality)
  171. '-c:a', 'aac',
  172. '-b:a', '128k'
  173. );
  174. break;
  175. case 'amf':
  176. // AMD AMF
  177. args.push(
  178. '-c:v', 'h264_amf',
  179. '-quality', this.getAMFQuality(quality),
  180. '-rc', 'cqp', // Constant Quality mode
  181. '-qp_i', this.getAMFQP(quality),
  182. '-qp_p', this.getAMFQP(quality),
  183. '-c:a', 'aac',
  184. '-b:a', '128k'
  185. );
  186. break;
  187. case 'qsv':
  188. // Intel Quick Sync
  189. args.push(
  190. '-c:v', 'h264_qsv',
  191. '-preset', 'medium',
  192. '-global_quality', this.getQSVQuality(quality),
  193. '-c:a', 'aac',
  194. '-b:a', '128k'
  195. );
  196. break;
  197. case 'vaapi':
  198. // VA-API (Linux)
  199. args.push(
  200. '-vaapi_device', '/dev/dri/renderD128',
  201. '-c:v', 'h264_vaapi',
  202. '-qp', this.getVAAPIQP(quality),
  203. '-c:a', 'aac',
  204. '-b:a', '128k'
  205. );
  206. break;
  207. default:
  208. throw new Error(`Unsupported GPU type: ${gpu.type}`);
  209. }
  210. console.log(`🎮 Using ${gpu.type} GPU acceleration for encoding`);
  211. return args;
  212. }
  213. /**
  214. * Get VideoToolbox bitrate based on quality
  215. * @param {string} quality - Video quality
  216. * @returns {string} Bitrate string (e.g., '10M')
  217. * @private
  218. */
  219. getVideotoolboxBitrate(quality) {
  220. const bitrateMap = {
  221. '4320p': '80M',
  222. '2160p': '40M',
  223. '1440p': '20M',
  224. '1080p': '10M',
  225. '720p': '5M',
  226. '480p': '2.5M',
  227. '360p': '1M'
  228. };
  229. return bitrateMap[quality] || '5M';
  230. }
  231. /**
  232. * Get NVENC constant quality value
  233. * @param {string} quality - Video quality
  234. * @returns {string} CQ value (0-51, lower = better)
  235. * @private
  236. */
  237. getNvencCQ(quality) {
  238. const cqMap = {
  239. '4320p': '19',
  240. '2160p': '21',
  241. '1440p': '23',
  242. '1080p': '23',
  243. '720p': '25',
  244. '480p': '28',
  245. '360p': '30'
  246. };
  247. return cqMap[quality] || '23';
  248. }
  249. /**
  250. * Get AMF quality preset
  251. * @param {string} quality - Video quality
  252. * @returns {string} Quality preset
  253. * @private
  254. */
  255. getAMFQuality(quality) {
  256. // AMF quality presets: speed, balanced, quality
  257. return quality.includes('4') || quality.includes('2160') ? 'quality' : 'balanced';
  258. }
  259. /**
  260. * Get AMF quantization parameter
  261. * @param {string} quality - Video quality
  262. * @returns {string} QP value
  263. * @private
  264. */
  265. getAMFQP(quality) {
  266. const qpMap = {
  267. '4320p': '18',
  268. '2160p': '20',
  269. '1440p': '22',
  270. '1080p': '22',
  271. '720p': '24',
  272. '480p': '26',
  273. '360p': '28'
  274. };
  275. return qpMap[quality] || '22';
  276. }
  277. /**
  278. * Get QSV global quality value
  279. * @param {string} quality - Video quality
  280. * @returns {string} Quality value
  281. * @private
  282. */
  283. getQSVQuality(quality) {
  284. const qualityMap = {
  285. '4320p': '18',
  286. '2160p': '20',
  287. '1440p': '22',
  288. '1080p': '22',
  289. '720p': '24',
  290. '480p': '26',
  291. '360p': '28'
  292. };
  293. return qualityMap[quality] || '22';
  294. }
  295. /**
  296. * Get VA-API quantization parameter
  297. * @param {string} quality - Video quality
  298. * @returns {string} QP value
  299. * @private
  300. */
  301. getVAAPIQP(quality) {
  302. const qpMap = {
  303. '4320p': '18',
  304. '2160p': '20',
  305. '1440p': '22',
  306. '1080p': '22',
  307. '720p': '24',
  308. '480p': '26',
  309. '360p': '28'
  310. };
  311. return qpMap[quality] || '22';
  312. }
  313. /**
  314. * Get ProRes profile based on quality setting
  315. * @param {string} quality - Video quality
  316. * @returns {string} ProRes profile number
  317. * @private
  318. */
  319. getProResProfile(quality) {
  320. const profileMap = {
  321. '4K': '3', // ProRes HQ for 4K
  322. '1440p': '2', // ProRes Standard for 1440p
  323. '1080p': '2', // ProRes Standard for 1080p
  324. '720p': '1', // ProRes LT for 720p
  325. '480p': '0' // ProRes Proxy for 480p
  326. };
  327. return profileMap[quality] || '2';
  328. }
  329. /**
  330. * Get DNxHR profile based on quality setting
  331. * @param {string} quality - Video quality
  332. * @returns {string} DNxHR profile
  333. * @private
  334. */
  335. getDNxHRProfile(quality) {
  336. const profileMap = {
  337. '4K': 'dnxhr_hqx', // DNxHR HQX for 4K
  338. '1440p': 'dnxhr_hq', // DNxHR HQ for 1440p
  339. '1080p': 'dnxhr_sq', // DNxHR SQ for 1080p
  340. '720p': 'dnxhr_lb', // DNxHR LB for 720p
  341. '480p': 'dnxhr_lb' // DNxHR LB for 480p
  342. };
  343. return profileMap[quality] || 'dnxhr_sq';
  344. }
  345. /**
  346. * Get output file extension for format
  347. * @param {string} format - Target format
  348. * @returns {string} File extension
  349. * @private
  350. */
  351. getOutputExtension(format) {
  352. const extensionMap = {
  353. 'H264': 'mp4',
  354. 'ProRes': 'mov',
  355. 'DNxHR': 'mov',
  356. 'Audio only': 'm4a'
  357. };
  358. return extensionMap[format] || 'mp4';
  359. }
  360. /**
  361. * Parse FFmpeg progress output
  362. * @param {string} line - Progress line from FFmpeg stderr
  363. * @returns {Object|null} Parsed progress data or null
  364. * @private
  365. */
  366. parseProgress(line) {
  367. // FFmpeg progress format: frame= 123 fps= 25 q=28.0 size= 1024kB time=00:00:05.00 bitrate=1677.7kbits/s speed=1.02x
  368. const progressMatch = line.match(/time=(\d{2}):(\d{2}):(\d{2}\.\d{2})/);
  369. const sizeMatch = line.match(/size=\s*(\d+)kB/);
  370. const speedMatch = line.match(/speed=\s*(\d+\.?\d*)x/);
  371. if (progressMatch) {
  372. const hours = parseInt(progressMatch[1]);
  373. const minutes = parseInt(progressMatch[2]);
  374. const seconds = parseFloat(progressMatch[3]);
  375. const totalSeconds = hours * 3600 + minutes * 60 + seconds;
  376. return {
  377. timeProcessed: totalSeconds,
  378. size: sizeMatch ? parseInt(sizeMatch[1]) : null,
  379. speed: speedMatch ? parseFloat(speedMatch[1]) : null
  380. };
  381. }
  382. return null;
  383. }
  384. /**
  385. * Calculate conversion progress percentage
  386. * @param {number} processedTime - Time processed in seconds
  387. * @param {number} totalDuration - Total video duration in seconds
  388. * @returns {number} Progress percentage (0-100)
  389. * @private
  390. */
  391. calculateProgress(processedTime, totalDuration) {
  392. if (!totalDuration || totalDuration <= 0) {
  393. return 0;
  394. }
  395. return Math.min(100, Math.round((processedTime / totalDuration) * 100));
  396. }
  397. /**
  398. * Convert video file to specified format
  399. * @param {Object} options - Conversion options
  400. * @param {string} options.inputPath - Path to input video file
  401. * @param {string} options.outputPath - Path for output file
  402. * @param {string} options.format - Target format (H264, ProRes, DNxHR, Audio only)
  403. * @param {string} options.quality - Video quality setting
  404. * @param {number} [options.duration] - Video duration in seconds for progress calculation
  405. * @param {Function} [options.onProgress] - Progress callback function
  406. * @returns {Promise<Object>} Conversion result
  407. */
  408. async convertVideo(options) {
  409. const {
  410. inputPath,
  411. outputPath,
  412. format,
  413. quality,
  414. duration,
  415. onProgress
  416. } = options;
  417. // Validate inputs
  418. if (!inputPath || !outputPath || !format || !quality) {
  419. throw new Error('Missing required conversion parameters');
  420. }
  421. if (!fs.existsSync(inputPath)) {
  422. throw new Error(`Input file not found: ${inputPath}`);
  423. }
  424. if (!this.isAvailable()) {
  425. throw new Error('FFmpeg binary not found');
  426. }
  427. const conversionId = ++this.conversionId;
  428. const ffmpegPath = this.getBinaryPath();
  429. // Build FFmpeg command arguments
  430. const args = [
  431. '-i', inputPath,
  432. '-y', // Overwrite output file
  433. ...this.getEncodingArgs(format, quality),
  434. outputPath
  435. ];
  436. console.log(`Starting FFmpeg conversion ${conversionId}:`, {
  437. input: inputPath,
  438. output: outputPath,
  439. format,
  440. quality,
  441. args: args.join(' ')
  442. });
  443. return new Promise((resolve, reject) => {
  444. const ffmpegProcess = spawn(ffmpegPath, args, {
  445. stdio: ['pipe', 'pipe', 'pipe'],
  446. cwd: process.cwd()
  447. });
  448. // Store active conversion for potential cancellation
  449. this.activeConversions.set(conversionId, ffmpegProcess);
  450. let output = '';
  451. let errorOutput = '';
  452. let lastProgress = 0;
  453. // Handle stdout (usually minimal for FFmpeg)
  454. ffmpegProcess.stdout.on('data', (data) => {
  455. output += data.toString();
  456. });
  457. // Handle stderr (where FFmpeg sends progress and status)
  458. ffmpegProcess.stderr.on('data', (data) => {
  459. const chunk = data.toString();
  460. errorOutput += chunk;
  461. // Parse progress information
  462. const lines = chunk.split('\n');
  463. lines.forEach(line => {
  464. const progress = this.parseProgress(line);
  465. if (progress && duration && onProgress) {
  466. const percentage = this.calculateProgress(progress.timeProcessed, duration);
  467. // Only emit progress updates when percentage changes
  468. if (percentage !== lastProgress) {
  469. lastProgress = percentage;
  470. onProgress({
  471. conversionId,
  472. progress: percentage,
  473. timeProcessed: progress.timeProcessed,
  474. speed: progress.speed,
  475. size: progress.size
  476. });
  477. }
  478. }
  479. });
  480. });
  481. // Handle process completion
  482. ffmpegProcess.on('close', (code) => {
  483. this.activeConversions.delete(conversionId);
  484. console.log(`FFmpeg conversion ${conversionId} completed with code ${code}`);
  485. if (code === 0) {
  486. // Verify output file was created
  487. if (fs.existsSync(outputPath)) {
  488. const stats = fs.statSync(outputPath);
  489. resolve({
  490. success: true,
  491. outputPath,
  492. fileSize: stats.size,
  493. conversionId,
  494. message: 'Conversion completed successfully'
  495. });
  496. } else {
  497. reject(new Error('Conversion completed but output file not found'));
  498. }
  499. } else {
  500. // Parse error message for user-friendly feedback
  501. let errorMessage = 'Conversion failed';
  502. if (errorOutput.includes('Invalid data found')) {
  503. errorMessage = 'Invalid or corrupted input file';
  504. } else if (errorOutput.includes('No space left')) {
  505. errorMessage = 'Insufficient disk space for conversion';
  506. } else if (errorOutput.includes('Permission denied')) {
  507. errorMessage = 'Permission denied - check file access rights';
  508. } else if (errorOutput.includes('codec')) {
  509. errorMessage = 'Unsupported codec or format combination';
  510. } else if (errorOutput.trim()) {
  511. // Extract the most relevant error line
  512. const errorLines = errorOutput.trim().split('\n');
  513. const relevantError = errorLines.find(line =>
  514. line.includes('Error') || line.includes('failed')
  515. );
  516. if (relevantError && relevantError.length < 200) {
  517. errorMessage = relevantError;
  518. }
  519. }
  520. reject(new Error(errorMessage));
  521. }
  522. });
  523. // Handle process errors
  524. ffmpegProcess.on('error', (error) => {
  525. this.activeConversions.delete(conversionId);
  526. console.error(`FFmpeg process ${conversionId} error:`, error);
  527. reject(new Error(`Failed to start conversion process: ${error.message}`));
  528. });
  529. });
  530. }
  531. /**
  532. * Cancel active conversion
  533. * @param {number} conversionId - ID of conversion to cancel
  534. * @returns {boolean} True if conversion was cancelled
  535. */
  536. cancelConversion(conversionId) {
  537. const process = this.activeConversions.get(conversionId);
  538. if (process) {
  539. process.kill('SIGTERM');
  540. this.activeConversions.delete(conversionId);
  541. console.log(`Cancelled FFmpeg conversion ${conversionId}`);
  542. return true;
  543. }
  544. return false;
  545. }
  546. /**
  547. * Cancel all active conversions
  548. * @returns {number} Number of conversions cancelled
  549. */
  550. cancelAllConversions() {
  551. let cancelledCount = 0;
  552. for (const [conversionId, process] of this.activeConversions) {
  553. process.kill('SIGTERM');
  554. cancelledCount++;
  555. }
  556. this.activeConversions.clear();
  557. console.log(`Cancelled ${cancelledCount} active conversions`);
  558. return cancelledCount;
  559. }
  560. /**
  561. * Get information about active conversions
  562. * @returns {Array<Object>} Array of active conversion info
  563. */
  564. getActiveConversions() {
  565. return Array.from(this.activeConversions.keys()).map(id => ({
  566. conversionId: id,
  567. pid: this.activeConversions.get(id).pid
  568. }));
  569. }
  570. /**
  571. * Get video duration from file using FFprobe
  572. * @param {string} filePath - Path to video file
  573. * @returns {Promise<number>} Duration in seconds
  574. */
  575. async getVideoDuration(filePath) {
  576. if (!fs.existsSync(filePath)) {
  577. throw new Error(`File not found: ${filePath}`);
  578. }
  579. const ffprobePath = this.getBinaryPath().replace('ffmpeg', 'ffprobe');
  580. if (!fs.existsSync(ffprobePath)) {
  581. console.warn('FFprobe not available, duration detection disabled');
  582. return null;
  583. }
  584. const args = [
  585. '-v', 'quiet',
  586. '-show_entries', 'format=duration',
  587. '-of', 'csv=p=0',
  588. filePath
  589. ];
  590. return new Promise((resolve, reject) => {
  591. const ffprobeProcess = spawn(ffprobePath, args, {
  592. stdio: ['pipe', 'pipe', 'pipe']
  593. });
  594. let output = '';
  595. let errorOutput = '';
  596. ffprobeProcess.stdout.on('data', (data) => {
  597. output += data.toString();
  598. });
  599. ffprobeProcess.stderr.on('data', (data) => {
  600. errorOutput += data.toString();
  601. });
  602. ffprobeProcess.on('close', (code) => {
  603. if (code === 0) {
  604. const duration = parseFloat(output.trim());
  605. resolve(isNaN(duration) ? null : duration);
  606. } else {
  607. console.warn('Failed to get video duration:', errorOutput);
  608. resolve(null); // Don't reject, just return null
  609. }
  610. });
  611. ffprobeProcess.on('error', (error) => {
  612. console.warn('FFprobe process error:', error);
  613. resolve(null); // Don't reject, just return null
  614. });
  615. });
  616. }
  617. }
  618. // Export singleton instance
  619. const ffmpegConverter = new FFmpegConverter();
  620. module.exports = ffmpegConverter;