UNPKG

@profullstack/transcoder

Version:

A server-side module for transcoding videos, audio, and images using FFmpeg with smart presets and optimizations

578 lines (501 loc) 18.5 kB
/** * @profullstack/transcoder - Batch Processing Module * Contains functionality for batch processing multiple files */ import fs from 'fs'; import path from 'path'; import { EventEmitter } from 'events'; import { transcode } from './video.js'; import { transcodeAudio } from './audio.js'; import { transcodeImage } from './image.js'; import { getPreset } from '../presets.js'; /** * BatchProcessEmitter class for emitting batch processing events * @extends EventEmitter */ export class BatchProcessEmitter extends EventEmitter { constructor() { super(); } } /** * Supported file extensions for different media types */ export const SUPPORTED_EXTENSIONS = { video: ['.mp4', '.mov', '.avi', '.mkv', '.webm', '.flv', '.wmv', '.m4v', '.3gp'], audio: ['.mp3', '.wav', '.aac', '.ogg', '.flac', '.m4a', '.wma'], image: ['.jpg', '.jpeg', '.png', '.gif', '.webp', '.tiff', '.bmp', '.svg'] }; /** * Default output extensions for different media types and presets */ export const DEFAULT_OUTPUT_EXTENSIONS = { video: { default: '.mp4', presets: { 'web': '.mp4', 'mobile': '.mp4', 'hd': '.mp4', 'youtube-hd': '.mp4', 'youtube-4k': '.mp4', 'instagram': '.mp4', 'twitter': '.mp4', 'facebook': '.mp4', 'tiktok': '.mp4', 'vimeo-hd': '.mp4' } }, audio: { default: '.mp3', presets: { 'audio-high': '.aac', 'audio-medium': '.aac', 'audio-low': '.aac', 'audio-voice': '.aac', 'mp3-high': '.mp3', 'mp3-medium': '.mp3', 'mp3-low': '.mp3' } }, image: { default: '.jpg', presets: { 'jpeg-high': '.jpg', 'jpeg-medium': '.jpg', 'jpeg-low': '.jpg', 'webp-high': '.webp', 'webp-medium': '.webp', 'webp-low': '.webp', 'png': '.png', 'png-optimized': '.png', 'avif-high': '.avif', 'avif-medium': '.avif', 'thumbnail': '.jpg', 'social-media': '.jpg', 'square': '.png', 'square-white': '.jpg', 'instagram-square': '.jpg' } } }; /** * Determines the media type based on file extension * * @param {string} filePath - Path to the file * @returns {string|null} - Media type ('video', 'audio', 'image') or null if unsupported */ function getMediaType(filePath) { const ext = path.extname(filePath).toLowerCase(); if (SUPPORTED_EXTENSIONS.video.includes(ext)) { return 'video'; } else if (SUPPORTED_EXTENSIONS.audio.includes(ext)) { return 'audio'; } else if (SUPPORTED_EXTENSIONS.image.includes(ext)) { return 'image'; } return null; } /** * Get the appropriate output extension based on media type and preset * * @param {string} mediaType - Media type ('video', 'audio', 'image') * @param {Object} settings - Batch processing settings * @param {string} filePath - Original file path (for fallback) * @returns {string} - Output file extension (e.g., '.mp4', '.mp3', '.jpg') */ function getOutputExtension(mediaType, settings, filePath) { // If an output extension is explicitly specified, use it if (settings.outputExtension) { return settings.outputExtension; } // Get the preset name if specified const presetName = settings.transcodeOptions?.preset?.toLowerCase(); // If a preset is specified and there's a default extension for it, use it if (presetName && DEFAULT_OUTPUT_EXTENSIONS[mediaType]?.presets?.[presetName]) { return DEFAULT_OUTPUT_EXTENSIONS[mediaType].presets[presetName]; } // For audio files, check if we're only applying enhancements if (mediaType === 'audio') { // Check if we're only applying audio enhancements without changing the codec const isEnhancementOnly = settings.transcodeOptions?.audio && !settings.transcodeOptions?.audioCodec; // If we're only enhancing, preserve the original extension if (isEnhancementOnly) { return path.extname(filePath); } // Otherwise, ensure the extension matches the codec const audioCodec = settings.transcodeOptions?.audioCodec?.toLowerCase(); if (audioCodec) { if (audioCodec === 'libmp3lame' || audioCodec.includes('mp3')) { return '.mp3'; } else if (audioCodec === 'aac' || audioCodec.includes('aac')) { return '.aac'; } else if (audioCodec === 'libvorbis' || audioCodec.includes('vorbis')) { return '.ogg'; } else if (audioCodec === 'flac' || audioCodec.includes('flac')) { return '.flac'; } else if (audioCodec.includes('pcm') || audioCodec.includes('wav')) { return '.wav'; } } // Default audio extension if codec doesn't match any known format return '.mp3'; } // Otherwise, use the default extension for the media type return DEFAULT_OUTPUT_EXTENSIONS[mediaType]?.default || path.extname(filePath); } /** * Scans a directory for media files * * @param {string} dirPath - Path to the directory to scan * @param {Object} options - Scan options * @param {Array<string>} [options.mediaTypes] - Media types to include ('video', 'audio', 'image') * @param {Array<string>} [options.extensions] - File extensions to include * @param {boolean} [options.recursive=false] - Whether to scan subdirectories * @returns {Promise<Array<string>>} - Promise that resolves with an array of file paths */ export async function scanDirectory(dirPath, options = {}) { const { mediaTypes = ['video', 'audio', 'image'], extensions = [], recursive = false } = options; // Validate directory path if (!fs.existsSync(dirPath)) { throw new Error(`Directory does not exist: ${dirPath}`); } const stats = fs.statSync(dirPath); if (!stats.isDirectory()) { throw new Error(`Not a directory: ${dirPath}`); } // Build list of supported extensions let supportedExtensions = []; if (extensions.length > 0) { // Use provided extensions supportedExtensions = extensions.map(ext => ext.startsWith('.') ? ext.toLowerCase() : `.${ext.toLowerCase()}`); } else { // Use extensions for specified media types mediaTypes.forEach(type => { if (SUPPORTED_EXTENSIONS[type]) { supportedExtensions = [...supportedExtensions, ...SUPPORTED_EXTENSIONS[type]]; } }); } // Read directory contents const entries = fs.readdirSync(dirPath, { withFileTypes: true }); // Process files and subdirectories const files = []; for (const entry of entries) { const entryPath = path.join(dirPath, entry.name); if (entry.isDirectory() && recursive) { // Recursively scan subdirectory const subFiles = await scanDirectory(entryPath, options); files.push(...subFiles); } else if (entry.isFile()) { // Check if file has a supported extension const ext = path.extname(entry.name).toLowerCase(); if (supportedExtensions.includes(ext)) { files.push(entryPath); } } } return files; } /** * Process a single file * * @param {string} filePath - Path to the file to process * @param {Object} settings - Batch processing settings * @param {BatchProcessEmitter} emitter - Batch process emitter * @param {number} index - Index of the file in the batch * @returns {Promise<Object>} - Promise that resolves with the processing result */ async function processFile(filePath, settings, emitter, index) { try { // Determine media type const mediaType = getMediaType(filePath); if (!mediaType) { throw new Error(`Unsupported file type: ${filePath}`); } // Get the appropriate output extension const outputExt = getOutputExtension(mediaType, settings, filePath); // Generate output path const fileName = path.basename(filePath, path.extname(filePath)); const outputFileName = `${settings.outputPrefix}${fileName}${settings.outputSuffix}${outputExt}`; const outputPath = path.join(settings.outputDir, outputFileName); // Emit file start event emitter.emit('fileStart', { filePath, outputPath, mediaType, index }); // Create a progress callback function const progressCallback = (progress) => { // Only log progress details if verbose is enabled if (settings.verbose) { console.log('Progress callback called:', progress); } emitter.emit('fileProgress', { filePath, outputPath, mediaType, percent: progress.time && progress.duration ? Math.min(100, Math.round((progress.time / progress.duration) * 100)) : 0, ...progress }); }; // Add the progress callback to the transcode options const transcodeOptionsWithProgress = { ...settings.transcodeOptions, onProgress: progressCallback, verbose: settings.verbose }; // Process file based on media type let result; if (mediaType === 'video') { // Emit initial progress emitter.emit('fileProgress', { filePath, outputPath, mediaType, percent: 0 }); result = await transcode(filePath, outputPath, transcodeOptionsWithProgress); } else if (mediaType === 'audio') { // Check if we're applying audio enhancements const hasAudioEnhancements = transcodeOptionsWithProgress.audio && (transcodeOptionsWithProgress.audio.normalize || transcodeOptionsWithProgress.audio.noiseReduction !== undefined || transcodeOptionsWithProgress.audio.fadeIn !== undefined || transcodeOptionsWithProgress.audio.fadeOut !== undefined || transcodeOptionsWithProgress.audio.volume !== undefined); // Check file extension for compatibility with audio enhancements const ext = path.extname(filePath).toLowerCase(); // If we're applying enhancements to a format that may not be compatible, skip it if (hasAudioEnhancements && !transcodeOptionsWithProgress.audioCodec && (ext === '.mp3' || ext === '.flac' || ext === '.ogg')) { const warningMessage = `Audio enhancement for ${ext} files may not work correctly. Use a codec parameter or convert to WAV first.`; // Log the warning console.warn(`Warning: ${warningMessage}`); // Emit a warning event emitter.emit('fileWarning', { filePath, warning: warningMessage }); // Mark as 100% complete emitter.emit('fileProgress', { filePath, outputPath, mediaType, percent: 100 }); // Return as skipped return { input: filePath, skipped: true, warning: warningMessage, success: false }; } // For audio, we don't have real-time progress, so emit a few progress updates emitter.emit('fileProgress', { filePath, outputPath, mediaType, percent: 25 }); result = await transcodeAudio(filePath, outputPath, transcodeOptionsWithProgress); emitter.emit('fileProgress', { filePath, outputPath, mediaType, percent: 75 }); } else if (mediaType === 'image') { // For images, we don't have real-time progress, so emit a few progress updates emitter.emit('fileProgress', { filePath, outputPath, mediaType, percent: 25 }); result = await transcodeImage(filePath, outputPath, transcodeOptionsWithProgress); emitter.emit('fileProgress', { filePath, outputPath, mediaType, percent: 75 }); } // Emit 100% progress to ensure the progress bar is completed emitter.emit('fileProgress', { filePath, outputPath, mediaType, percent: 100 }); // Emit file complete event emitter.emit('fileComplete', { filePath, outputPath, mediaType, metadata: result.metadata, success: true }); return { input: filePath, output: outputPath, mediaType, metadata: result.metadata, success: true }; } catch (error) { // Only log detailed error if verbose is enabled if (settings.verbose) { console.error('Error processing file:', error); } // Check if this is a format compatibility warning for audio enhancement if (error.isFormatWarning) { // This is a warning about format compatibility, not a fatal error const warningMessage = error.message; // Log the warning console.warn(`Warning: ${warningMessage}`); // Emit a warning event emitter.emit('fileWarning', { filePath, warning: warningMessage }); // Mark as 100% complete emitter.emit('fileProgress', { filePath, outputPath, mediaType, percent: 100 }); // Return as skipped return { input: filePath, skipped: true, warning: warningMessage, success: false }; } // For other errors, emit error event emitter.emit('fileError', { filePath, error: error.message }); // Return as failed return { input: filePath, error: error.message, success: false }; } } /** * Processes a batch of files in parallel * * @param {Array<string>} filePaths - Array of file paths to process * @param {Object} options - Batch processing options * @param {string} options.outputDir - Directory where processed files will be saved * @param {Object} options.transcodeOptions - Options for transcoding * @param {string} [options.outputExtension] - Extension for output files (e.g., '.mp4') * @param {string} [options.outputPrefix=''] - Prefix for output filenames * @param {string} [options.outputSuffix=''] - Suffix for output filenames * @param {number} [options.concurrency=1] - Number of files to process concurrently * @param {BatchProcessEmitter} [options.emitter] - Custom emitter for batch processing events * @param {boolean} [options.verbose=false] - Whether to log verbose output * @returns {Promise<Object>} - Promise that resolves with batch processing results */ export async function batchProcess(filePaths, options) { // Create an emitter for batch processing events or use the provided one const emitter = options.emitter || new BatchProcessEmitter(); // Default options const settings = { outputDir: path.dirname(filePaths[0]), transcodeOptions: {}, outputExtension: null, outputPrefix: '', outputSuffix: '', concurrency: 2, // Default to 2 concurrent processes verbose: false, ...options }; // Ensure output directory exists if (!fs.existsSync(settings.outputDir)) { fs.mkdirSync(settings.outputDir, { recursive: true }); } // Results object const results = { total: filePaths.length, completed: 0, successful: [], failed: [] }; // Emit start event emitter.emit('start', { total: filePaths.length }); // Process files in parallel with concurrency limit return new Promise((resolve) => { // Track active promises let activePromises = 0; let fileIndex = 0; let completedCount = 0; // Function to process the next file const processNextFile = () => { // If we've processed all files and no active promises, we're done if (fileIndex >= filePaths.length && activePromises === 0) { // Emit complete event emitter.emit('complete', results); resolve({ results, emitter }); return; } // If we've reached the end of the files, just wait for active promises if (fileIndex >= filePaths.length) { return; } // If we've reached concurrency limit, wait for some promises to complete if (activePromises >= settings.concurrency) { return; } // Get the next file const filePath = filePaths[fileIndex]; fileIndex++; // Increment active promises activePromises++; // Process the file processFile(filePath, settings, emitter, fileIndex) .then((result) => { // Update results if (result.success) { results.successful.push(result); } else { results.failed.push(result); } // Update completed count completedCount++; results.completed = completedCount; // Emit progress event emitter.emit('progress', { completed: completedCount, total: filePaths.length, percent: Math.round((completedCount / filePaths.length) * 100) }); }) .catch((error) => { // Log error if (settings.verbose) { console.error('Error processing file:', error); } // Update results results.failed.push({ input: filePath, error: error.message, success: false }); // Update completed count completedCount++; results.completed = completedCount; // Emit progress event emitter.emit('progress', { completed: completedCount, total: filePaths.length, percent: Math.round((completedCount / filePaths.length) * 100) }); }) .finally(() => { // Decrement active promises activePromises--; // Process next file processNextFile(); }); // Try to process more files if we haven't reached concurrency limit processNextFile(); }; // Start processing files processNextFile(); }); } /** * Processes all media files in a directory * * @param {string} dirPath - Path to the directory containing media files * @param {Object} options - Batch processing options * @param {Object} scanOptions - Options for scanning the directory * @returns {Promise<Object>} - Promise that resolves with batch processing results */ export async function batchProcessDirectory(dirPath, options = {}, scanOptions = {}) { // Scan directory for media files const filePaths = await scanDirectory(dirPath, scanOptions); if (filePaths.length === 0) { throw new Error(`No supported media files found in directory: ${dirPath}`); } // Only log detailed file count if verbose is enabled if (options.verbose) { console.log(`Found ${filePaths.length} files to process`); } // Process files return batchProcess(filePaths, options); }