ffmpeg-converter.js 16 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480
  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. /**
  29. * FFmpeg Converter Class
  30. *
  31. * Manages video format conversion operations with progress tracking
  32. * and comprehensive error handling
  33. */
  34. class FFmpegConverter {
  35. constructor() {
  36. this.activeConversions = new Map();
  37. this.conversionId = 0;
  38. }
  39. /**
  40. * Get path to ffmpeg binary based on platform
  41. * @returns {string} Path to ffmpeg executable
  42. * @private
  43. */
  44. getBinaryPath() {
  45. const binariesPath = path.join(__dirname, '../../binaries');
  46. const extension = process.platform === 'win32' ? '.exe' : '';
  47. return path.join(binariesPath, `ffmpeg${extension}`);
  48. }
  49. /**
  50. * Check if ffmpeg binary is available
  51. * @returns {boolean} True if ffmpeg binary exists
  52. */
  53. isAvailable() {
  54. const ffmpegPath = this.getBinaryPath();
  55. return fs.existsSync(ffmpegPath);
  56. }
  57. /**
  58. * Get FFmpeg encoding arguments for specific format
  59. * @param {string} format - Target format (H264, ProRes, DNxHR, Audio only)
  60. * @param {string} quality - Video quality setting
  61. * @returns {Array<string>} FFmpeg arguments array
  62. * @private
  63. */
  64. getEncodingArgs(format, quality) {
  65. const args = [];
  66. switch (format) {
  67. case 'H264':
  68. args.push(
  69. '-c:v', 'libx264',
  70. '-preset', 'medium',
  71. '-crf', this.getH264CRF(quality),
  72. '-c:a', 'aac',
  73. '-b:a', '128k'
  74. );
  75. break;
  76. case 'ProRes':
  77. args.push(
  78. '-c:v', 'prores_ks',
  79. '-profile:v', this.getProResProfile(quality),
  80. '-c:a', 'pcm_s16le'
  81. );
  82. break;
  83. case 'DNxHR':
  84. args.push(
  85. '-c:v', 'dnxhd',
  86. '-profile:v', this.getDNxHRProfile(quality),
  87. '-c:a', 'pcm_s16le'
  88. );
  89. break;
  90. case 'Audio only':
  91. args.push(
  92. '-vn', // No video
  93. '-c:a', 'aac',
  94. '-b:a', '192k'
  95. );
  96. break;
  97. default:
  98. throw new Error(`Unsupported format: ${format}`);
  99. }
  100. return args;
  101. }
  102. /**
  103. * Get H.264 CRF value based on quality setting
  104. * @param {string} quality - Video quality (720p, 1080p, etc.)
  105. * @returns {string} CRF value for H.264 encoding
  106. * @private
  107. */
  108. getH264CRF(quality) {
  109. const crfMap = {
  110. '4K': '18', // High quality for 4K
  111. '1440p': '20', // High quality for 1440p
  112. '1080p': '23', // Balanced quality for 1080p
  113. '720p': '25', // Good quality for 720p
  114. '480p': '28' // Acceptable quality for 480p
  115. };
  116. return crfMap[quality] || '23';
  117. }
  118. /**
  119. * Get ProRes profile based on quality setting
  120. * @param {string} quality - Video quality
  121. * @returns {string} ProRes profile number
  122. * @private
  123. */
  124. getProResProfile(quality) {
  125. const profileMap = {
  126. '4K': '3', // ProRes HQ for 4K
  127. '1440p': '2', // ProRes Standard for 1440p
  128. '1080p': '2', // ProRes Standard for 1080p
  129. '720p': '1', // ProRes LT for 720p
  130. '480p': '0' // ProRes Proxy for 480p
  131. };
  132. return profileMap[quality] || '2';
  133. }
  134. /**
  135. * Get DNxHR profile based on quality setting
  136. * @param {string} quality - Video quality
  137. * @returns {string} DNxHR profile
  138. * @private
  139. */
  140. getDNxHRProfile(quality) {
  141. const profileMap = {
  142. '4K': 'dnxhr_hqx', // DNxHR HQX for 4K
  143. '1440p': 'dnxhr_hq', // DNxHR HQ for 1440p
  144. '1080p': 'dnxhr_sq', // DNxHR SQ for 1080p
  145. '720p': 'dnxhr_lb', // DNxHR LB for 720p
  146. '480p': 'dnxhr_lb' // DNxHR LB for 480p
  147. };
  148. return profileMap[quality] || 'dnxhr_sq';
  149. }
  150. /**
  151. * Get output file extension for format
  152. * @param {string} format - Target format
  153. * @returns {string} File extension
  154. * @private
  155. */
  156. getOutputExtension(format) {
  157. const extensionMap = {
  158. 'H264': 'mp4',
  159. 'ProRes': 'mov',
  160. 'DNxHR': 'mov',
  161. 'Audio only': 'm4a'
  162. };
  163. return extensionMap[format] || 'mp4';
  164. }
  165. /**
  166. * Parse FFmpeg progress output
  167. * @param {string} line - Progress line from FFmpeg stderr
  168. * @returns {Object|null} Parsed progress data or null
  169. * @private
  170. */
  171. parseProgress(line) {
  172. // FFmpeg progress format: frame= 123 fps= 25 q=28.0 size= 1024kB time=00:00:05.00 bitrate=1677.7kbits/s speed=1.02x
  173. const progressMatch = line.match(/time=(\d{2}):(\d{2}):(\d{2}\.\d{2})/);
  174. const sizeMatch = line.match(/size=\s*(\d+)kB/);
  175. const speedMatch = line.match(/speed=\s*(\d+\.?\d*)x/);
  176. if (progressMatch) {
  177. const hours = parseInt(progressMatch[1]);
  178. const minutes = parseInt(progressMatch[2]);
  179. const seconds = parseFloat(progressMatch[3]);
  180. const totalSeconds = hours * 3600 + minutes * 60 + seconds;
  181. return {
  182. timeProcessed: totalSeconds,
  183. size: sizeMatch ? parseInt(sizeMatch[1]) : null,
  184. speed: speedMatch ? parseFloat(speedMatch[1]) : null
  185. };
  186. }
  187. return null;
  188. }
  189. /**
  190. * Calculate conversion progress percentage
  191. * @param {number} processedTime - Time processed in seconds
  192. * @param {number} totalDuration - Total video duration in seconds
  193. * @returns {number} Progress percentage (0-100)
  194. * @private
  195. */
  196. calculateProgress(processedTime, totalDuration) {
  197. if (!totalDuration || totalDuration <= 0) {
  198. return 0;
  199. }
  200. return Math.min(100, Math.round((processedTime / totalDuration) * 100));
  201. }
  202. /**
  203. * Convert video file to specified format
  204. * @param {Object} options - Conversion options
  205. * @param {string} options.inputPath - Path to input video file
  206. * @param {string} options.outputPath - Path for output file
  207. * @param {string} options.format - Target format (H264, ProRes, DNxHR, Audio only)
  208. * @param {string} options.quality - Video quality setting
  209. * @param {number} [options.duration] - Video duration in seconds for progress calculation
  210. * @param {Function} [options.onProgress] - Progress callback function
  211. * @returns {Promise<Object>} Conversion result
  212. */
  213. async convertVideo(options) {
  214. const {
  215. inputPath,
  216. outputPath,
  217. format,
  218. quality,
  219. duration,
  220. onProgress
  221. } = options;
  222. // Validate inputs
  223. if (!inputPath || !outputPath || !format || !quality) {
  224. throw new Error('Missing required conversion parameters');
  225. }
  226. if (!fs.existsSync(inputPath)) {
  227. throw new Error(`Input file not found: ${inputPath}`);
  228. }
  229. if (!this.isAvailable()) {
  230. throw new Error('FFmpeg binary not found');
  231. }
  232. const conversionId = ++this.conversionId;
  233. const ffmpegPath = this.getBinaryPath();
  234. // Build FFmpeg command arguments
  235. const args = [
  236. '-i', inputPath,
  237. '-y', // Overwrite output file
  238. ...this.getEncodingArgs(format, quality),
  239. outputPath
  240. ];
  241. console.log(`Starting FFmpeg conversion ${conversionId}:`, {
  242. input: inputPath,
  243. output: outputPath,
  244. format,
  245. quality,
  246. args: args.join(' ')
  247. });
  248. return new Promise((resolve, reject) => {
  249. const ffmpegProcess = spawn(ffmpegPath, args, {
  250. stdio: ['pipe', 'pipe', 'pipe'],
  251. cwd: process.cwd()
  252. });
  253. // Store active conversion for potential cancellation
  254. this.activeConversions.set(conversionId, ffmpegProcess);
  255. let output = '';
  256. let errorOutput = '';
  257. let lastProgress = 0;
  258. // Handle stdout (usually minimal for FFmpeg)
  259. ffmpegProcess.stdout.on('data', (data) => {
  260. output += data.toString();
  261. });
  262. // Handle stderr (where FFmpeg sends progress and status)
  263. ffmpegProcess.stderr.on('data', (data) => {
  264. const chunk = data.toString();
  265. errorOutput += chunk;
  266. // Parse progress information
  267. const lines = chunk.split('\n');
  268. lines.forEach(line => {
  269. const progress = this.parseProgress(line);
  270. if (progress && duration && onProgress) {
  271. const percentage = this.calculateProgress(progress.timeProcessed, duration);
  272. // Only emit progress updates when percentage changes
  273. if (percentage !== lastProgress) {
  274. lastProgress = percentage;
  275. onProgress({
  276. conversionId,
  277. progress: percentage,
  278. timeProcessed: progress.timeProcessed,
  279. speed: progress.speed,
  280. size: progress.size
  281. });
  282. }
  283. }
  284. });
  285. });
  286. // Handle process completion
  287. ffmpegProcess.on('close', (code) => {
  288. this.activeConversions.delete(conversionId);
  289. console.log(`FFmpeg conversion ${conversionId} completed with code ${code}`);
  290. if (code === 0) {
  291. // Verify output file was created
  292. if (fs.existsSync(outputPath)) {
  293. const stats = fs.statSync(outputPath);
  294. resolve({
  295. success: true,
  296. outputPath,
  297. fileSize: stats.size,
  298. conversionId,
  299. message: 'Conversion completed successfully'
  300. });
  301. } else {
  302. reject(new Error('Conversion completed but output file not found'));
  303. }
  304. } else {
  305. // Parse error message for user-friendly feedback
  306. let errorMessage = 'Conversion failed';
  307. if (errorOutput.includes('Invalid data found')) {
  308. errorMessage = 'Invalid or corrupted input file';
  309. } else if (errorOutput.includes('No space left')) {
  310. errorMessage = 'Insufficient disk space for conversion';
  311. } else if (errorOutput.includes('Permission denied')) {
  312. errorMessage = 'Permission denied - check file access rights';
  313. } else if (errorOutput.includes('codec')) {
  314. errorMessage = 'Unsupported codec or format combination';
  315. } else if (errorOutput.trim()) {
  316. // Extract the most relevant error line
  317. const errorLines = errorOutput.trim().split('\n');
  318. const relevantError = errorLines.find(line =>
  319. line.includes('Error') || line.includes('failed')
  320. );
  321. if (relevantError && relevantError.length < 200) {
  322. errorMessage = relevantError;
  323. }
  324. }
  325. reject(new Error(errorMessage));
  326. }
  327. });
  328. // Handle process errors
  329. ffmpegProcess.on('error', (error) => {
  330. this.activeConversions.delete(conversionId);
  331. console.error(`FFmpeg process ${conversionId} error:`, error);
  332. reject(new Error(`Failed to start conversion process: ${error.message}`));
  333. });
  334. });
  335. }
  336. /**
  337. * Cancel active conversion
  338. * @param {number} conversionId - ID of conversion to cancel
  339. * @returns {boolean} True if conversion was cancelled
  340. */
  341. cancelConversion(conversionId) {
  342. const process = this.activeConversions.get(conversionId);
  343. if (process) {
  344. process.kill('SIGTERM');
  345. this.activeConversions.delete(conversionId);
  346. console.log(`Cancelled FFmpeg conversion ${conversionId}`);
  347. return true;
  348. }
  349. return false;
  350. }
  351. /**
  352. * Cancel all active conversions
  353. * @returns {number} Number of conversions cancelled
  354. */
  355. cancelAllConversions() {
  356. let cancelledCount = 0;
  357. for (const [conversionId, process] of this.activeConversions) {
  358. process.kill('SIGTERM');
  359. cancelledCount++;
  360. }
  361. this.activeConversions.clear();
  362. console.log(`Cancelled ${cancelledCount} active conversions`);
  363. return cancelledCount;
  364. }
  365. /**
  366. * Get information about active conversions
  367. * @returns {Array<Object>} Array of active conversion info
  368. */
  369. getActiveConversions() {
  370. return Array.from(this.activeConversions.keys()).map(id => ({
  371. conversionId: id,
  372. pid: this.activeConversions.get(id).pid
  373. }));
  374. }
  375. /**
  376. * Get video duration from file using FFprobe
  377. * @param {string} filePath - Path to video file
  378. * @returns {Promise<number>} Duration in seconds
  379. */
  380. async getVideoDuration(filePath) {
  381. if (!fs.existsSync(filePath)) {
  382. throw new Error(`File not found: ${filePath}`);
  383. }
  384. const ffprobePath = this.getBinaryPath().replace('ffmpeg', 'ffprobe');
  385. if (!fs.existsSync(ffprobePath)) {
  386. console.warn('FFprobe not available, duration detection disabled');
  387. return null;
  388. }
  389. const args = [
  390. '-v', 'quiet',
  391. '-show_entries', 'format=duration',
  392. '-of', 'csv=p=0',
  393. filePath
  394. ];
  395. return new Promise((resolve, reject) => {
  396. const ffprobeProcess = spawn(ffprobePath, args, {
  397. stdio: ['pipe', 'pipe', 'pipe']
  398. });
  399. let output = '';
  400. let errorOutput = '';
  401. ffprobeProcess.stdout.on('data', (data) => {
  402. output += data.toString();
  403. });
  404. ffprobeProcess.stderr.on('data', (data) => {
  405. errorOutput += data.toString();
  406. });
  407. ffprobeProcess.on('close', (code) => {
  408. if (code === 0) {
  409. const duration = parseFloat(output.trim());
  410. resolve(isNaN(duration) ? null : duration);
  411. } else {
  412. console.warn('Failed to get video duration:', errorOutput);
  413. resolve(null); // Don't reject, just return null
  414. }
  415. });
  416. ffprobeProcess.on('error', (error) => {
  417. console.warn('FFprobe process error:', error);
  418. resolve(null); // Don't reject, just return null
  419. });
  420. });
  421. }
  422. }
  423. // Export singleton instance
  424. const ffmpegConverter = new FFmpegConverter();
  425. module.exports = ffmpegConverter;