ffmpeg-toolkit
Version:
A modern FFmpeg toolkit for Node.js
423 lines • 14 kB
JavaScript
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