ffmpeg-converter.js 22 KB

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