Files
smartffmpeg/ts/classes.smartffmpeg.ts

751 lines
20 KiB
TypeScript
Raw Permalink Normal View History

2025-12-11 23:03:14 +00:00
import * as plugins from './plugins.js';
import type * as interfaces from './interfaces.js';
import { FfmpegCommand, type TFfmpegInput, type IInputOptions } from './classes.ffmpegcommand.js';
/**
* Event callback types
*/
export type TProgressCallback = (progress: interfaces.IProgressInfo) => void;
export type TErrorCallback = (error: Error) => void;
export type TEndCallback = () => void;
/**
* SmartFfmpeg - A fast Node.js module for media file conversion using ffmpeg
*
* @example Modern Builder API
* ```typescript
* const ffmpeg = new SmartFfmpeg();
*
* // File to file with fluent API
* await ffmpeg.create()
* .input('/path/to/input.mp4')
* .videoCodec('libx264')
* .audioBitrate('128k')
* .size(1920, 1080)
* .output('/path/to/output.mp4')
* .run();
*
* // Buffer to buffer
* const outputBuffer = await ffmpeg.create()
* .input(inputBuffer, { format: 'mp4' })
* .videoCodec('libx264')
* .toBuffer('webm');
*
* // With progress tracking
* await ffmpeg.create()
* .input('/path/to/input.mp4')
* .videoCodec('libx264')
* .onProgress(p => console.log(`${p.percent?.toFixed(1)}%`))
* .output('/path/to/output.mp4')
* .run();
*
* // Stream output
* const stream = ffmpeg.create()
* .input('/path/to/input.mp4')
* .videoCodec('libx264')
* .toStream('mp4');
* ```
*
* @example Legacy API (still supported)
* ```typescript
* await ffmpeg.convert('input.mp4', 'output.webm', {
* videoCodec: 'libvpx-vp9',
* audioBitrate: '128k'
* });
* ```
*/
export class SmartFfmpeg {
private ffmpegPath: string;
private ffprobePath: string;
constructor() {
this.ffmpegPath = plugins.ffmpegBinaryPath;
this.ffprobePath = plugins.ffprobeBinaryPath;
}
// ==================== BUILDER API ====================
/**
* Create a new FfmpegCommand builder for fluent API usage
*
* @example
* ```typescript
* await ffmpeg.create()
* .input('/path/to/input.mp4')
* .videoCodec('libx264')
* .crf(23)
* .output('/path/to/output.mp4')
* .run();
* ```
*/
create(): FfmpegCommand {
return new FfmpegCommand(this.ffmpegPath, this.ffprobePath);
}
/**
* Shorthand to create a command with input already set
*
* @example
* ```typescript
* // File input
* await ffmpeg.input('/path/to/input.mp4')
* .videoCodec('libx264')
* .output('/path/to/output.mp4')
* .run();
*
* // Buffer input
* const output = await ffmpeg.input(buffer, { format: 'mp4' })
* .videoCodec('libx264')
* .toBuffer('webm');
* ```
*/
input(source: TFfmpegInput, options?: IInputOptions): FfmpegCommand {
return this.create().input(source, options);
}
// ==================== LEGACY API ====================
/**
* Get media file information using ffprobe
*/
public async getMediaInfo(inputPath: string): Promise<interfaces.IMediaInfo> {
const args = [
'-v', 'quiet',
'-print_format', 'json',
'-show_format',
'-show_streams',
inputPath
];
const result = await this.runProcess(this.ffprobePath, args);
const data = JSON.parse(result.stdout);
return {
format: {
filename: data.format.filename,
formatName: data.format.format_name,
formatLongName: data.format.format_long_name,
duration: parseFloat(data.format.duration) || 0,
size: parseInt(data.format.size, 10) || 0,
bitrate: parseInt(data.format.bit_rate, 10) || 0,
},
streams: (data.streams || []).map((stream: any) => this.parseStreamInfo(stream)),
};
}
/**
* Convert media file with specified options
*/
public async convert(
inputPath: string,
outputPath: string,
options: interfaces.IConversionOptions = {}
): Promise<void> {
const args = this.buildConversionArgs(inputPath, outputPath, options);
await this.runProcess(this.ffmpegPath, args);
}
/**
* Convert media file with progress reporting
*/
public async convertWithProgress(
inputPath: string,
outputPath: string,
options: interfaces.IConversionOptions = {},
onProgress?: TProgressCallback
): Promise<void> {
// Get duration for progress percentage calculation
let totalDuration: number | undefined;
try {
const info = await this.getMediaInfo(inputPath);
totalDuration = info.format.duration;
} catch {
// Continue without duration info
}
const args = ['-progress', 'pipe:1', ...this.buildConversionArgs(inputPath, outputPath, options)];
return new Promise((resolve, reject) => {
const process = plugins.child_process.spawn(this.ffmpegPath, args);
let progressData: Partial<interfaces.IProgressInfo> = {};
process.stdout?.on('data', (data: Buffer) => {
const lines = data.toString().split('\n');
for (const line of lines) {
const [key, value] = line.split('=');
if (!key || !value) continue;
switch (key.trim()) {
case 'frame':
progressData.frame = parseInt(value, 10);
break;
case 'fps':
progressData.fps = parseFloat(value);
break;
case 'total_size':
progressData.size = parseInt(value, 10);
break;
case 'out_time_ms':
progressData.time = parseInt(value, 10) / 1000000;
if (totalDuration && progressData.time) {
progressData.percent = Math.min(100, (progressData.time / totalDuration) * 100);
}
break;
case 'bitrate':
progressData.bitrate = value.trim();
break;
case 'speed':
progressData.speed = value.trim();
break;
case 'progress':
if (value.trim() === 'continue' || value.trim() === 'end') {
if (onProgress && progressData.frame !== undefined) {
onProgress(progressData as interfaces.IProgressInfo);
}
}
break;
}
}
});
let stderr = '';
process.stderr?.on('data', (data: Buffer) => {
stderr += data.toString();
});
process.on('close', (code) => {
if (code === 0) {
resolve();
} else {
reject(new Error(`FFmpeg exited with code ${code}: ${stderr}`));
}
});
process.on('error', (err) => {
reject(err);
});
});
}
/**
* Extract audio from video file
*/
public async extractAudio(
inputPath: string,
outputPath: string,
options: Pick<interfaces.IConversionOptions, 'audioCodec' | 'audioBitrate' | 'sampleRate' | 'audioChannels' | 'startTime' | 'duration' | 'overwrite'> = {}
): Promise<void> {
const conversionOptions: interfaces.IConversionOptions = {
...options,
noVideo: true,
};
await this.convert(inputPath, outputPath, conversionOptions);
}
/**
* Remove audio from video file
*/
public async removeAudio(
inputPath: string,
outputPath: string,
options: Pick<interfaces.IConversionOptions, 'videoCodec' | 'videoBitrate' | 'overwrite'> = {}
): Promise<void> {
const conversionOptions: interfaces.IConversionOptions = {
...options,
noAudio: true,
};
await this.convert(inputPath, outputPath, conversionOptions);
}
/**
* Take a screenshot at a specific time
*/
public async screenshot(
inputPath: string,
outputPath: string,
options: interfaces.IScreenshotOptions
): Promise<void> {
const args: string[] = [
'-y',
'-ss', this.formatTime(options.time),
'-i', inputPath,
'-vframes', '1',
];
if (options.width || options.height) {
const scale = this.buildScaleFilter(options.width, options.height);
args.push('-vf', scale);
}
if (options.format === 'jpg' && options.quality) {
args.push('-q:v', String(Math.round((100 - options.quality) / 100 * 31)));
} else if (options.format === 'webp' && options.quality) {
args.push('-quality', String(options.quality));
}
args.push(outputPath);
await this.runProcess(this.ffmpegPath, args);
}
/**
* Generate multiple thumbnails from video
*/
public async generateThumbnails(
inputPath: string,
outputDir: string,
options: interfaces.IThumbnailOptions
): Promise<string[]> {
const info = await this.getMediaInfo(inputPath);
const duration = info.format.duration;
const interval = duration / (options.count + 1);
const pattern = options.filenamePattern || 'thumb_%d';
const ext = options.format || 'png';
const outputPaths: string[] = [];
for (let i = 1; i <= options.count; i++) {
const time = interval * i;
const filename = pattern.replace('%d', String(i).padStart(3, '0')) + '.' + ext;
const outputPath = plugins.path.join(outputDir, filename);
await this.screenshot(inputPath, outputPath, {
time,
width: options.width,
height: options.height,
format: options.format,
});
outputPaths.push(outputPath);
}
return outputPaths;
}
/**
* Resize video
*/
public async resize(
inputPath: string,
outputPath: string,
width?: number,
height?: number,
options: Omit<interfaces.IConversionOptions, 'width' | 'height'> = {}
): Promise<void> {
await this.convert(inputPath, outputPath, { ...options, width, height });
}
/**
* Change video frame rate
*/
public async changeFrameRate(
inputPath: string,
outputPath: string,
fps: number,
options: Omit<interfaces.IConversionOptions, 'fps'> = {}
): Promise<void> {
await this.convert(inputPath, outputPath, { ...options, fps });
}
/**
* Trim media file
*/
public async trim(
inputPath: string,
outputPath: string,
startTime: number | string,
duration: number | string,
options: Omit<interfaces.IConversionOptions, 'startTime' | 'duration'> = {}
): Promise<void> {
await this.convert(inputPath, outputPath, { ...options, startTime, duration });
}
/**
* Convert to GIF
*/
public async toGif(
inputPath: string,
outputPath: string,
options: {
width?: number;
height?: number;
fps?: number;
startTime?: number | string;
duration?: number | string;
} = {}
): Promise<void> {
const args: string[] = ['-y'];
if (options.startTime !== undefined) {
args.push('-ss', this.formatTime(options.startTime));
}
args.push('-i', inputPath);
if (options.duration !== undefined) {
args.push('-t', this.formatTime(options.duration));
}
// Build filter for GIF optimization
const filters: string[] = [];
if (options.fps) {
filters.push(`fps=${options.fps}`);
}
if (options.width || options.height) {
filters.push(this.buildScaleFilter(options.width, options.height));
}
// Split filter for palette generation (better GIF quality)
filters.push('split[s0][s1]');
filters.push('[s0]palettegen[p]');
filters.push('[s1][p]paletteuse');
args.push('-filter_complex', filters.join(','));
args.push(outputPath);
await this.runProcess(this.ffmpegPath, args);
}
/**
* Concatenate multiple media files
*/
public async concat(
inputPaths: string[],
outputPath: string,
options: interfaces.IConversionOptions = {}
): Promise<void> {
// Create concat file content
const concatContent = inputPaths
.map(p => `file '${p.replace(/'/g, "'\\''")}'`)
.join('\n');
// Write to temp file
const tempFile = plugins.path.join(
plugins.path.dirname(outputPath),
`.concat_${Date.now()}.txt`
);
// Write concat file using SmartFs
const fs = new plugins.smartfs.SmartFs(new plugins.smartfs.SmartFsProviderNode());
await fs.file(tempFile).write(concatContent);
try {
const args: string[] = [
'-y',
'-f', 'concat',
'-safe', '0',
'-i', tempFile,
];
if (options.videoCodec) {
args.push('-c:v', options.videoCodec);
} else {
args.push('-c', 'copy');
}
if (options.audioCodec) {
args.push('-c:a', options.audioCodec);
}
args.push(outputPath);
await this.runProcess(this.ffmpegPath, args);
} finally {
// Clean up temp file
await fs.file(tempFile).delete();
}
}
/**
* Add audio to video
*/
public async addAudio(
videoPath: string,
audioPath: string,
outputPath: string,
options: {
videoCodec?: interfaces.TVideoCodec;
audioCodec?: interfaces.TAudioCodec;
audioBitrate?: string;
shortest?: boolean;
overwrite?: boolean;
} = {}
): Promise<void> {
const args: string[] = [
'-y',
'-i', videoPath,
'-i', audioPath,
'-c:v', options.videoCodec || 'copy',
'-c:a', options.audioCodec || 'aac',
];
if (options.audioBitrate) {
args.push('-b:a', options.audioBitrate);
}
if (options.shortest) {
args.push('-shortest');
}
args.push('-map', '0:v:0', '-map', '1:a:0');
args.push(outputPath);
await this.runProcess(this.ffmpegPath, args);
}
/**
* Get available encoders
*/
public async getEncoders(): Promise<string[]> {
const result = await this.runProcess(this.ffmpegPath, ['-encoders', '-hide_banner']);
const lines = result.stdout.split('\n');
const encoders: string[] = [];
let started = false;
for (const line of lines) {
if (line.includes('------')) {
started = true;
continue;
}
if (started && line.trim()) {
const match = line.match(/^\s*[A-Z.]+\s+(\S+)/);
if (match) {
encoders.push(match[1]);
}
}
}
return encoders;
}
/**
* Get available decoders
*/
public async getDecoders(): Promise<string[]> {
const result = await this.runProcess(this.ffmpegPath, ['-decoders', '-hide_banner']);
const lines = result.stdout.split('\n');
const decoders: string[] = [];
let started = false;
for (const line of lines) {
if (line.includes('------')) {
started = true;
continue;
}
if (started && line.trim()) {
const match = line.match(/^\s*[A-Z.]+\s+(\S+)/);
if (match) {
decoders.push(match[1]);
}
}
}
return decoders;
}
/**
* Get available formats
*/
public async getFormats(): Promise<string[]> {
const result = await this.runProcess(this.ffmpegPath, ['-formats', '-hide_banner']);
const lines = result.stdout.split('\n');
const formats: string[] = [];
let started = false;
for (const line of lines) {
if (line.includes('--')) {
started = true;
continue;
}
if (started && line.trim()) {
const match = line.match(/^\s*[DE ]+\s+(\S+)/);
if (match) {
formats.push(match[1]);
}
}
}
return formats;
}
/**
* Run ffmpeg with raw arguments
*/
public async runRaw(args: string[]): Promise<{ stdout: string; stderr: string }> {
return this.runProcess(this.ffmpegPath, args);
}
/**
* Run ffprobe with raw arguments
*/
public async runProbeRaw(args: string[]): Promise<{ stdout: string; stderr: string }> {
return this.runProcess(this.ffprobePath, args);
}
// ============ Private Methods ============
private buildConversionArgs(
inputPath: string,
outputPath: string,
options: interfaces.IConversionOptions
): string[] {
const args: string[] = [];
// Overwrite flag
if (options.overwrite !== false) {
args.push('-y');
}
// Input seeking (before -i for fast seeking)
if (options.startTime !== undefined) {
args.push('-ss', this.formatTime(options.startTime));
}
args.push('-i', inputPath);
// Duration (after -i)
if (options.duration !== undefined) {
args.push('-t', this.formatTime(options.duration));
}
// Video options
if (options.noVideo) {
args.push('-vn');
} else {
if (options.videoCodec) {
args.push('-c:v', options.videoCodec);
}
if (options.videoBitrate) {
args.push('-b:v', options.videoBitrate);
}
if (options.crf !== undefined) {
args.push('-crf', String(options.crf));
}
if (options.preset) {
args.push('-preset', options.preset);
}
if (options.fps) {
args.push('-r', String(options.fps));
}
if (options.width || options.height) {
args.push('-vf', this.buildScaleFilter(options.width, options.height));
}
}
// Audio options
if (options.noAudio) {
args.push('-an');
} else {
if (options.audioCodec) {
args.push('-c:a', options.audioCodec);
}
if (options.audioBitrate) {
args.push('-b:a', options.audioBitrate);
}
if (options.sampleRate) {
args.push('-ar', String(options.sampleRate));
}
if (options.audioChannels) {
args.push('-ac', String(options.audioChannels));
}
}
// Output format
if (options.format) {
args.push('-f', options.format);
}
// Extra arguments
if (options.extraArgs) {
args.push(...options.extraArgs);
}
args.push(outputPath);
return args;
}
private buildScaleFilter(width?: number, height?: number): string {
const w = width ? String(width) : '-1';
const h = height ? String(height) : '-1';
// Use -2 instead of -1 to ensure even dimensions (required for some codecs)
return `scale=${w === '-1' ? '-2' : w}:${h === '-1' ? '-2' : h}`;
}
private formatTime(time: number | string): string {
if (typeof time === 'string') {
return time;
}
// Convert seconds to HH:MM:SS.mmm format
const hours = Math.floor(time / 3600);
const minutes = Math.floor((time % 3600) / 60);
const seconds = time % 60;
return `${hours.toString().padStart(2, '0')}:${minutes.toString().padStart(2, '0')}:${seconds.toFixed(3).padStart(6, '0')}`;
}
private parseStreamInfo(stream: any): interfaces.IStreamInfo {
const info: interfaces.IStreamInfo = {
index: stream.index,
codecName: stream.codec_name,
codecLongName: stream.codec_long_name,
codecType: stream.codec_type,
};
if (stream.codec_type === 'video') {
info.width = stream.width;
info.height = stream.height;
if (stream.r_frame_rate) {
const [num, den] = stream.r_frame_rate.split('/').map(Number);
info.frameRate = den ? num / den : num;
}
if (stream.bit_rate) {
info.bitrate = parseInt(stream.bit_rate, 10);
}
}
if (stream.codec_type === 'audio') {
info.sampleRate = parseInt(stream.sample_rate, 10);
info.channels = stream.channels;
if (stream.bit_rate) {
info.bitrate = parseInt(stream.bit_rate, 10);
}
}
if (stream.duration) {
info.duration = parseFloat(stream.duration);
}
return info;
}
private runProcess(command: string, args: string[]): Promise<{ stdout: string; stderr: string }> {
return new Promise((resolve, reject) => {
const process = plugins.child_process.spawn(command, args);
let stdout = '';
let stderr = '';
process.stdout?.on('data', (data: Buffer) => {
stdout += data.toString();
});
process.stderr?.on('data', (data: Buffer) => {
stderr += data.toString();
});
process.on('close', (code) => {
if (code === 0) {
resolve({ stdout, stderr });
} else {
reject(new Error(`Process exited with code ${code}: ${stderr}`));
}
});
process.on('error', (err) => {
reject(err);
});
});
}
}