UNPKG

ffmpeg-toolkit

Version:

A modern FFmpeg toolkit for Node.js

423 lines 14 kB
import ffmpegPath from '@ffmpeg-installer/ffmpeg'; import ffprobePath from '@ffprobe-installer/ffprobe'; import { EventEmitter } from 'events'; import ffmpeg from 'fluent-ffmpeg'; import { ensureDirSync, removeSync } from 'fs-extra'; import { join } from 'path'; import { PLATFORM_DIMENSIONS } from '../utils/constant.js'; import { generateId, logger } from '../utils/index.js'; /** * Base class for FFmpeg operations * Handles core video processing functionality */ export class BaseCore extends EventEmitter { constructor(config = {}) { super(); this.ffmpeg = ffmpeg; this.logger = null; this.defaultOptions = { threads: 0, bufsize: '32M', maxrate: '32M', preset: 'ultrafast', tune: 'fastdecode', cpuUsed: 1, fps: BaseCore.VIDEO_CONSTANTS.DEFAULT_FPS, customOptions: [ '-crf 23', '-profile:v high', '-level 4.0', '-pix_fmt yuv420p', '-vsync 1', '-max_muxing_queue_size 8192', '-color_primaries bt709', '-color_trc bt709', '-colorspace bt709', '-thread_type frame+slice', '-tile-columns 2', '-frame-parallel 1', '-row-mt 1', '-deadline realtime', '-quality realtime', '-lag-in-frames 25', '-auto-alt-ref 1', '-arnr-maxframes 7', '-arnr-strength 5', '-flags +global_header', '-movflags +faststart', '-strict experimental', ], inputOptions: [ '-thread_queue_size 8192', '-avoid_negative_ts make_zero', '-max_delay 500000', '-fflags +genpts+igndts+discardcorrupt', '-probesize 32M', '-analyzeduration 0', '-hwaccel auto', '-hwaccel_output_format auto', ], }; this.initializeConfig(config); this.initializeFFmpeg(); this.initializeLogger(config); } /** * Initialize core configuration */ initializeConfig(config) { this.config = { timeout: config.timeout || 0, logger: config.logger || false, threads: config.threads || 4, rootOutput: config.rootOutput || join(process.cwd(), 'resources', 'ffmpegs'), }; this.rootOutput = this.config.rootOutput; this.state = { status: 'idle', lastUpdated: new Date(), }; } /** * Initialize FFmpeg settings and directories */ initializeFFmpeg() { this.ffmpeg.setFfmpegPath(ffmpegPath.path); this.ffmpeg.setFfprobePath(ffprobePath.path); ensureDirSync(this.rootOutput); this.pathOutputForder = join(this.rootOutput, generateId('ffmpeg')); ensureDirSync(this.pathOutputForder); } /** * Initialize logger if enabled */ initializeLogger(config) { if (this.config.logger && config.loggerPath) { this.logger = logger(config.loggerPath); } } /** * Handle errors in a consistent way across all classes */ handleError(error, context) { const errorMessage = error instanceof Error ? error.message : 'Unknown error'; const fullMessage = `${context}: ${errorMessage}`; // Log error if logger is enabled this.logger?.error(fullMessage); // Update state with error this.updateState('error', new Error(fullMessage)); // Throw error with context throw new Error(fullMessage); } /** * Public method to handle errors from outside the class */ handleModuleError(error, context) { return this.handleError(error, context); } /** * Get video information using ffprobe */ async getVideoInfo(videoPath) { try { return new Promise((resolve, reject) => { this.ffmpeg() .input(videoPath) .ffprobe((err, data) => { if (err) { reject(err); return; } const videoStream = data.streams[0]; if (!videoStream) { reject(new Error('Invalid video stream data')); return; } const videoInfo = { ...videoStream, width: videoStream.width, height: videoStream.height, codec: videoStream.codec_name, colorSpace: videoStream.color_space, }; resolve(videoInfo); }); }); } catch (error) { this.handleError(error, 'Error getting video info'); } } /** * Get platform-specific video filter */ getPlatformVideoFilter(platform, videoInfo) { const { width, height } = videoInfo; const currentAspectRatio = width && height ? width / height : 0; const platformConfig = PLATFORM_DIMENSIONS[platform]; if (!platformConfig) return ''; const [w, h] = platformConfig.aspectRatio.split(':').map(Number); if (!w || !h) return ''; const { targetW, targetH } = this.calculateTargetDimensions(w, h); const targetAspectRatio = w / h; const diff = Math.abs(currentAspectRatio - targetAspectRatio); if (diff > 0.01) { return `scale=${targetW}:${targetH}:force_original_aspect_ratio=increase,crop=${targetW}:${targetH}`; } return `scale=${targetW}:${targetH}`; } /** * Calculate target dimensions based on aspect ratio */ calculateTargetDimensions(w, h) { let targetW = 0, targetH = 0; const { MAX_RESOLUTION } = BaseCore.VIDEO_CONSTANTS; if (w >= h) { targetW = MAX_RESOLUTION; targetH = Math.round((targetW * h) / w); if (targetH > MAX_RESOLUTION) { targetH = MAX_RESOLUTION; targetW = Math.round((targetH * w) / h); } } else { targetH = MAX_RESOLUTION; targetW = Math.round((targetH * w) / h); if (targetW > MAX_RESOLUTION) { targetW = MAX_RESOLUTION; targetH = Math.round((targetW * h) / w); } } return { targetW, targetH }; } /** * Calculate optimal bitrate for video */ calculateBitrate(videoInfo) { const { MIN_BITRATE, MAX_BITRATE, BITRATE_FACTOR } = BaseCore.VIDEO_CONSTANTS; const resolution = videoInfo.width * videoInfo.height; const targetBitrate = Math.min(Math.max(resolution * BITRATE_FACTOR, MIN_BITRATE), MAX_BITRATE); const bitrateInMbps = Math.ceil(targetBitrate / 1000000); return { targetBitrate, bitrateInMbps }; } /** * Get optimized FFmpeg options for video */ getOptimizedOptions(videoInfo) { const { targetBitrate, bitrateInMbps } = this.calculateBitrate(videoInfo); const { DEFAULT_FPS } = BaseCore.VIDEO_CONSTANTS; return { ...this.defaultOptions, fps: videoInfo.fps, bufsize: `${bitrateInMbps}M`, maxrate: `${bitrateInMbps}M`, customOptions: [ `-r ${videoInfo.fps && videoInfo.fps > DEFAULT_FPS ? DEFAULT_FPS : videoInfo.fps}`, `-b:v ${targetBitrate}`, `-maxrate ${targetBitrate}`, `-bufsize ${targetBitrate * 2}`, '-preset veryfast', '-tune film', '-threads 4', '-cpu-used 1', '-x264-params "ref=3:me=hex:subme=4:trellis=0:fast-pskip=1:aq-mode=1:aq-strength=0.5"', '-rc-lookahead 20', '-sc_threshold 0', '-g 30', '-keyint_min 30', '-bf 2', '-b_strategy 1', '-qcomp 0.6', '-qdiff 4', '-qblur 0.5', '-qmin 10', '-qmax 51', ], }; } /** * Get FFmpeg options */ getFFmpegOptions(customOptions) { const options = { ...this.defaultOptions, ...customOptions }; const ffmpegOptions = [ `-threads ${options.threads}`, `-bufsize ${options.bufsize}`, `-maxrate ${options.maxrate}`, `-preset ${options.preset}`, `-tune ${options.tune}`, `-cpu-used ${options.cpuUsed}`, `-r ${options.fps}`, ]; if (options.customOptions) { ffmpegOptions.push(...options.customOptions); } return ffmpegOptions; } /** * Get input options */ getInputOptions(customOptions) { const options = { ...this.defaultOptions, ...customOptions }; return options.inputOptions || []; } /** * Set default FFmpeg options */ setDefaultOptions(options) { Object.assign(this.defaultOptions, options); } /** * Create response object */ response({ status, message, data }) { return { status, message, data }; } /** * Process video with FFmpeg */ async process(options) { try { const command = options.callback(); this.setupCommandOptions(command, options); return new Promise((resolve, reject) => { this.setupCommandHandlers(command, options, resolve, reject); if (options.pathOutput) { command.save(options.pathOutput); } else { command.run(); } }); } catch (error) { return this.response({ status: 'error', message: error instanceof Error ? error.message : 'Unknown error', data: null, }); } } /** * Setup FFmpeg command options */ setupCommandOptions(command, options) { const { isDefaultOptions = true } = options; const inputOptions = isDefaultOptions ? this.getInputOptions() : []; if (inputOptions.length > 0) { command.inputOptions(inputOptions); } const outputOptions = isDefaultOptions ? this.getFFmpegOptions() : []; if (outputOptions.length > 0) { command.addOptions(outputOptions); } if (options.platform && options.inputPath) { this.setupPlatformOptions(command, options); } } /** * Setup platform-specific options */ async setupPlatformOptions(command, options) { try { const videoInfo = await this.getVideoInfo(options.inputPath); const videoFilter = this.getPlatformVideoFilter(options.platform, videoInfo); if (videoFilter) { command.videoFilters(videoFilter); } } catch (error) { this.handleError(error, 'Error processing platform filter'); } } /** * Setup FFmpeg command event handlers */ setupCommandHandlers(command, options, resolve, reject) { command .on('start', (commandLine) => { this.logger?.info(`[FFmpeg START] ${commandLine}`); }) .on('end', async (stdout, stderr) => { this.logger?.success(`[FFmpeg END] ${stdout}`); if (options.onEnd) { await options.onEnd(stdout, stderr); } resolve(this.response({ status: 'success', message: 'success', data: options.data, })); }) .on('error', async (err, stdout, stderr) => { this.logger?.error(`[FFmpeg ERROR] ${err.message} ${stderr} ${stdout}`); if (options.onError) { await options.onError(err, stdout, stderr); } reject(this.response({ status: 'error', message: err.message, data: null, })); }) .on('progress', async (progress) => { this.logger?.debug(`[FFmpeg PROGRESS] ${JSON.stringify(progress)}`); if (options.onProgress) { await options.onProgress(progress); } }); } /** * Clean up resources */ destroy() { try { removeSync(this.pathOutputForder); } catch (error) { this.handleError(error, 'Error cleaning up resources'); } } /** * Get default FPS */ getDefaultFps() { return BaseCore.VIDEO_CONSTANTS.DEFAULT_FPS; } /** * Update core state */ updateState(status, error, progress) { this.state = { status, lastUpdated: new Date(), error: error?.message, progress, }; } /** * Get current core state */ getState() { return this.state; } getOutputFolder() { return this.pathOutputForder; } async getVideoInfoPublic(videoPath) { return this.getVideoInfo(videoPath); } } BaseCore.VIDEO_CONSTANTS = { MIN_BITRATE: 8000000, MAX_BITRATE: 32000000, BITRATE_FACTOR: 0.2, DEFAULT_FPS: 30, DEFAULT_AUDIO_SAMPLE_RATE: 44100, DEFAULT_AUDIO_BITRATE: '192k', DEFAULT_AUDIO_CHANNELS: 2, MAX_RESOLUTION: 1920, }; //# sourceMappingURL=BaseCore.js.map