initial
This commit is contained in:
8
ts/00_commitinfo_data.ts
Normal file
8
ts/00_commitinfo_data.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
/**
|
||||
* autocreated commitinfo by @push.rocks/commitinfo
|
||||
*/
|
||||
export const commitinfo = {
|
||||
name: '@push.rocks/smartffmpeg',
|
||||
version: '1.0.0',
|
||||
description: 'A fast Node.js module for media file conversion using ffmpeg'
|
||||
}
|
||||
890
ts/classes.ffmpegcommand.ts
Normal file
890
ts/classes.ffmpegcommand.ts
Normal file
@@ -0,0 +1,890 @@
|
||||
import * as plugins from './plugins.js';
|
||||
import type * as interfaces from './interfaces.js';
|
||||
import { Readable, Writable } from 'stream';
|
||||
|
||||
/**
|
||||
* Input source for FfmpegCommand
|
||||
* Supports file paths, memory buffers, and Web Streams
|
||||
*/
|
||||
export type TFfmpegInput =
|
||||
| string // File path
|
||||
| Buffer // Memory buffer
|
||||
| Uint8Array // Web-compatible binary
|
||||
| ReadableStream<Uint8Array>; // Web ReadableStream
|
||||
|
||||
/**
|
||||
* Output destination for FfmpegCommand
|
||||
*/
|
||||
export type TFfmpegOutput =
|
||||
| string // File path
|
||||
| 'buffer' // Return as Buffer
|
||||
| 'stream' // Return as ReadableStream
|
||||
| WritableStream<Uint8Array>; // Pipe to Web WritableStream
|
||||
|
||||
/**
|
||||
* Input options when using Buffer or Stream
|
||||
*/
|
||||
export interface IInputOptions {
|
||||
/** Input format (required for buffer/stream input) */
|
||||
format?: interfaces.TOutputFormat;
|
||||
/** Input duration hint for progress calculation */
|
||||
duration?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Result of running an FfmpegCommand
|
||||
*/
|
||||
export interface IFfmpegResult {
|
||||
/** Output buffer (when outputting to buffer) */
|
||||
buffer?: Buffer;
|
||||
/** Output stream (when outputting to stream) */
|
||||
stream?: ReadableStream<Uint8Array>;
|
||||
/** Path to output file (when outputting to file) */
|
||||
outputPath?: string;
|
||||
/** Duration of processing in ms */
|
||||
processingTime: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Progress callback type for FfmpegCommand
|
||||
*/
|
||||
export type TFfmpegProgressCallback = (progress: interfaces.IProgressInfo) => void;
|
||||
|
||||
/**
|
||||
* Modern fluent builder API for ffmpeg commands
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* // File to file
|
||||
* await ffmpeg.create()
|
||||
* .input('/path/to/input.mp4')
|
||||
* .videoCodec('libx264')
|
||||
* .audioBitrate('128k')
|
||||
* .output('/path/to/output.mp4')
|
||||
* .run();
|
||||
*
|
||||
* // Buffer to buffer
|
||||
* const outputBuffer = await ffmpeg.create()
|
||||
* .input(inputBuffer, { format: 'mp4' })
|
||||
* .videoCodec('libx264')
|
||||
* .toBuffer('webm');
|
||||
*
|
||||
* // With progress
|
||||
* await ffmpeg.create()
|
||||
* .input('/path/to/input.mp4')
|
||||
* .videoCodec('libx264')
|
||||
* .onProgress(p => console.log(`${p.percent}%`))
|
||||
* .output('/path/to/output.mp4')
|
||||
* .run();
|
||||
* ```
|
||||
*/
|
||||
export class FfmpegCommand {
|
||||
private ffmpegPath: string;
|
||||
private ffprobePath: string;
|
||||
|
||||
// Input configuration
|
||||
private inputSource: TFfmpegInput | null = null;
|
||||
private inputOpts: IInputOptions = {};
|
||||
private customInputArgs: string[] = [];
|
||||
|
||||
// Output configuration
|
||||
private outputDest: TFfmpegOutput | null = null;
|
||||
private outputFormat: interfaces.TOutputFormat | null = null;
|
||||
private customOutputArgs: string[] = [];
|
||||
|
||||
// Video settings
|
||||
private _videoCodec: interfaces.TVideoCodec | null = null;
|
||||
private _videoBitrate: string | null = null;
|
||||
private _width: number | null = null;
|
||||
private _height: number | null = null;
|
||||
private _fps: number | null = null;
|
||||
private _crf: number | null = null;
|
||||
private _preset: interfaces.TPreset | null = null;
|
||||
private _noVideo = false;
|
||||
|
||||
// Audio settings
|
||||
private _audioCodec: interfaces.TAudioCodec | null = null;
|
||||
private _audioBitrate: string | null = null;
|
||||
private _sampleRate: number | null = null;
|
||||
private _audioChannels: number | null = null;
|
||||
private _noAudio = false;
|
||||
|
||||
// Timing
|
||||
private _startTime: number | string | null = null;
|
||||
private _duration: number | string | null = null;
|
||||
|
||||
// Video filters
|
||||
private videoFilters: string[] = [];
|
||||
|
||||
// Audio filters
|
||||
private audioFilters: string[] = [];
|
||||
|
||||
// Complex filter
|
||||
private _complexFilter: string | null = null;
|
||||
|
||||
// Extra arguments
|
||||
private extraInputArgs: string[] = [];
|
||||
private extraOutputArgs: string[] = [];
|
||||
|
||||
// Callbacks
|
||||
private progressCallback: TFfmpegProgressCallback | null = null;
|
||||
|
||||
// Options
|
||||
private _overwrite = true;
|
||||
|
||||
constructor(ffmpegPath: string, ffprobePath: string) {
|
||||
this.ffmpegPath = ffmpegPath;
|
||||
this.ffprobePath = ffprobePath;
|
||||
}
|
||||
|
||||
// ==================== INPUT METHODS ====================
|
||||
|
||||
/**
|
||||
* Set the input source
|
||||
* @param source - File path, Buffer, or Readable stream
|
||||
* @param options - Input options (format required for buffer/stream)
|
||||
*/
|
||||
input(source: TFfmpegInput, options: IInputOptions = {}): this {
|
||||
this.inputSource = source;
|
||||
this.inputOpts = options;
|
||||
return this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Seek to position before input (fast seek)
|
||||
* @param time - Time in seconds or timecode string
|
||||
*/
|
||||
seekInput(time: number | string): this {
|
||||
this._startTime = time;
|
||||
return this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Add custom input arguments
|
||||
*/
|
||||
inputArgs(...args: string[]): this {
|
||||
this.extraInputArgs.push(...args);
|
||||
return this;
|
||||
}
|
||||
|
||||
// ==================== VIDEO METHODS ====================
|
||||
|
||||
/**
|
||||
* Set video codec
|
||||
*/
|
||||
videoCodec(codec: interfaces.TVideoCodec): this {
|
||||
this._videoCodec = codec;
|
||||
return this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set video bitrate
|
||||
* @param bitrate - e.g., '1M', '2000k'
|
||||
*/
|
||||
videoBitrate(bitrate: string): this {
|
||||
this._videoBitrate = bitrate;
|
||||
return this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set output dimensions
|
||||
* @param width - Width in pixels (-1 for auto)
|
||||
* @param height - Height in pixels (-1 for auto)
|
||||
*/
|
||||
size(width: number, height?: number): this {
|
||||
this._width = width;
|
||||
this._height = height ?? null;
|
||||
return this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set frame rate
|
||||
*/
|
||||
fps(rate: number): this {
|
||||
this._fps = rate;
|
||||
return this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set Constant Rate Factor (quality)
|
||||
* @param value - 0-51, lower is better quality
|
||||
*/
|
||||
crf(value: number): this {
|
||||
this._crf = value;
|
||||
return this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set encoding preset
|
||||
*/
|
||||
preset(value: interfaces.TPreset): this {
|
||||
this._preset = value;
|
||||
return this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove video stream
|
||||
*/
|
||||
noVideo(): this {
|
||||
this._noVideo = true;
|
||||
return this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Add a video filter
|
||||
* @param filter - Filter string (e.g., 'scale=1920:1080', 'hflip')
|
||||
*/
|
||||
videoFilter(filter: string): this {
|
||||
this.videoFilters.push(filter);
|
||||
return this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Scale video
|
||||
* @param width - Width (-1 or -2 for auto)
|
||||
* @param height - Height (-1 or -2 for auto)
|
||||
*/
|
||||
scale(width: number | string, height: number | string): this {
|
||||
// Use -2 to ensure even dimensions
|
||||
const w = width === -1 ? '-2' : String(width);
|
||||
const h = height === -1 ? '-2' : String(height);
|
||||
this.videoFilters.push(`scale=${w}:${h}`);
|
||||
return this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Crop video
|
||||
*/
|
||||
crop(width: number, height: number, x = 0, y = 0): this {
|
||||
this.videoFilters.push(`crop=${width}:${height}:${x}:${y}`);
|
||||
return this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Rotate video
|
||||
* @param angle - Rotation in radians or 'PI/2', 'PI', etc.
|
||||
*/
|
||||
rotate(angle: number | string): this {
|
||||
this.videoFilters.push(`rotate=${angle}`);
|
||||
return this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Flip video horizontally
|
||||
*/
|
||||
flipHorizontal(): this {
|
||||
this.videoFilters.push('hflip');
|
||||
return this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Flip video vertically
|
||||
*/
|
||||
flipVertical(): this {
|
||||
this.videoFilters.push('vflip');
|
||||
return this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Add padding to video
|
||||
*/
|
||||
pad(width: number, height: number, x = 0, y = 0, color = 'black'): this {
|
||||
this.videoFilters.push(`pad=${width}:${height}:${x}:${y}:${color}`);
|
||||
return this;
|
||||
}
|
||||
|
||||
// ==================== AUDIO METHODS ====================
|
||||
|
||||
/**
|
||||
* Set audio codec
|
||||
*/
|
||||
audioCodec(codec: interfaces.TAudioCodec): this {
|
||||
this._audioCodec = codec;
|
||||
return this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set audio bitrate
|
||||
* @param bitrate - e.g., '128k', '320k'
|
||||
*/
|
||||
audioBitrate(bitrate: string): this {
|
||||
this._audioBitrate = bitrate;
|
||||
return this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set audio sample rate
|
||||
* @param rate - Sample rate in Hz (e.g., 44100, 48000)
|
||||
*/
|
||||
sampleRate(rate: number): this {
|
||||
this._sampleRate = rate;
|
||||
return this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set number of audio channels
|
||||
* @param channels - 1 for mono, 2 for stereo
|
||||
*/
|
||||
audioChannels(channels: number): this {
|
||||
this._audioChannels = channels;
|
||||
return this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove audio stream
|
||||
*/
|
||||
noAudio(): this {
|
||||
this._noAudio = true;
|
||||
return this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Add an audio filter
|
||||
* @param filter - Filter string (e.g., 'volume=2', 'aecho=0.8:0.88:60:0.4')
|
||||
*/
|
||||
audioFilter(filter: string): this {
|
||||
this.audioFilters.push(filter);
|
||||
return this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set audio volume
|
||||
* @param level - Volume multiplier (e.g., 2 for double, 0.5 for half)
|
||||
*/
|
||||
volume(level: number): this {
|
||||
this.audioFilters.push(`volume=${level}`);
|
||||
return this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalize audio
|
||||
*/
|
||||
normalize(): this {
|
||||
this.audioFilters.push('loudnorm');
|
||||
return this;
|
||||
}
|
||||
|
||||
// ==================== TIMING METHODS ====================
|
||||
|
||||
/**
|
||||
* Set start time (seek)
|
||||
* @param time - Time in seconds or timecode string
|
||||
*/
|
||||
seek(time: number | string): this {
|
||||
this._startTime = time;
|
||||
return this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set output duration
|
||||
* @param time - Duration in seconds or timecode string
|
||||
*/
|
||||
duration(time: number | string): this {
|
||||
this._duration = time;
|
||||
return this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set both start and end time
|
||||
*/
|
||||
trim(start: number | string, end: number | string): this {
|
||||
this._startTime = start;
|
||||
// Calculate duration from end - start
|
||||
if (typeof start === 'number' && typeof end === 'number') {
|
||||
this._duration = end - start;
|
||||
} else {
|
||||
// For string timestamps, set end time as duration (approximate)
|
||||
this._duration = end;
|
||||
}
|
||||
return this;
|
||||
}
|
||||
|
||||
// ==================== FILTER METHODS ====================
|
||||
|
||||
/**
|
||||
* Set a complex filter graph
|
||||
* @param filterGraph - Complex filter string
|
||||
*/
|
||||
complexFilter(filterGraph: string): this {
|
||||
this._complexFilter = filterGraph;
|
||||
return this;
|
||||
}
|
||||
|
||||
// ==================== OUTPUT METHODS ====================
|
||||
|
||||
/**
|
||||
* Set output format
|
||||
*/
|
||||
format(fmt: interfaces.TOutputFormat): this {
|
||||
this.outputFormat = fmt;
|
||||
return this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set output destination (file path)
|
||||
*/
|
||||
output(dest: string): this {
|
||||
this.outputDest = dest;
|
||||
return this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Add custom output arguments
|
||||
*/
|
||||
outputArgs(...args: string[]): this {
|
||||
this.extraOutputArgs.push(...args);
|
||||
return this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set overwrite flag
|
||||
*/
|
||||
overwrite(value = true): this {
|
||||
this._overwrite = value;
|
||||
return this;
|
||||
}
|
||||
|
||||
// ==================== CALLBACK METHODS ====================
|
||||
|
||||
/**
|
||||
* Set progress callback
|
||||
*/
|
||||
onProgress(callback: TFfmpegProgressCallback): this {
|
||||
this.progressCallback = callback;
|
||||
return this;
|
||||
}
|
||||
|
||||
// ==================== EXECUTION METHODS ====================
|
||||
|
||||
/**
|
||||
* Run the command and output to file
|
||||
*/
|
||||
async run(): Promise<IFfmpegResult> {
|
||||
if (!this.outputDest || typeof this.outputDest !== 'string') {
|
||||
throw new Error('Output path must be set. Use .output() or .toBuffer()/.toStream()');
|
||||
}
|
||||
|
||||
const startTime = Date.now();
|
||||
const args = this.buildArgs(this.outputDest);
|
||||
|
||||
await this.execute(args);
|
||||
|
||||
return {
|
||||
outputPath: this.outputDest,
|
||||
processingTime: Date.now() - startTime,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Run the command and return output as Buffer
|
||||
* @param format - Output format
|
||||
*/
|
||||
async toBuffer(format?: interfaces.TOutputFormat): Promise<Buffer> {
|
||||
if (format) {
|
||||
this.outputFormat = format;
|
||||
}
|
||||
|
||||
if (!this.outputFormat) {
|
||||
throw new Error('Output format must be specified when outputting to buffer. Use .format() or pass format to .toBuffer()');
|
||||
}
|
||||
|
||||
const args = this.buildArgs('pipe:1');
|
||||
return this.executeToBuffer(args);
|
||||
}
|
||||
|
||||
/**
|
||||
* Run the command and return output as Web ReadableStream
|
||||
* @param format - Output format
|
||||
*/
|
||||
toStream(format?: interfaces.TOutputFormat): ReadableStream<Uint8Array> {
|
||||
if (format) {
|
||||
this.outputFormat = format;
|
||||
}
|
||||
|
||||
if (!this.outputFormat) {
|
||||
throw new Error('Output format must be specified when outputting to stream. Use .format() or pass format to .toStream()');
|
||||
}
|
||||
|
||||
const args = this.buildArgs('pipe:1');
|
||||
const nodeStream = this.executeToNodeStream(args);
|
||||
|
||||
// Convert Node.js Readable to Web ReadableStream
|
||||
return Readable.toWeb(nodeStream) as ReadableStream<Uint8Array>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Pipe output to a Web WritableStream
|
||||
* @param writable - Web WritableStream
|
||||
* @param format - Output format
|
||||
*/
|
||||
async pipe(writable: WritableStream<Uint8Array>, format?: interfaces.TOutputFormat): Promise<void> {
|
||||
if (format) {
|
||||
this.outputFormat = format;
|
||||
}
|
||||
|
||||
if (!this.outputFormat) {
|
||||
throw new Error('Output format must be specified when piping. Use .format() or pass format to .pipe()');
|
||||
}
|
||||
|
||||
const args = this.buildArgs('pipe:1');
|
||||
const nodeReadable = this.executeToNodeStream(args);
|
||||
|
||||
// Convert Node.js Readable to Web ReadableStream and pipe to WritableStream
|
||||
const webReadable = Readable.toWeb(nodeReadable) as ReadableStream<Uint8Array>;
|
||||
await webReadable.pipeTo(writable);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the ffmpeg arguments that would be used (for debugging)
|
||||
*/
|
||||
getArgs(outputPath = 'output'): string[] {
|
||||
return this.buildArgs(outputPath);
|
||||
}
|
||||
|
||||
// ==================== PRIVATE METHODS ====================
|
||||
|
||||
private buildArgs(outputPath: string): string[] {
|
||||
const args: string[] = [];
|
||||
|
||||
// Overwrite flag
|
||||
if (this._overwrite) {
|
||||
args.push('-y');
|
||||
}
|
||||
|
||||
// Seek before input (fast seek)
|
||||
if (this._startTime !== null) {
|
||||
args.push('-ss', this.formatTime(this._startTime));
|
||||
}
|
||||
|
||||
// Extra input args
|
||||
args.push(...this.extraInputArgs);
|
||||
|
||||
// Input format (for buffer/stream input)
|
||||
if (this.inputOpts.format && this.isMemoryInput()) {
|
||||
args.push('-f', this.inputOpts.format);
|
||||
}
|
||||
|
||||
// Input source
|
||||
if (typeof this.inputSource === 'string') {
|
||||
args.push('-i', this.inputSource);
|
||||
} else {
|
||||
// Buffer or stream - read from stdin
|
||||
args.push('-i', 'pipe:0');
|
||||
}
|
||||
|
||||
// Duration (after input)
|
||||
if (this._duration !== null) {
|
||||
args.push('-t', this.formatTime(this._duration));
|
||||
}
|
||||
|
||||
// Video options
|
||||
if (this._noVideo) {
|
||||
args.push('-vn');
|
||||
} else {
|
||||
if (this._videoCodec) {
|
||||
args.push('-c:v', this._videoCodec);
|
||||
}
|
||||
if (this._videoBitrate) {
|
||||
args.push('-b:v', this._videoBitrate);
|
||||
}
|
||||
if (this._crf !== null) {
|
||||
args.push('-crf', String(this._crf));
|
||||
}
|
||||
if (this._preset) {
|
||||
args.push('-preset', this._preset);
|
||||
}
|
||||
if (this._fps) {
|
||||
args.push('-r', String(this._fps));
|
||||
}
|
||||
|
||||
// Size (via scale filter or direct)
|
||||
if (this._width !== null || this._height !== null) {
|
||||
const w = this._width !== null ? String(this._width) : '-2';
|
||||
const h = this._height !== null ? String(this._height) : '-2';
|
||||
this.videoFilters.unshift(`scale=${w}:${h}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Audio options
|
||||
if (this._noAudio) {
|
||||
args.push('-an');
|
||||
} else {
|
||||
if (this._audioCodec) {
|
||||
args.push('-c:a', this._audioCodec);
|
||||
}
|
||||
if (this._audioBitrate) {
|
||||
args.push('-b:a', this._audioBitrate);
|
||||
}
|
||||
if (this._sampleRate) {
|
||||
args.push('-ar', String(this._sampleRate));
|
||||
}
|
||||
if (this._audioChannels) {
|
||||
args.push('-ac', String(this._audioChannels));
|
||||
}
|
||||
}
|
||||
|
||||
// Complex filter
|
||||
if (this._complexFilter) {
|
||||
args.push('-filter_complex', this._complexFilter);
|
||||
} else {
|
||||
// Video filters
|
||||
if (this.videoFilters.length > 0) {
|
||||
args.push('-vf', this.videoFilters.join(','));
|
||||
}
|
||||
|
||||
// Audio filters
|
||||
if (this.audioFilters.length > 0) {
|
||||
args.push('-af', this.audioFilters.join(','));
|
||||
}
|
||||
}
|
||||
|
||||
// Output format
|
||||
if (this.outputFormat) {
|
||||
args.push('-f', this.outputFormat);
|
||||
}
|
||||
|
||||
// Extra output args
|
||||
args.push(...this.extraOutputArgs);
|
||||
|
||||
// Output
|
||||
args.push(outputPath);
|
||||
|
||||
return args;
|
||||
}
|
||||
|
||||
private async execute(args: string[]): Promise<void> {
|
||||
const inputDuration = await this.getInputDuration();
|
||||
|
||||
// Add progress output if callback is set
|
||||
if (this.progressCallback) {
|
||||
args.unshift('-progress', 'pipe:2');
|
||||
}
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const proc = plugins.child_process.spawn(this.ffmpegPath, args, {
|
||||
stdio: ['pipe', 'pipe', 'pipe'],
|
||||
});
|
||||
|
||||
// Handle input
|
||||
this.pipeInputToProcess(proc);
|
||||
|
||||
// Handle progress
|
||||
if (this.progressCallback && proc.stderr) {
|
||||
this.parseProgress(proc.stderr, inputDuration);
|
||||
}
|
||||
|
||||
let stderr = '';
|
||||
proc.stderr?.on('data', (data: Buffer) => {
|
||||
stderr += data.toString();
|
||||
});
|
||||
|
||||
proc.on('close', (code) => {
|
||||
if (code === 0) {
|
||||
resolve();
|
||||
} else {
|
||||
reject(new Error(`FFmpeg exited with code ${code}: ${stderr}`));
|
||||
}
|
||||
});
|
||||
|
||||
proc.on('error', reject);
|
||||
});
|
||||
}
|
||||
|
||||
private executeToBuffer(args: string[]): Promise<Buffer> {
|
||||
const inputDuration = this.inputOpts.duration;
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const proc = plugins.child_process.spawn(this.ffmpegPath, args, {
|
||||
stdio: ['pipe', 'pipe', 'pipe'],
|
||||
});
|
||||
|
||||
// Handle input
|
||||
this.pipeInputToProcess(proc);
|
||||
|
||||
const chunks: Buffer[] = [];
|
||||
proc.stdout?.on('data', (chunk: Buffer) => {
|
||||
chunks.push(chunk);
|
||||
});
|
||||
|
||||
// Handle progress on stderr
|
||||
if (this.progressCallback && proc.stderr) {
|
||||
this.parseProgress(proc.stderr, inputDuration);
|
||||
}
|
||||
|
||||
let stderr = '';
|
||||
proc.stderr?.on('data', (data: Buffer) => {
|
||||
stderr += data.toString();
|
||||
});
|
||||
|
||||
proc.on('close', (code) => {
|
||||
if (code === 0) {
|
||||
resolve(Buffer.concat(chunks));
|
||||
} else {
|
||||
reject(new Error(`FFmpeg exited with code ${code}: ${stderr}`));
|
||||
}
|
||||
});
|
||||
|
||||
proc.on('error', reject);
|
||||
});
|
||||
}
|
||||
|
||||
private executeToNodeStream(args: string[]): Readable {
|
||||
const proc = plugins.child_process.spawn(this.ffmpegPath, args, {
|
||||
stdio: ['pipe', 'pipe', 'pipe'],
|
||||
});
|
||||
|
||||
// Handle input
|
||||
this.pipeInputToProcess(proc);
|
||||
|
||||
// Handle progress on stderr
|
||||
if (this.progressCallback && proc.stderr) {
|
||||
this.parseProgress(proc.stderr, this.inputOpts.duration);
|
||||
}
|
||||
|
||||
proc.on('error', (err) => {
|
||||
proc.stdout?.destroy(err);
|
||||
});
|
||||
|
||||
return proc.stdout!;
|
||||
}
|
||||
|
||||
private parseProgress(stderr: Readable, duration?: number): void {
|
||||
let progressData: Partial<interfaces.IProgressInfo> = {};
|
||||
|
||||
stderr.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 (duration && progressData.time) {
|
||||
progressData.percent = Math.min(100, (progressData.time / duration) * 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') &&
|
||||
this.progressCallback &&
|
||||
progressData.frame !== undefined) {
|
||||
this.progressCallback(progressData as interfaces.IProgressInfo);
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private async getInputDuration(): Promise<number | undefined> {
|
||||
if (this.inputOpts.duration) {
|
||||
return this.inputOpts.duration;
|
||||
}
|
||||
|
||||
if (typeof this.inputSource !== 'string') {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await this.runProbe([
|
||||
'-v', 'quiet',
|
||||
'-print_format', 'json',
|
||||
'-show_format',
|
||||
this.inputSource,
|
||||
]);
|
||||
const data = JSON.parse(result);
|
||||
return parseFloat(data.format?.duration) || undefined;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
private runProbe(args: string[]): Promise<string> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const proc = plugins.child_process.spawn(this.ffprobePath, args);
|
||||
let stdout = '';
|
||||
let stderr = '';
|
||||
|
||||
proc.stdout?.on('data', (data: Buffer) => {
|
||||
stdout += data.toString();
|
||||
});
|
||||
|
||||
proc.stderr?.on('data', (data: Buffer) => {
|
||||
stderr += data.toString();
|
||||
});
|
||||
|
||||
proc.on('close', (code) => {
|
||||
if (code === 0) {
|
||||
resolve(stdout);
|
||||
} else {
|
||||
reject(new Error(`FFprobe exited with code ${code}: ${stderr}`));
|
||||
}
|
||||
});
|
||||
|
||||
proc.on('error', reject);
|
||||
});
|
||||
}
|
||||
|
||||
private formatTime(time: number | string): string {
|
||||
if (typeof time === 'string') {
|
||||
return time;
|
||||
}
|
||||
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 isNodeStream(value: unknown): value is Readable {
|
||||
return value !== null &&
|
||||
typeof value === 'object' &&
|
||||
typeof (value as any).pipe === 'function' &&
|
||||
!(value instanceof ReadableStream);
|
||||
}
|
||||
|
||||
private isWebReadableStream(value: unknown): value is ReadableStream<Uint8Array> {
|
||||
return value instanceof ReadableStream;
|
||||
}
|
||||
|
||||
private isMemoryInput(): boolean {
|
||||
return Buffer.isBuffer(this.inputSource) ||
|
||||
this.inputSource instanceof Uint8Array ||
|
||||
this.isNodeStream(this.inputSource) ||
|
||||
this.isWebReadableStream(this.inputSource);
|
||||
}
|
||||
|
||||
private pipeInputToProcess(proc: ReturnType<typeof plugins.child_process.spawn>): void {
|
||||
if (Buffer.isBuffer(this.inputSource)) {
|
||||
proc.stdin?.write(this.inputSource);
|
||||
proc.stdin?.end();
|
||||
} else if (this.inputSource instanceof Uint8Array) {
|
||||
// Convert Uint8Array to Buffer
|
||||
proc.stdin?.write(Buffer.from(this.inputSource));
|
||||
proc.stdin?.end();
|
||||
} else if (this.isWebReadableStream(this.inputSource)) {
|
||||
// Convert Web ReadableStream to Node.js Readable and pipe
|
||||
// Cast to any to handle type mismatch between global ReadableStream and node:stream/web types
|
||||
const nodeReadable = Readable.fromWeb(this.inputSource as any);
|
||||
nodeReadable.pipe(proc.stdin!);
|
||||
} else if (this.isNodeStream(this.inputSource)) {
|
||||
(this.inputSource as Readable).pipe(proc.stdin!);
|
||||
} else if (typeof this.inputSource === 'string') {
|
||||
proc.stdin?.end();
|
||||
}
|
||||
}
|
||||
}
|
||||
750
ts/classes.smartffmpeg.ts
Normal file
750
ts/classes.smartffmpeg.ts
Normal file
@@ -0,0 +1,750 @@
|
||||
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);
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
8
ts/index.ts
Normal file
8
ts/index.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
// Export main class
|
||||
export * from './classes.smartffmpeg.js';
|
||||
|
||||
// Export builder command class
|
||||
export * from './classes.ffmpegcommand.js';
|
||||
|
||||
// Export interfaces and types
|
||||
export * from './interfaces.js';
|
||||
185
ts/interfaces.ts
Normal file
185
ts/interfaces.ts
Normal file
@@ -0,0 +1,185 @@
|
||||
/**
|
||||
* Video codec options
|
||||
*/
|
||||
export type TVideoCodec =
|
||||
| 'libx264' // H.264
|
||||
| 'libx265' // H.265/HEVC
|
||||
| 'libvpx' // VP8
|
||||
| 'libvpx-vp9' // VP9
|
||||
| 'libaom-av1' // AV1
|
||||
| 'mpeg4'
|
||||
| 'copy' // Copy without re-encoding
|
||||
| string; // Allow custom codecs
|
||||
|
||||
/**
|
||||
* Audio codec options
|
||||
*/
|
||||
export type TAudioCodec =
|
||||
| 'aac'
|
||||
| 'libmp3lame' // MP3
|
||||
| 'libopus' // Opus
|
||||
| 'libvorbis' // Vorbis
|
||||
| 'flac'
|
||||
| 'pcm_s16le' // PCM 16-bit
|
||||
| 'copy' // Copy without re-encoding
|
||||
| string; // Allow custom codecs
|
||||
|
||||
/**
|
||||
* Output format/container
|
||||
*/
|
||||
export type TOutputFormat =
|
||||
| 'mp4'
|
||||
| 'webm'
|
||||
| 'mkv'
|
||||
| 'avi'
|
||||
| 'mov'
|
||||
| 'flv'
|
||||
| 'mp3'
|
||||
| 'wav'
|
||||
| 'ogg'
|
||||
| 'flac'
|
||||
| 'm4a'
|
||||
| 'gif'
|
||||
| string;
|
||||
|
||||
/**
|
||||
* Preset for encoding speed/quality tradeoff
|
||||
*/
|
||||
export type TPreset =
|
||||
| 'ultrafast'
|
||||
| 'superfast'
|
||||
| 'veryfast'
|
||||
| 'faster'
|
||||
| 'fast'
|
||||
| 'medium'
|
||||
| 'slow'
|
||||
| 'slower'
|
||||
| 'veryslow';
|
||||
|
||||
/**
|
||||
* Media information from ffprobe
|
||||
*/
|
||||
export interface IMediaInfo {
|
||||
format: {
|
||||
filename: string;
|
||||
formatName: string;
|
||||
formatLongName: string;
|
||||
duration: number;
|
||||
size: number;
|
||||
bitrate: number;
|
||||
};
|
||||
streams: IStreamInfo[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Stream information
|
||||
*/
|
||||
export interface IStreamInfo {
|
||||
index: number;
|
||||
codecName: string;
|
||||
codecLongName: string;
|
||||
codecType: 'video' | 'audio' | 'subtitle' | 'data';
|
||||
width?: number;
|
||||
height?: number;
|
||||
frameRate?: number;
|
||||
bitrate?: number;
|
||||
sampleRate?: number;
|
||||
channels?: number;
|
||||
duration?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Conversion options
|
||||
*/
|
||||
export interface IConversionOptions {
|
||||
/** Output format/container */
|
||||
format?: TOutputFormat;
|
||||
/** Video codec */
|
||||
videoCodec?: TVideoCodec;
|
||||
/** Audio codec */
|
||||
audioCodec?: TAudioCodec;
|
||||
/** Video bitrate (e.g., '1M', '2000k') */
|
||||
videoBitrate?: string;
|
||||
/** Audio bitrate (e.g., '128k', '320k') */
|
||||
audioBitrate?: string;
|
||||
/** Output width (height auto-scaled if not specified) */
|
||||
width?: number;
|
||||
/** Output height (width auto-scaled if not specified) */
|
||||
height?: number;
|
||||
/** Frame rate */
|
||||
fps?: number;
|
||||
/** Audio sample rate in Hz */
|
||||
sampleRate?: number;
|
||||
/** Audio channels (1 for mono, 2 for stereo) */
|
||||
audioChannels?: number;
|
||||
/** Encoding preset (speed/quality tradeoff) */
|
||||
preset?: TPreset;
|
||||
/** Constant Rate Factor for quality (0-51, lower is better) */
|
||||
crf?: number;
|
||||
/** Start time in seconds or timecode string */
|
||||
startTime?: number | string;
|
||||
/** Duration in seconds or timecode string */
|
||||
duration?: number | string;
|
||||
/** Remove audio track */
|
||||
noAudio?: boolean;
|
||||
/** Remove video track */
|
||||
noVideo?: boolean;
|
||||
/** Additional ffmpeg arguments */
|
||||
extraArgs?: string[];
|
||||
/** Overwrite output file if exists */
|
||||
overwrite?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Progress information during conversion
|
||||
*/
|
||||
export interface IProgressInfo {
|
||||
/** Current frame number */
|
||||
frame: number;
|
||||
/** Frames per second being processed */
|
||||
fps: number;
|
||||
/** Current quality metric */
|
||||
q: number;
|
||||
/** Output file size so far */
|
||||
size: number;
|
||||
/** Current time position in seconds */
|
||||
time: number;
|
||||
/** Current bitrate */
|
||||
bitrate: string;
|
||||
/** Processing speed (e.g., 1.5x realtime) */
|
||||
speed: string;
|
||||
/** Progress percentage (0-100) if duration known */
|
||||
percent?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Screenshot options
|
||||
*/
|
||||
export interface IScreenshotOptions {
|
||||
/** Time position in seconds or timecode string */
|
||||
time: number | string;
|
||||
/** Output width */
|
||||
width?: number;
|
||||
/** Output height */
|
||||
height?: number;
|
||||
/** Output format (default: 'png') */
|
||||
format?: 'png' | 'jpg' | 'webp';
|
||||
/** Quality for jpg/webp (1-100) */
|
||||
quality?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Thumbnail generation options
|
||||
*/
|
||||
export interface IThumbnailOptions {
|
||||
/** Number of thumbnails to generate */
|
||||
count: number;
|
||||
/** Output width */
|
||||
width?: number;
|
||||
/** Output height */
|
||||
height?: number;
|
||||
/** Output format */
|
||||
format?: 'png' | 'jpg' | 'webp';
|
||||
/** Filename pattern (use %d for number) */
|
||||
filenamePattern?: string;
|
||||
}
|
||||
16
ts/plugins.ts
Normal file
16
ts/plugins.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
// Node native modules
|
||||
import * as path from 'path';
|
||||
import * as child_process from 'child_process';
|
||||
import { createRequire } from 'module';
|
||||
export { path, child_process };
|
||||
|
||||
// @push.rocks scope
|
||||
import * as smartfs from '@push.rocks/smartfs';
|
||||
import * as smartpath from '@push.rocks/smartpath';
|
||||
import * as smartpromise from '@push.rocks/smartpromise';
|
||||
export { smartfs, smartpath, smartpromise };
|
||||
|
||||
// ffmpeg static binaries - use createRequire for CommonJS compatibility
|
||||
const require = createRequire(import.meta.url);
|
||||
export const ffmpegBinaryPath: string = require('ffmpeg-static');
|
||||
export const ffprobeBinaryPath: string = require('ffprobe-static').path;
|
||||
Reference in New Issue
Block a user