UNPKG

@profullstack/transcoder

Version:

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

755 lines (645 loc) 26.4 kB
/** * @profullstack/transcoder - Video Module * Contains functionality for transcoding video files */ import { spawn } from 'child_process'; import fs from 'fs'; import path from 'path'; import { TranscodeEmitter, DEFAULT_OPTIONS, checkFfmpeg, parseProgress } from './core.js'; import { getVideoMetadata, getVideoDuration, validatePaths, ensureOutputDirectory } from './utils.js'; import { generateThumbnails } from './thumbnails.js'; import { getPreset, getResponsiveProfileSet, PRESETS } from '../presets.js'; /** * Transcodes a video file to web-friendly MP4 format * * @param {string} inputPath - Path to the input video file * @param {string} outputPath - Path where the transcoded video will be saved * @param {Object} [options={}] - Transcoding options * @returns {Promise<Object>} - Promise that resolves with the output path and emitter */ export async function transcode(inputPath, outputPath, options = {}) { // Create an emitter for progress events const emitter = new TranscodeEmitter(); // Handle platform-specific presets let mergedOptions = { ...options }; // If a preset name is provided, get the preset configuration if (options.preset && typeof options.preset === 'string' && PRESETS[options.preset.toLowerCase()]) { const presetConfig = getPreset(options.preset); if (presetConfig) { // Merge preset with user options (user options take precedence over preset) mergedOptions = { ...presetConfig, ...options }; // Remove the preset name to avoid confusion with ffmpeg's preset parameter if (mergedOptions.preset === options.preset) { // If the preset name is the same as the original options.preset, // restore the ffmpeg preset value from the preset config mergedOptions.preset = presetConfig.preset; } } } // Merge default options with user options (including preset if applicable) const settings = { ...DEFAULT_OPTIONS, ...mergedOptions }; // Extract thumbnails option if present const thumbnailOptions = settings.thumbnails; delete settings.thumbnails; // Extract trim option if present const trimOptions = settings.trim; delete settings.trim; // Extract onProgress callback if present const onProgress = settings.onProgress; delete settings.onProgress; // Extract verbose flag if present const verbose = settings.verbose; delete settings.verbose; // Validate input and output paths await validatePaths(inputPath, outputPath, settings.overwrite); // Check if ffmpeg is installed try { await checkFfmpeg(); } catch (error) { throw error; } // Get video duration for progress calculation let duration = 0; try { duration = await getVideoDuration(inputPath); } catch (error) { if (verbose) { console.warn(`Warning: Could not determine video duration: ${error.message}`); } } // Build ffmpeg arguments const ffmpegArgs = []; // Add trim start time if specified (before input for faster seeking) if (trimOptions && trimOptions.start) { ffmpegArgs.push('-ss', trimOptions.start); } // Add input file ffmpegArgs.push('-i', inputPath); // Add trim end time if specified (after input) if (trimOptions && trimOptions.end) { ffmpegArgs.push('-to', trimOptions.end); } // Add video codec ffmpegArgs.push('-c:v', settings.videoCodec); // Add audio codec ffmpegArgs.push('-c:a', settings.audioCodec); // Add video bitrate if specified if (settings.videoBitrate) { ffmpegArgs.push('-b:v', settings.videoBitrate); } // Add audio bitrate if specified if (settings.audioBitrate) { ffmpegArgs.push('-b:a', settings.audioBitrate); } // Prepare video filters let videoFilters = []; // Prepare audio filters let audioFilters = []; // Handle audio enhancement options if (settings.audio) { // Audio normalization if (settings.audio.normalize) { audioFilters.push('loudnorm=I=-16:TP=-1.5:LRA=11'); if (verbose) { console.log('Adding audio normalization filter'); } } // Noise reduction if (settings.audio.noiseReduction > 0) { // Ensure the value is between 0 and 1 const nrValue = Math.min(1, Math.max(0, settings.audio.noiseReduction)); // Convert to a value between 0.01 and 0.97 for the FFmpeg filter const nrAmount = 0.01 + (nrValue * 0.96); // Use a valid noise floor value (in dB, between -80 and -20) const noiseFloor = -60; // A reasonable default value audioFilters.push(`afftdn=nr=${nrAmount}:nf=${noiseFloor}`); if (verbose) { console.log(`Adding noise reduction filter: ${nrAmount}`); } } // Fade in if (settings.audio.fadeIn > 0) { audioFilters.push(`afade=t=in:st=0:d=${settings.audio.fadeIn}`); if (verbose) { console.log(`Adding fade in filter: ${settings.audio.fadeIn}s`); } } // Fade out if (settings.audio.fadeOut > 0) { // Get audio duration to calculate fade out start time try { if (duration > 0) { const fadeOutStart = Math.max(0, duration - settings.audio.fadeOut); audioFilters.push(`afade=t=out:st=${fadeOutStart}:d=${settings.audio.fadeOut}`); if (verbose) { console.log(`Adding fade out filter: ${settings.audio.fadeOut}s starting at ${fadeOutStart}s`); } } else { // If we can't get the duration, add a fade out without a specific start time audioFilters.push(`afade=t=out:d=${settings.audio.fadeOut}`); if (verbose) { console.log(`Adding fade out filter without start time: ${settings.audio.fadeOut}s`); } } } catch (error) { if (verbose) { console.warn(`Warning: Could not determine audio duration for fade out: ${error.message}`); } // If we can't get the duration, add a fade out without a specific start time audioFilters.push(`afade=t=out:d=${settings.audio.fadeOut}`); } } // Volume adjustment if (settings.audio.volume && settings.audio.volume !== 1.0) { // Ensure the value is reasonable (0.1 to 10.0) const volValue = Math.min(10, Math.max(0.1, settings.audio.volume)); audioFilters.push(`volume=${volValue}`); if (verbose) { console.log(`Adding volume adjustment filter: ${volValue}`); } } } // Add scaling filter if specified if (settings.width > 0 && settings.height > 0) { videoFilters.push(`scale=${settings.width}:${settings.height}`); } else if (settings.width > 0) { videoFilters.push(`scale=${settings.width}:-1`); } else if (settings.height > 0) { videoFilters.push(`scale=-1:${settings.height}`); } // Add watermark if specified if (settings.watermark) { const watermark = settings.watermark; // Validate watermark settings if (!watermark.image && !watermark.text) { throw new Error('Watermark must have either image or text property'); } try { // Flag to track if we should skip watermark processing let skipWatermark = false; if (watermark.image) { // Check if watermark image exists if (!fs.existsSync(watermark.image)) { // Check if this is an intentional test case if (watermark.image.includes('intentionally-non-existent')) { if (verbose) { console.warn(`Notice: Using intentionally non-existent watermark image for testing: ${watermark.image}`); console.warn('Skipping watermark for this test case'); } // Skip watermark processing but continue with transcoding skipWatermark = true; } else { throw new Error(`Watermark image does not exist: ${watermark.image}`); } } // Only proceed with watermark if we're not skipping it if (!skipWatermark) { // Set default values const position = watermark.position || 'bottomRight'; const opacity = watermark.opacity || 0.7; const margin = watermark.margin || 10; // Calculate position let positionFilter = ''; switch (position) { case 'topLeft': positionFilter = `${margin}:${margin}`; break; case 'topRight': positionFilter = `main_w-overlay_w-${margin}:${margin}`; break; case 'bottomLeft': positionFilter = `${margin}:main_h-overlay_h-${margin}`; break; case 'bottomRight': positionFilter = `main_w-overlay_w-${margin}:main_h-overlay_h-${margin}`; break; case 'center': positionFilter = `(main_w-overlay_w)/2:(main_h-overlay_h)/2`; break; default: positionFilter = `main_w-overlay_w-${margin}:main_h-overlay_h-${margin}`; } // Use complex filter for image watermarks // Add the image as a second input ffmpegArgs.splice(2, 0, '-i', watermark.image); // Use filter_complex instead of vf for multiple inputs const complexFilter = `[0:v][1:v]overlay=${positionFilter}:alpha=${opacity}[out]`; // Remove any existing video filters videoFilters = []; // Add the complex filter ffmpegArgs.push('-filter_complex', complexFilter); ffmpegArgs.push('-map', '[out]'); // Set a flag to indicate we're using a complex filter settings.usingComplexFilter = true; } } else if (watermark.text) { // For text watermarks, we'll create a temporary image file with the text // This is a workaround for systems where the drawtext filter is not available // Create a temporary directory for the watermark image if it doesn't exist const tempDir = path.join(path.dirname(outputPath), '.temp'); if (!fs.existsSync(tempDir)) { fs.mkdirSync(tempDir, { recursive: true }); } // Create a unique filename for the watermark image const tempWatermarkImage = path.join(tempDir, `watermark-${Date.now()}.png`); // Set default values const position = watermark.position || 'bottomRight'; const opacity = watermark.opacity || 0.7; const margin = watermark.margin || 10; // Use drawtext filter directly for text watermarking if (verbose) { console.log('Using drawtext filter for text watermark'); } // Use the position, opacity, and margin values already defined above // Determine text color and font size - use much larger values for better visibility const fontColor = watermark.fontColor || 'yellow'; const fontSize = watermark.fontSize || 120; // Much larger font size // Calculate position for the text let x, y; // Calculate position for the text switch (position) { case 'topLeft': x = margin; y = margin + fontSize; // Add font size to ensure text is visible break; case 'topRight': x = `w-text_w-${margin}`; y = margin + fontSize; break; case 'bottomLeft': x = margin; y = `h-${margin}`; break; case 'bottomRight': x = `w-text_w-${margin}`; y = `h-${margin}`; break; case 'center': x = '(w-text_w)/2'; y = '(h-text_h)/2'; break; default: x = `w-text_w-${margin}`; y = `h-${margin}`; } // Try to find a system font that's likely to be available let fontFile = watermark.fontFile; if (!fontFile) { // First, try common font locations const fontPaths = [ '/usr/share/fonts/Adwaita/AdwaitaSans-Bold.ttf', '/usr/share/fonts/Adwaita/AdwaitaSans-Regular.ttf', '/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf', '/usr/share/fonts/TTF/DejaVuSans-Bold.ttf', '/usr/share/fonts/truetype/liberation/LiberationSans-Bold.ttf', '/System/Library/Fonts/Helvetica.ttc', '/Windows/Fonts/arial.ttf' ]; for (const path of fontPaths) { if (fs.existsSync(path)) { fontFile = path; if (verbose) { console.log(`Using font: ${path}`); } break; } } // If no common font is found, try to find any TTF font on the system if (!fontFile) { try { // Use child_process.execSync to find a TTF font const { execSync } = require('child_process'); const fontSearch = execSync('find /usr/share/fonts -name "*.ttf" | head -1').toString().trim(); if (fontSearch && fs.existsSync(fontSearch)) { fontFile = fontSearch; if (verbose) { console.log(`Using font: ${fontSearch}`); } } } catch (err) { if (verbose) { console.warn(`Warning: Could not find any TTF font on the system: ${err.message}`); } } } } // Create drawtext filter with the font file if available let textFilter; if (fontFile && fs.existsSync(fontFile)) { if (verbose) { console.log(`Using font file: ${fontFile}`); } textFilter = `drawtext=fontfile=${fontFile}:text='${watermark.text}':x=${x}:y=${y}:fontsize=${fontSize}:fontcolor=${fontColor}:box=1:boxcolor=black@0.8:boxborderw=10`; } else { // Fallback without fontfile - use a colored rectangle instead if (verbose) { console.warn('Warning: No suitable font found for text watermark. Using colored rectangle instead.'); } // Create a bright colored rectangle at the specified position let rectX, rectY; // Calculate position for the rectangle switch (position) { case 'topLeft': rectX = margin; rectY = margin; break; case 'topRight': rectX = `w-300-${margin}`; rectY = margin; break; case 'bottomLeft': rectX = margin; rectY = `h-100-${margin}`; break; case 'bottomRight': rectX = `w-300-${margin}`; rectY = `h-100-${margin}`; break; case 'center': rectX = '(w-300)/2'; rectY = '(h-100)/2'; break; default: rectX = `w-300-${margin}`; rectY = `h-100-${margin}`; } // Create a bright magenta rectangle that will be visible on any background textFilter = `drawbox=x=${rectX}:y=${rectY}:w=300:h=100:color=magenta@${opacity}:t=fill`; } // Use the drawtext filter videoFilters = [textFilter]; // Add cleanup function to delete the temporary watermark image after transcoding process.on('exit', () => { try { if (fs.existsSync(tempWatermarkImage)) { fs.unlinkSync(tempWatermarkImage); } } catch (err) { // Ignore errors during cleanup } }); } } catch (error) { if (verbose) { console.warn(`Warning: Failed to add watermark: ${error.message}`); console.warn('Continuing transcoding without watermark...'); } } } // Apply video filters if any and we're not using a complex filter if (videoFilters.length > 0 && !settings.usingComplexFilter) { // Use -vf for all filters now that we're using drawbox instead of overlay ffmpegArgs.push('-vf', videoFilters.join(',')); // Log the filter being used for debugging if (verbose) { console.log(`Using video filter: ${videoFilters.join(',')}`); } } else if (videoFilters.length === 0 && !settings.usingComplexFilter && verbose) { console.log('No video filters applied'); } // Apply audio filters if any if (audioFilters.length > 0) { ffmpegArgs.push('-af', audioFilters.join(',')); // Log the filter being used for debugging if (verbose) { console.log(`Using audio filter: ${audioFilters.join(',')}`); } } // Add fps if specified if (settings.fps > 0) { ffmpegArgs.push('-r', settings.fps.toString()); } // Add preset ffmpegArgs.push('-preset', settings.preset); // Add profile ffmpegArgs.push('-profile:v', settings.profile); // Add level ffmpegArgs.push('-level', settings.level); // Add pixel format ffmpegArgs.push('-pix_fmt', settings.pixelFormat); // Add movflags for web optimization ffmpegArgs.push('-movflags', settings.movflags); // Add thread count ffmpegArgs.push('-threads', settings.threads.toString()); // Add progress output ffmpegArgs.push('-progress', 'pipe:1'); // Add overwrite flag if needed if (settings.overwrite) { ffmpegArgs.push('-y'); } else { ffmpegArgs.push('-n'); } // Add custom ffmpeg arguments if specified if (settings.ffmpegArgs) { // Split the custom arguments string into an array of arguments // This handles arguments with spaces correctly by respecting quotes const customArgs = settings.ffmpegArgs.match(/(?:[^\s"']+|"[^"]*"|'[^']*')+/g) || []; // Remove any quotes from the arguments const processedArgs = customArgs.map(arg => arg.replace(/^['"]|['"]$/g, '')); if (verbose) { console.log(`Adding custom ffmpeg arguments: ${processedArgs.join(' ')}`); } // Add the custom arguments to the ffmpeg command ffmpegArgs.push(...processedArgs); } // Add output file ffmpegArgs.push(outputPath); // Store the complete FFmpeg command for logging const ffmpegCommand = `ffmpeg ${ffmpegArgs.join(' ')}`; return new Promise((resolve, reject) => { // Spawn ffmpeg process const ffmpegProcess = spawn('ffmpeg', ffmpegArgs); let errorOutput = ''; // Handle stdout (progress information) ffmpegProcess.stdout.on('data', (data) => { const dataStr = data.toString(); const progress = parseProgress(dataStr); if (progress) { // Add duration to progress for percentage calculation progress.duration = duration; // Emit progress event emitter.emit('progress', progress); // Call onProgress callback if provided if (typeof onProgress === 'function') { onProgress(progress); } } }); // Handle stderr (log information) ffmpegProcess.stderr.on('data', (data) => { const dataStr = data.toString(); errorOutput += dataStr; // FFmpeg outputs progress information to stderr as well const progress = parseProgress(dataStr); if (progress) { // Add duration to progress for percentage calculation progress.duration = duration; // Emit progress event emitter.emit('progress', progress); // Call onProgress callback if provided if (typeof onProgress === 'function') { onProgress(progress); } } // Emit log event emitter.emit('log', dataStr); }); // Handle process exit ffmpegProcess.on('close', async (code) => { if (code === 0) { // Check if output file was created if (!fs.existsSync(outputPath)) { return reject(new Error('Transcoding failed: Output file was not created')); } // Extract metadata from the input video try { const metadata = await getVideoMetadata(inputPath); // Generate thumbnails if requested if (thumbnailOptions) { try { const thumbnailDir = path.dirname(outputPath); const thumbnails = await generateThumbnails(inputPath, thumbnailDir, thumbnailOptions); resolve({ outputPath, emitter, thumbnails, ffmpegCommand, metadata }); } catch (thumbnailError) { // If thumbnail generation fails, still return the transcoded video if (verbose) { console.error(`Thumbnail generation failed: ${thumbnailError.message}`); } resolve({ outputPath, emitter, ffmpegCommand, metadata }); } } else { resolve({ outputPath, emitter, ffmpegCommand, metadata }); } } catch (metadataError) { if (verbose) { console.warn(`Warning: Failed to extract metadata: ${metadataError.message}`); } // Generate thumbnails if requested if (thumbnailOptions) { try { const thumbnailDir = path.dirname(outputPath); const thumbnails = await generateThumbnails(inputPath, thumbnailDir, thumbnailOptions); resolve({ outputPath, emitter, thumbnails, ffmpegCommand }); } catch (thumbnailError) { // If thumbnail generation fails, still return the transcoded video if (verbose) { console.error(`Thumbnail generation failed: ${thumbnailError.message}`); } resolve({ outputPath, emitter, ffmpegCommand }); } } else { resolve({ outputPath, emitter, ffmpegCommand }); } } } else { reject(new Error(`FFmpeg transcoding failed with code ${code}: ${errorOutput}`)); } }); // Handle process error ffmpegProcess.on('error', (err) => { reject(new Error(`Failed to start FFmpeg process: ${err.message}`)); }); // Emit start event emitter.emit('start', { command: 'ffmpeg', args: ffmpegArgs }); }); } /** * Transcodes a video file into multiple versions optimized for different devices and connection speeds * * @param {string} inputPath - Path to the input video file * @param {Object} options - Transcoding options * @param {boolean} options.responsive - Whether to generate responsive profiles * @param {Array<string>} options.profiles - Array of profile names to generate (e.g., ['mobile', 'web', 'hd']) * @param {string} options.outputDir - Directory where the transcoded videos will be saved * @param {string} options.filenamePattern - Pattern for output filenames (e.g., 'video-%s.mp4' where %s will be replaced with profile name) * @returns {Promise<Object>} - Promise that resolves with an object containing the output paths and emitters for each profile */ export async function transcodeResponsive(inputPath, options = {}) { // Validate input path if (!inputPath || typeof inputPath !== 'string') { throw new Error('Input path is required and must be a string'); } // Check if input file exists if (!fs.existsSync(inputPath)) { throw new Error(`Input file does not exist: ${inputPath}`); } // Default options const settings = { responsive: true, profiles: ['mobile', 'web', 'hd'], outputDir: path.dirname(inputPath), filenamePattern: '%s-' + path.basename(inputPath), ...options }; // If a profile set name is provided, use those profiles if (options.profileSet && typeof options.profileSet === 'string') { const profileSet = getResponsiveProfileSet(options.profileSet); if (profileSet) { settings.profiles = profileSet; } else { if (options.verbose) { console.warn(`Warning: Profile set "${options.profileSet}" not found. Using default profiles.`); } } } // Validate profiles if (!Array.isArray(settings.profiles) || settings.profiles.length === 0) { throw new Error('At least one profile must be specified'); } // Validate profiles exist for (const profile of settings.profiles) { if (!PRESETS[profile.toLowerCase()]) { throw new Error(`Profile "${profile}" is not a valid preset`); } } // Ensure output directory exists if (!fs.existsSync(settings.outputDir)) { try { fs.mkdirSync(settings.outputDir, { recursive: true }); } catch (err) { throw new Error(`Failed to create output directory: ${err.message}`); } } // Create a result object to store all outputs const result = { inputPath, outputs: {} }; // Process each profile for (const profile of settings.profiles) { // Generate output path for this profile const outputFilename = settings.filenamePattern.replace('%s', profile); const outputPath = path.join(settings.outputDir, outputFilename); // Get preset for this profile const preset = getPreset(profile); // Merge any additional options provided by the user const transcodeOptions = { ...preset, ...options.transcodeOptions }; // Transcode the video with this profile try { if (options.verbose) { console.log(`Transcoding ${profile} version: ${outputPath}`); } const output = await transcode(inputPath, outputPath, transcodeOptions); // Store the result result.outputs[profile] = { outputPath: output.outputPath, emitter: output.emitter, metadata: output.metadata, ffmpegCommand: output.ffmpegCommand }; // If thumbnails were generated, add them to the result if (output.thumbnails) { result.outputs[profile].thumbnails = output.thumbnails; } } catch (error) { if (options.verbose) { console.error(`Failed to transcode ${profile} version: ${error.message}`); } // Continue with other profiles even if one fails } } return result; }