UNPKG

@profullstack/transcoder

Version:

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

736 lines (657 loc) 23.3 kB
/** * @profullstack/transcoder - CLI Module * Contains functionality for the command-line interface */ import fs from 'fs'; import path from 'path'; import os from 'os'; import cliProgress from 'cli-progress'; import colors from 'ansi-colors'; import yargs from 'yargs/yargs'; import { hideBin } from 'yargs/helpers'; import { spawn } from 'child_process'; import { parseProgress } from './core.js'; import { getVideoDuration } from './utils.js'; import { generateThumbnailsEfficient } from './thumbnails.js'; import { getPreset, PRESETS } from '../presets.js'; /** * Format time as HH:MM:SS * * @param {number} seconds - Time in seconds * @returns {string} - Formatted time string */ export function formatTime(seconds) { if (!seconds || isNaN(seconds)) return '0:00:00'; const h = Math.floor(seconds / 3600); const m = Math.floor((seconds % 3600) / 60); const s = Math.floor(seconds % 60); return `${h}:${m.toString().padStart(2, '0')}:${s.toString().padStart(2, '0')}`; } /** * Format file size in human-readable format * * @param {number} bytes - Size in bytes * @returns {string} - Formatted size string */ export function formatFileSize(bytes) { if (!bytes || isNaN(bytes)) return '0 B'; const sizes = ['B', 'KB', 'MB', 'GB', 'TB']; const i = Math.floor(Math.log(bytes) / Math.log(1024)); return `${(bytes / Math.pow(1024, i)).toFixed(2)} ${sizes[i]}`; } /** * Expand tilde in path to user's home directory * * @param {string} filePath - Path that may contain a tilde * @returns {string} - Path with tilde expanded */ export function expandTildePath(filePath) { if (!filePath) return filePath; if (filePath.startsWith('~/') || filePath === '~') { return filePath.replace(/^~/, os.homedir()); } return filePath; } /** * Configure the command-line interface * * @returns {Object} - Yargs parser */ export function configureCommandLine() { return yargs(hideBin(process.argv)) .usage('Usage: $0 <input> <output> [options] OR $0 --path <directory> [options]') .example('$0 input.mp4 output.mp4 --preset youtube-hd', 'Transcode a video using the youtube-hd preset') .example('$0 input.mp4 output.mp4 --thumbnails 3', 'Transcode a video and generate 3 thumbnails') .example('$0 input.mp4 output.mp4 --width 1280 --height 720', 'Transcode a video to 720p resolution') .example('$0 --thumbnails-only input.mp4 --count 5', 'Generate 5 thumbnails without transcoding') .example('$0 --path ./videos --preset web', 'Batch process all videos in the directory using the web preset') .example('$0 --path ./videos --recursive --output-dir ./processed', 'Recursively process all videos and save to output directory') // Input and output arguments .positional('input', { describe: 'Input video file', type: 'string' }) .positional('output', { describe: 'Output video file', type: 'string' }) // Batch processing options .option('path', { describe: 'Path to directory containing media files for batch processing', type: 'string' }) .option('recursive', { describe: 'Recursively process files in subdirectories (for batch processing)', type: 'boolean', default: false }) .option('output-dir', { describe: 'Output directory for batch processed files', type: 'string' }) .option('output-prefix', { describe: 'Prefix to add to output filenames (for batch processing)', type: 'string', default: '' }) .option('output-suffix', { describe: 'Suffix to add to output filenames (for batch processing)', type: 'string', default: '' }) .option('output-extension', { describe: 'Extension for output files (for batch processing)', type: 'string' }) .option('media-types', { describe: 'Media types to process (for batch processing)', type: 'array', choices: ['video', 'audio', 'image'], default: ['video', 'audio', 'image'] }) .option('concurrency', { describe: 'Number of files to process concurrently (for batch processing)', type: 'number', default: 1 }) .option('fancy-ui', { describe: 'Use fancy terminal UI for batch processing', type: 'boolean', default: true }) // Transcoding options .option('preset', { alias: 'p', describe: 'Use a predefined preset (e.g., youtube-hd, twitter, instagram)', type: 'string' }) .option('width', { alias: 'w', describe: 'Output video width', type: 'number' }) .option('height', { alias: 'h', describe: 'Output video height', type: 'number' }) .option('bitrate', { alias: 'b', describe: 'Output video bitrate (e.g., 1M, 5M)', type: 'string' }) .option('fps', { alias: 'f', describe: 'Output video frame rate', type: 'number' }) .option('codec', { alias: 'c', describe: 'Video codec to use (e.g., h264, h265)', type: 'string' }) .option('audio-codec', { alias: 'a', describe: 'Audio codec to use (e.g., aac, mp3)', type: 'string' }) .option('audio-bitrate', { describe: 'Audio bitrate (e.g., 128k, 256k)', type: 'string' }) // Thumbnail options .option('thumbnails', { alias: 't', describe: 'Number of thumbnails to generate during transcoding', type: 'number' }) .option('thumbnails-only', { describe: 'Generate thumbnails without transcoding', type: 'boolean' }) .option('count', { describe: 'Number of thumbnails to generate (for thumbnails-only mode)', type: 'number', default: 3 }) .option('format', { describe: 'Thumbnail format (jpg or png)', type: 'string', choices: ['jpg', 'png'], default: 'jpg' }) .option('timestamps', { describe: 'Specific timestamps for thumbnails (comma-separated, in seconds or HH:MM:SS format)', type: 'string' }) .option('thumbnail-output', { describe: 'Output pattern for thumbnails (e.g., "thumb-%d.jpg")', type: 'string' }) // Watermark options .option('watermark-image', { describe: 'Path to image file to use as watermark', type: 'string' }) .option('watermark-text', { describe: 'Text to use as watermark', type: 'string' }) .option('watermark-position', { describe: 'Position of the watermark', type: 'string', choices: ['topLeft', 'topRight', 'bottomLeft', 'bottomRight', 'center'], default: 'bottomRight' }) .option('watermark-opacity', { describe: 'Opacity of the watermark (0.0 to 1.0)', type: 'number', default: 1.0 }) .option('watermark-margin', { describe: 'Margin from the edge in pixels', type: 'number', default: 20 }) .option('watermark-font-size', { describe: 'Font size for text watermark in pixels', type: 'number', default: 72 }) .option('watermark-font-color', { describe: 'Font color for text watermark', type: 'string', default: 'yellow' }) .option('watermark-font', { describe: 'Path to font file for text watermark (if not specified, will try to find a system font)', type: 'string' }) .option('watermark-box-color', { describe: 'Background box color for text watermark (e.g., "black@0.9" for nearly opaque black)', type: 'string', default: 'black@0.9' }) // Trim options .option('trim', { describe: 'Enable video trimming', type: 'boolean' }) .option('start', { describe: 'Start time for trimming (in seconds or HH:MM:SS format)', type: 'string' }) .option('end', { describe: 'End time for trimming (in seconds or HH:MM:SS format)', type: 'string' }) // Audio enhancement options .option('audio-normalize', { describe: 'Normalize audio levels for consistent volume', type: 'boolean', default: false }) .option('audio-noise-reduction', { describe: 'Reduce background noise (0.0 to 1.0, higher values = more reduction)', type: 'number' }) .option('audio-fade-in', { describe: 'Fade in duration in seconds', type: 'number' }) .option('audio-fade-out', { describe: 'Fade out duration in seconds', type: 'number' }) .option('audio-volume', { describe: 'Volume adjustment factor (1.0 = original volume)', type: 'number' }) // Other options .option('verbose', { alias: 'v', describe: 'Show detailed progress information', type: 'boolean', default: false }) .option('ffmpeg-args', { describe: 'Pass custom arguments directly to ffmpeg (e.g., "--ffmpeg-args=\'-vf eq=brightness=0.1\'")', type: 'string' }) .option('help', { alias: '?', describe: 'Show help', type: 'boolean' }) .middleware((argv) => { // Expand tilde in paths if (argv.path) { argv.path = expandTildePath(argv.path); } if (argv.outputDir) { argv.outputDir = expandTildePath(argv.outputDir); } if (argv._[0]) { argv._[0] = expandTildePath(argv._[0]); } if (argv._[1]) { argv._[1] = expandTildePath(argv._[1]); } return argv; }) .demandCommand(0) .help(); } /** * Handle thumbnails-only mode * * @param {Object} argv - Command-line arguments */ export async function handleThumbnailsOnly(argv) { const input = argv._[0]; if (!input) { console.error(colors.red('Error: Input file is required for thumbnails-only mode')); process.exit(1); } if (!fs.existsSync(input)) { console.error(colors.red(`Error: Input file "${input}" does not exist`)); process.exit(1); } // Get video duration first to provide better progress information try { const duration = await getVideoDuration(input); console.log(`Video duration: ${formatTime(duration)}`); const options = { count: argv.count, format: argv.format, outputPattern: argv.thumbnailOutput || path.join(path.dirname(input), 'thumbnail-%d.' + argv.format) }; if (argv.timestamps) { options.timestamps = true; options.timestampList = argv.timestamps.split(',').map(t => t.trim()); } console.log(`Generating ${options.count} thumbnails from ${input}...`); // Create a progress bar const progressBar = new cliProgress.SingleBar({ format: colors.cyan('{bar}') + ' | ' + colors.yellow('Generating thumbnails'), barCompleteChar: '\u2588', barIncompleteChar: '\u2591', hideCursor: true }); progressBar.start(100, 0); try { const thumbnails = await generateThumbnailsEfficient(input, options); progressBar.update(100); progressBar.stop(); console.log(colors.green('\nThumbnails generated successfully:')); thumbnails.forEach(thumbnail => console.log(`- ${colors.yellow(thumbnail)}`)); } catch (err) { progressBar.stop(); console.error(colors.red('Error:'), err.message); process.exit(1); } } catch (durationErr) { console.warn(colors.yellow(`Warning: Could not determine video duration: ${durationErr.message}`)); console.error(colors.red('Error: Failed to generate thumbnails')); process.exit(1); } } /** * Prepare transcoding options from command-line arguments * * @param {Object} argv - Command-line arguments * @returns {Object} - Transcoding options */ export function prepareTranscodeOptions(argv) { const options = {}; // Add preset if specified if (argv.preset) { options.preset = argv.preset; } // Add video options if (argv.width || argv.height) { options.width = argv.width || -1; options.height = argv.height || -1; } if (argv.bitrate) options.videoBitrate = argv.bitrate; if (argv.fps) options.fps = argv.fps; if (argv.codec) options.videoCodec = argv.codec; // Add audio options if (argv.audioCodec) options.audioCodec = argv.audioCodec; if (argv.audioBitrate) options.audioBitrate = argv.audioBitrate; // Add custom ffmpeg arguments if specified if (argv.ffmpegArgs) { options.ffmpegArgs = argv.ffmpegArgs; } // Add audio enhancement options if (argv.audioNormalize || argv.audioNoiseReduction !== undefined || argv.audioFadeIn !== undefined || argv.audioFadeOut !== undefined || argv.audioVolume !== undefined) { options.audio = {}; if (argv.audioNormalize) { options.audio.normalize = true; } if (argv.audioNoiseReduction !== undefined) { options.audio.noiseReduction = Math.min(1, Math.max(0, argv.audioNoiseReduction)); } if (argv.audioFadeIn !== undefined) { options.audio.fadeIn = argv.audioFadeIn; } if (argv.audioFadeOut !== undefined) { options.audio.fadeOut = argv.audioFadeOut; } if (argv.audioVolume !== undefined) { options.audio.volume = argv.audioVolume; } // For audio-only files, don't set a specific codec if none is provided // This will allow the original format to be preserved if (!argv.audioCodec && argv['media-types'] && argv['media-types'].length === 1 && argv['media-types'][0] === 'audio') { // Remove any codec that might have been set by a preset delete options.audioCodec; } } // Add thumbnail options if (argv.thumbnails) { options.thumbnails = { count: argv.thumbnails, format: argv.format }; if (argv.thumbnailOutput) { options.thumbnails.outputPattern = argv.thumbnailOutput; } } // Add watermark options if (argv.watermarkImage || argv.watermarkText) { options.watermark = {}; if (argv.watermarkImage) { options.watermark.image = argv.watermarkImage; } if (argv.watermarkText) { options.watermark.text = argv.watermarkText; } options.watermark.position = argv.watermarkPosition; options.watermark.opacity = argv.watermarkOpacity; options.watermark.margin = argv.watermarkMargin; if (argv.watermarkText) { options.watermark.fontSize = argv.watermarkFontSize; options.watermark.fontColor = argv.watermarkFontColor; if (argv.watermarkBoxColor) { options.watermark.boxColor = argv.watermarkBoxColor; } if (argv.watermarkFont) { options.watermark.fontFile = argv.watermarkFont; } } } // Add trim options if (argv.trim && (argv.start || argv.end)) { options.trim = {}; if (argv.start) options.trim.start = argv.start; if (argv.end) options.trim.end = argv.end; } // Add overwrite option options.overwrite = true; // Add verbose option if (argv.verbose) { options.verbose = true; } return options; } /** * Prepare batch processing options from command-line arguments * * @param {Object} argv - Command-line arguments * @returns {Object} - Batch processing options */ export function prepareBatchOptions(argv) { const options = { transcodeOptions: prepareTranscodeOptions(argv) }; // Add output directory if (argv.outputDir) { options.outputDir = argv.outputDir; } // Add output filename options if (argv.outputPrefix) { options.outputPrefix = argv.outputPrefix; } if (argv.outputSuffix) { options.outputSuffix = argv.outputSuffix; } if (argv.outputExtension) { options.outputExtension = argv.outputExtension.startsWith('.') ? argv.outputExtension : `.${argv.outputExtension}`; } // Add concurrency if (argv.concurrency) { options.concurrency = argv.concurrency; } return options; } /** * Prepare scan options from command-line arguments * * @param {Object} argv - Command-line arguments * @returns {Object} - Scan options */ export function prepareScanOptions(argv) { const options = {}; // Add media types - use the correct property name from argv if (argv['media-types']) { options.mediaTypes = argv['media-types']; } // Add recursive option if (argv.recursive) { options.recursive = argv.recursive; } return options; } /** * Display transcoding results * * @param {Object} result - Transcoding result */ export function displayTranscodeResults(result) { console.log(colors.green(`\nTranscoding completed successfully: ${result.outputPath}`)); // Log the FFmpeg command if (result.ffmpegCommand) { console.log('\nEquivalent FFmpeg command:'); console.log(colors.cyan(result.ffmpegCommand)); // Check if the command includes video filters if (result.ffmpegCommand.includes('-vf')) { console.log('\nCommand includes video filters:'); const vfIndex = result.ffmpegCommand.indexOf('-vf'); const nextArgIndex = result.ffmpegCommand.indexOf(' ', vfIndex + 4); const filter = result.ffmpegCommand.substring(vfIndex + 4, nextArgIndex); console.log(colors.yellow(filter)); } else { console.log('\nCommand does not include video filters'); } // Check if the command includes audio filters if (result.ffmpegCommand.includes('-af')) { console.log('\nCommand includes audio filters:'); const afIndex = result.ffmpegCommand.indexOf('-af'); const nextArgIndex = result.ffmpegCommand.indexOf(' ', afIndex + 4); const filter = result.ffmpegCommand.substring(afIndex + 4, nextArgIndex); console.log(colors.yellow(filter)); } } // Display metadata if available if (result.metadata) { console.log('\nVideo Metadata:'); // Format metadata if (result.metadata.format) { console.log(colors.green('\nFormat:')); console.log(` Format: ${colors.yellow(result.metadata.format.formatName || 'Unknown')}`); console.log(` Duration: ${colors.yellow(formatTime(result.metadata.format.duration || 0))}`); console.log(` Size: ${colors.yellow(formatFileSize(result.metadata.format.size || 0))}`); console.log(` Bitrate: ${colors.yellow((result.metadata.format.bitrate / 1000).toFixed(2) + ' kbps' || 'Unknown')}`); } // Video stream metadata if (result.metadata.video && Object.keys(result.metadata.video).length > 0) { console.log(colors.green('\nVideo:')); console.log(` Codec: ${colors.yellow(result.metadata.video.codec || 'Unknown')}`); console.log(` Resolution: ${colors.yellow(result.metadata.video.width + 'x' + result.metadata.video.height || 'Unknown')}`); console.log(` Aspect Ratio: ${colors.yellow(result.metadata.video.aspectRatio || 'Unknown')}`); console.log(` Frame Rate: ${colors.yellow(result.metadata.video.fps?.toFixed(2) + ' fps' || 'Unknown')}`); console.log(` Pixel Format: ${colors.yellow(result.metadata.video.pixelFormat || 'Unknown')}`); if (result.metadata.video.bitrate) { console.log(` Bitrate: ${colors.yellow((result.metadata.video.bitrate / 1000).toFixed(2) + ' kbps' || 'Unknown')}`); } } // Audio stream metadata if (result.metadata.audio && Object.keys(result.metadata.audio).length > 0) { console.log(colors.green('\nAudio:')); console.log(` Codec: ${colors.yellow(result.metadata.audio.codec || 'Unknown')}`); console.log(` Sample Rate: ${colors.yellow(result.metadata.audio.sampleRate + ' Hz' || 'Unknown')}`); console.log(` Channels: ${colors.yellow(result.metadata.audio.channels || 'Unknown')}`); console.log(` Channel Layout: ${colors.yellow(result.metadata.audio.channelLayout || 'Unknown')}`); if (result.metadata.audio.bitrate) { console.log(` Bitrate: ${colors.yellow((result.metadata.audio.bitrate / 1000).toFixed(2) + ' kbps' || 'Unknown')}`); } } } if (result.thumbnails && result.thumbnails.length > 0) { console.log('\nThumbnails generated:'); result.thumbnails.forEach(thumbnail => console.log(`- ${colors.yellow(thumbnail)}`)); } } /** * Display batch processing results * * @param {Object} results - Batch processing results */ export function displayBatchResults(results) { console.log(colors.green(`\nBatch processing completed successfully!`)); // Count skipped files const skippedFiles = results.failed.filter(item => item.skipped); const failedFiles = results.failed.filter(item => !item.skipped); console.log(colors.green(`Processed ${results.total} files: ${results.successful.length} successful, ${failedFiles.length} failed, ${skippedFiles.length} skipped`)); if (results.successful.length > 0) { console.log(colors.green('\nSuccessfully processed files:')); results.successful.forEach((item, index) => { console.log(`${index + 1}. ${colors.yellow(path.basename(item.input))} → ${colors.yellow(path.basename(item.output))}`); }); } if (skippedFiles.length > 0) { console.log(colors.yellow('\nSkipped files (format not fully supported for enhancement):')); skippedFiles.forEach((item, index) => { console.log(`${index + 1}. ${colors.yellow(path.basename(item.input))}: ${colors.yellow(item.warning)}`); }); } if (failedFiles.length > 0) { console.log(colors.red('\nFailed files:')); failedFiles.forEach((item, index) => { console.log(`${index + 1}. ${colors.yellow(path.basename(item.input))}: ${colors.red(item.error)}`); }); } } /** * Create a progress bar for transcoding * * @param {number} duration - Video duration in seconds * @returns {Object} - CLI progress bar */ export function createTranscodeProgressBar(duration) { return new cliProgress.SingleBar({ format: colors.cyan('{bar}') + ' | ' + colors.yellow('{percentage}%') + ' | ' + colors.green('{fps} fps') + ' | ' + colors.blue('Time: {time}') + ' | ' + colors.magenta('ETA: {eta}'), barCompleteChar: '\u2588', barIncompleteChar: '\u2591', hideCursor: true, clearOnComplete: false, barsize: 30 }, cliProgress.Presets.shades_classic); } /** * Update progress bar with transcoding progress * * @param {Object} progressBar - CLI progress bar * @param {Object} progress - Progress information * @param {number} duration - Video duration in seconds */ export function updateProgressBar(progressBar, progress, duration) { const currentTime = progress.time || 0; const percentage = duration ? Math.min(100, (currentTime / duration) * 100) : 0; progressBar.update(percentage, { fps: progress.fps ? `${progress.fps}` : '0', time: formatTime(currentTime), eta: formatTime(duration ? (duration - currentTime) / (progress.speed || 1) : 0) }); } /** * Create a batch progress bar * * @param {number} total - Total number of files * @returns {Object} - CLI progress bar */ export function createBatchProgressBar(total) { return new cliProgress.MultiBar({ clearOnComplete: false, hideCursor: true, format: '{bar} | {percentage}% | {value}/{total} files | {file}', barCompleteChar: '\u2588', barIncompleteChar: '\u2591' }, cliProgress.Presets.shades_classic); }