initial
This commit is contained in:
2
.gitignore
vendored
Normal file
2
.gitignore
vendored
Normal file
@@ -0,0 +1,2 @@
|
||||
.nogit/
|
||||
node_modules/
|
||||
8
dist_ts/00_commitinfo_data.d.ts
vendored
Normal file
8
dist_ts/00_commitinfo_data.d.ts
vendored
Normal file
@@ -0,0 +1,8 @@
|
||||
/**
|
||||
* autocreated commitinfo by @push.rocks/commitinfo
|
||||
*/
|
||||
export declare const commitinfo: {
|
||||
name: string;
|
||||
version: string;
|
||||
description: string;
|
||||
};
|
||||
9
dist_ts/00_commitinfo_data.js
Normal file
9
dist_ts/00_commitinfo_data.js
Normal file
@@ -0,0 +1,9 @@
|
||||
/**
|
||||
* 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'
|
||||
};
|
||||
//# sourceMappingURL=data:application/json;base64,eyJ2ZXJzaW9uIjozLCJmaWxlIjoiMDBfY29tbWl0aW5mb19kYXRhLmpzIiwic291cmNlUm9vdCI6IiIsInNvdXJjZXMiOlsiLi4vdHMvMDBfY29tbWl0aW5mb19kYXRhLnRzIl0sIm5hbWVzIjpbXSwibWFwcGluZ3MiOiJBQUFBOztHQUVHO0FBQ0gsTUFBTSxDQUFDLE1BQU0sVUFBVSxHQUFHO0lBQ3hCLElBQUksRUFBRSx5QkFBeUI7SUFDL0IsT0FBTyxFQUFFLE9BQU87SUFDaEIsV0FBVyxFQUFFLDhEQUE4RDtDQUM1RSxDQUFBIn0=
|
||||
288
dist_ts/classes.ffmpegcommand.d.ts
vendored
Normal file
288
dist_ts/classes.ffmpegcommand.d.ts
vendored
Normal file
@@ -0,0 +1,288 @@
|
||||
import type * as interfaces from './interfaces.js';
|
||||
/**
|
||||
* Input source for FfmpegCommand
|
||||
* Supports file paths, memory buffers, and Web Streams
|
||||
*/
|
||||
export type TFfmpegInput = string | Buffer | Uint8Array | ReadableStream<Uint8Array>;
|
||||
/**
|
||||
* Output destination for FfmpegCommand
|
||||
*/
|
||||
export type TFfmpegOutput = string | 'buffer' | 'stream' | WritableStream<Uint8Array>;
|
||||
/**
|
||||
* 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 declare class FfmpegCommand {
|
||||
private ffmpegPath;
|
||||
private ffprobePath;
|
||||
private inputSource;
|
||||
private inputOpts;
|
||||
private customInputArgs;
|
||||
private outputDest;
|
||||
private outputFormat;
|
||||
private customOutputArgs;
|
||||
private _videoCodec;
|
||||
private _videoBitrate;
|
||||
private _width;
|
||||
private _height;
|
||||
private _fps;
|
||||
private _crf;
|
||||
private _preset;
|
||||
private _noVideo;
|
||||
private _audioCodec;
|
||||
private _audioBitrate;
|
||||
private _sampleRate;
|
||||
private _audioChannels;
|
||||
private _noAudio;
|
||||
private _startTime;
|
||||
private _duration;
|
||||
private videoFilters;
|
||||
private audioFilters;
|
||||
private _complexFilter;
|
||||
private extraInputArgs;
|
||||
private extraOutputArgs;
|
||||
private progressCallback;
|
||||
private _overwrite;
|
||||
constructor(ffmpegPath: string, ffprobePath: string);
|
||||
/**
|
||||
* 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;
|
||||
/**
|
||||
* Seek to position before input (fast seek)
|
||||
* @param time - Time in seconds or timecode string
|
||||
*/
|
||||
seekInput(time: number | string): this;
|
||||
/**
|
||||
* Add custom input arguments
|
||||
*/
|
||||
inputArgs(...args: string[]): this;
|
||||
/**
|
||||
* Set video codec
|
||||
*/
|
||||
videoCodec(codec: interfaces.TVideoCodec): this;
|
||||
/**
|
||||
* Set video bitrate
|
||||
* @param bitrate - e.g., '1M', '2000k'
|
||||
*/
|
||||
videoBitrate(bitrate: string): 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;
|
||||
/**
|
||||
* Set frame rate
|
||||
*/
|
||||
fps(rate: number): this;
|
||||
/**
|
||||
* Set Constant Rate Factor (quality)
|
||||
* @param value - 0-51, lower is better quality
|
||||
*/
|
||||
crf(value: number): this;
|
||||
/**
|
||||
* Set encoding preset
|
||||
*/
|
||||
preset(value: interfaces.TPreset): this;
|
||||
/**
|
||||
* Remove video stream
|
||||
*/
|
||||
noVideo(): this;
|
||||
/**
|
||||
* Add a video filter
|
||||
* @param filter - Filter string (e.g., 'scale=1920:1080', 'hflip')
|
||||
*/
|
||||
videoFilter(filter: string): 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;
|
||||
/**
|
||||
* Crop video
|
||||
*/
|
||||
crop(width: number, height: number, x?: number, y?: number): this;
|
||||
/**
|
||||
* Rotate video
|
||||
* @param angle - Rotation in radians or 'PI/2', 'PI', etc.
|
||||
*/
|
||||
rotate(angle: number | string): this;
|
||||
/**
|
||||
* Flip video horizontally
|
||||
*/
|
||||
flipHorizontal(): this;
|
||||
/**
|
||||
* Flip video vertically
|
||||
*/
|
||||
flipVertical(): this;
|
||||
/**
|
||||
* Add padding to video
|
||||
*/
|
||||
pad(width: number, height: number, x?: number, y?: number, color?: string): this;
|
||||
/**
|
||||
* Set audio codec
|
||||
*/
|
||||
audioCodec(codec: interfaces.TAudioCodec): this;
|
||||
/**
|
||||
* Set audio bitrate
|
||||
* @param bitrate - e.g., '128k', '320k'
|
||||
*/
|
||||
audioBitrate(bitrate: string): this;
|
||||
/**
|
||||
* Set audio sample rate
|
||||
* @param rate - Sample rate in Hz (e.g., 44100, 48000)
|
||||
*/
|
||||
sampleRate(rate: number): this;
|
||||
/**
|
||||
* Set number of audio channels
|
||||
* @param channels - 1 for mono, 2 for stereo
|
||||
*/
|
||||
audioChannels(channels: number): this;
|
||||
/**
|
||||
* Remove audio stream
|
||||
*/
|
||||
noAudio(): 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;
|
||||
/**
|
||||
* Set audio volume
|
||||
* @param level - Volume multiplier (e.g., 2 for double, 0.5 for half)
|
||||
*/
|
||||
volume(level: number): this;
|
||||
/**
|
||||
* Normalize audio
|
||||
*/
|
||||
normalize(): this;
|
||||
/**
|
||||
* Set start time (seek)
|
||||
* @param time - Time in seconds or timecode string
|
||||
*/
|
||||
seek(time: number | string): this;
|
||||
/**
|
||||
* Set output duration
|
||||
* @param time - Duration in seconds or timecode string
|
||||
*/
|
||||
duration(time: number | string): this;
|
||||
/**
|
||||
* Set both start and end time
|
||||
*/
|
||||
trim(start: number | string, end: number | string): this;
|
||||
/**
|
||||
* Set a complex filter graph
|
||||
* @param filterGraph - Complex filter string
|
||||
*/
|
||||
complexFilter(filterGraph: string): this;
|
||||
/**
|
||||
* Set output format
|
||||
*/
|
||||
format(fmt: interfaces.TOutputFormat): this;
|
||||
/**
|
||||
* Set output destination (file path)
|
||||
*/
|
||||
output(dest: string): this;
|
||||
/**
|
||||
* Add custom output arguments
|
||||
*/
|
||||
outputArgs(...args: string[]): this;
|
||||
/**
|
||||
* Set overwrite flag
|
||||
*/
|
||||
overwrite(value?: boolean): this;
|
||||
/**
|
||||
* Set progress callback
|
||||
*/
|
||||
onProgress(callback: TFfmpegProgressCallback): this;
|
||||
/**
|
||||
* Run the command and output to file
|
||||
*/
|
||||
run(): Promise<IFfmpegResult>;
|
||||
/**
|
||||
* Run the command and return output as Buffer
|
||||
* @param format - Output format
|
||||
*/
|
||||
toBuffer(format?: interfaces.TOutputFormat): Promise<Buffer>;
|
||||
/**
|
||||
* Run the command and return output as Web ReadableStream
|
||||
* @param format - Output format
|
||||
*/
|
||||
toStream(format?: interfaces.TOutputFormat): ReadableStream<Uint8Array>;
|
||||
/**
|
||||
* Pipe output to a Web WritableStream
|
||||
* @param writable - Web WritableStream
|
||||
* @param format - Output format
|
||||
*/
|
||||
pipe(writable: WritableStream<Uint8Array>, format?: interfaces.TOutputFormat): Promise<void>;
|
||||
/**
|
||||
* Get the ffmpeg arguments that would be used (for debugging)
|
||||
*/
|
||||
getArgs(outputPath?: string): string[];
|
||||
private buildArgs;
|
||||
private execute;
|
||||
private executeToBuffer;
|
||||
private executeToNodeStream;
|
||||
private parseProgress;
|
||||
private getInputDuration;
|
||||
private runProbe;
|
||||
private formatTime;
|
||||
private isNodeStream;
|
||||
private isWebReadableStream;
|
||||
private isMemoryInput;
|
||||
private pipeInputToProcess;
|
||||
}
|
||||
731
dist_ts/classes.ffmpegcommand.js
Normal file
731
dist_ts/classes.ffmpegcommand.js
Normal file
File diff suppressed because one or more lines are too long
185
dist_ts/classes.smartffmpeg.d.ts
vendored
Normal file
185
dist_ts/classes.smartffmpeg.d.ts
vendored
Normal file
@@ -0,0 +1,185 @@
|
||||
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 declare class SmartFfmpeg {
|
||||
private ffmpegPath;
|
||||
private ffprobePath;
|
||||
constructor();
|
||||
/**
|
||||
* 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;
|
||||
/**
|
||||
* 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;
|
||||
/**
|
||||
* Get media file information using ffprobe
|
||||
*/
|
||||
getMediaInfo(inputPath: string): Promise<interfaces.IMediaInfo>;
|
||||
/**
|
||||
* Convert media file with specified options
|
||||
*/
|
||||
convert(inputPath: string, outputPath: string, options?: interfaces.IConversionOptions): Promise<void>;
|
||||
/**
|
||||
* Convert media file with progress reporting
|
||||
*/
|
||||
convertWithProgress(inputPath: string, outputPath: string, options?: interfaces.IConversionOptions, onProgress?: TProgressCallback): Promise<void>;
|
||||
/**
|
||||
* Extract audio from video file
|
||||
*/
|
||||
extractAudio(inputPath: string, outputPath: string, options?: Pick<interfaces.IConversionOptions, 'audioCodec' | 'audioBitrate' | 'sampleRate' | 'audioChannels' | 'startTime' | 'duration' | 'overwrite'>): Promise<void>;
|
||||
/**
|
||||
* Remove audio from video file
|
||||
*/
|
||||
removeAudio(inputPath: string, outputPath: string, options?: Pick<interfaces.IConversionOptions, 'videoCodec' | 'videoBitrate' | 'overwrite'>): Promise<void>;
|
||||
/**
|
||||
* Take a screenshot at a specific time
|
||||
*/
|
||||
screenshot(inputPath: string, outputPath: string, options: interfaces.IScreenshotOptions): Promise<void>;
|
||||
/**
|
||||
* Generate multiple thumbnails from video
|
||||
*/
|
||||
generateThumbnails(inputPath: string, outputDir: string, options: interfaces.IThumbnailOptions): Promise<string[]>;
|
||||
/**
|
||||
* Resize video
|
||||
*/
|
||||
resize(inputPath: string, outputPath: string, width?: number, height?: number, options?: Omit<interfaces.IConversionOptions, 'width' | 'height'>): Promise<void>;
|
||||
/**
|
||||
* Change video frame rate
|
||||
*/
|
||||
changeFrameRate(inputPath: string, outputPath: string, fps: number, options?: Omit<interfaces.IConversionOptions, 'fps'>): Promise<void>;
|
||||
/**
|
||||
* Trim media file
|
||||
*/
|
||||
trim(inputPath: string, outputPath: string, startTime: number | string, duration: number | string, options?: Omit<interfaces.IConversionOptions, 'startTime' | 'duration'>): Promise<void>;
|
||||
/**
|
||||
* Convert to GIF
|
||||
*/
|
||||
toGif(inputPath: string, outputPath: string, options?: {
|
||||
width?: number;
|
||||
height?: number;
|
||||
fps?: number;
|
||||
startTime?: number | string;
|
||||
duration?: number | string;
|
||||
}): Promise<void>;
|
||||
/**
|
||||
* Concatenate multiple media files
|
||||
*/
|
||||
concat(inputPaths: string[], outputPath: string, options?: interfaces.IConversionOptions): Promise<void>;
|
||||
/**
|
||||
* Add audio to video
|
||||
*/
|
||||
addAudio(videoPath: string, audioPath: string, outputPath: string, options?: {
|
||||
videoCodec?: interfaces.TVideoCodec;
|
||||
audioCodec?: interfaces.TAudioCodec;
|
||||
audioBitrate?: string;
|
||||
shortest?: boolean;
|
||||
overwrite?: boolean;
|
||||
}): Promise<void>;
|
||||
/**
|
||||
* Get available encoders
|
||||
*/
|
||||
getEncoders(): Promise<string[]>;
|
||||
/**
|
||||
* Get available decoders
|
||||
*/
|
||||
getDecoders(): Promise<string[]>;
|
||||
/**
|
||||
* Get available formats
|
||||
*/
|
||||
getFormats(): Promise<string[]>;
|
||||
/**
|
||||
* Run ffmpeg with raw arguments
|
||||
*/
|
||||
runRaw(args: string[]): Promise<{
|
||||
stdout: string;
|
||||
stderr: string;
|
||||
}>;
|
||||
/**
|
||||
* Run ffprobe with raw arguments
|
||||
*/
|
||||
runProbeRaw(args: string[]): Promise<{
|
||||
stdout: string;
|
||||
stderr: string;
|
||||
}>;
|
||||
private buildConversionArgs;
|
||||
private buildScaleFilter;
|
||||
private formatTime;
|
||||
private parseStreamInfo;
|
||||
private runProcess;
|
||||
}
|
||||
586
dist_ts/classes.smartffmpeg.js
Normal file
586
dist_ts/classes.smartffmpeg.js
Normal file
File diff suppressed because one or more lines are too long
3
dist_ts/index.d.ts
vendored
Normal file
3
dist_ts/index.d.ts
vendored
Normal file
@@ -0,0 +1,3 @@
|
||||
export * from './classes.smartffmpeg.js';
|
||||
export * from './classes.ffmpegcommand.js';
|
||||
export * from './interfaces.js';
|
||||
7
dist_ts/index.js
Normal file
7
dist_ts/index.js
Normal file
@@ -0,0 +1,7 @@
|
||||
// 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';
|
||||
//# sourceMappingURL=data:application/json;base64,eyJ2ZXJzaW9uIjozLCJmaWxlIjoiaW5kZXguanMiLCJzb3VyY2VSb290IjoiIiwic291cmNlcyI6WyIuLi90cy9pbmRleC50cyJdLCJuYW1lcyI6W10sIm1hcHBpbmdzIjoiQUFBQSxvQkFBb0I7QUFDcEIsY0FBYywwQkFBMEIsQ0FBQztBQUV6QywrQkFBK0I7QUFDL0IsY0FBYyw0QkFBNEIsQ0FBQztBQUUzQyw4QkFBOEI7QUFDOUIsY0FBYyxpQkFBaUIsQ0FBQyJ9
|
||||
138
dist_ts/interfaces.d.ts
vendored
Normal file
138
dist_ts/interfaces.d.ts
vendored
Normal file
@@ -0,0 +1,138 @@
|
||||
/**
|
||||
* Video codec options
|
||||
*/
|
||||
export type TVideoCodec = 'libx264' | 'libx265' | 'libvpx' | 'libvpx-vp9' | 'libaom-av1' | 'mpeg4' | 'copy' | string;
|
||||
/**
|
||||
* Audio codec options
|
||||
*/
|
||||
export type TAudioCodec = 'aac' | 'libmp3lame' | 'libopus' | 'libvorbis' | 'flac' | 'pcm_s16le' | 'copy' | string;
|
||||
/**
|
||||
* 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;
|
||||
}
|
||||
2
dist_ts/interfaces.js
Normal file
2
dist_ts/interfaces.js
Normal file
@@ -0,0 +1,2 @@
|
||||
export {};
|
||||
//# sourceMappingURL=data:application/json;base64,eyJ2ZXJzaW9uIjozLCJmaWxlIjoiaW50ZXJmYWNlcy5qcyIsInNvdXJjZVJvb3QiOiIiLCJzb3VyY2VzIjpbIi4uL3RzL2ludGVyZmFjZXMudHMiXSwibmFtZXMiOltdLCJtYXBwaW5ncyI6IiJ9
|
||||
9
dist_ts/plugins.d.ts
vendored
Normal file
9
dist_ts/plugins.d.ts
vendored
Normal file
@@ -0,0 +1,9 @@
|
||||
import * as path from 'path';
|
||||
import * as child_process from 'child_process';
|
||||
export { path, child_process };
|
||||
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 };
|
||||
export declare const ffmpegBinaryPath: string;
|
||||
export declare const ffprobeBinaryPath: string;
|
||||
15
dist_ts/plugins.js
Normal file
15
dist_ts/plugins.js
Normal file
@@ -0,0 +1,15 @@
|
||||
// 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 = require('ffmpeg-static');
|
||||
export const ffprobeBinaryPath = require('ffprobe-static').path;
|
||||
//# sourceMappingURL=data:application/json;base64,eyJ2ZXJzaW9uIjozLCJmaWxlIjoicGx1Z2lucy5qcyIsInNvdXJjZVJvb3QiOiIiLCJzb3VyY2VzIjpbIi4uL3RzL3BsdWdpbnMudHMiXSwibmFtZXMiOltdLCJtYXBwaW5ncyI6IkFBQUEsc0JBQXNCO0FBQ3RCLE9BQU8sS0FBSyxJQUFJLE1BQU0sTUFBTSxDQUFDO0FBQzdCLE9BQU8sS0FBSyxhQUFhLE1BQU0sZUFBZSxDQUFDO0FBQy9DLE9BQU8sRUFBRSxhQUFhLEVBQUUsTUFBTSxRQUFRLENBQUM7QUFDdkMsT0FBTyxFQUFFLElBQUksRUFBRSxhQUFhLEVBQUUsQ0FBQztBQUUvQixvQkFBb0I7QUFDcEIsT0FBTyxLQUFLLE9BQU8sTUFBTSxxQkFBcUIsQ0FBQztBQUMvQyxPQUFPLEtBQUssU0FBUyxNQUFNLHVCQUF1QixDQUFDO0FBQ25ELE9BQU8sS0FBSyxZQUFZLE1BQU0sMEJBQTBCLENBQUM7QUFDekQsT0FBTyxFQUFFLE9BQU8sRUFBRSxTQUFTLEVBQUUsWUFBWSxFQUFFLENBQUM7QUFFNUMsd0VBQXdFO0FBQ3hFLE1BQU0sT0FBTyxHQUFHLGFBQWEsQ0FBQyxNQUFNLENBQUMsSUFBSSxDQUFDLEdBQUcsQ0FBQyxDQUFDO0FBQy9DLE1BQU0sQ0FBQyxNQUFNLGdCQUFnQixHQUFXLE9BQU8sQ0FBQyxlQUFlLENBQUMsQ0FBQztBQUNqRSxNQUFNLENBQUMsTUFBTSxpQkFBaUIsR0FBVyxPQUFPLENBQUMsZ0JBQWdCLENBQUMsQ0FBQyxJQUFJLENBQUMifQ==
|
||||
21
license
Normal file
21
license
Normal file
@@ -0,0 +1,21 @@
|
||||
The MIT License (MIT)
|
||||
|
||||
Copyright (c) 2025 Task Venture Capital GmbH
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
25
npmextra.json
Normal file
25
npmextra.json
Normal file
@@ -0,0 +1,25 @@
|
||||
{
|
||||
"gitzone": {
|
||||
"projectType": "npm",
|
||||
"module": {
|
||||
"githost": "code.foss.global",
|
||||
"gitscope": "push.rocks",
|
||||
"gitrepo": "smartffmpeg",
|
||||
"description": "A fast Node.js module for media file conversion using ffmpeg",
|
||||
"npmPackagename": "@push.rocks/smartffmpeg",
|
||||
"license": "MIT",
|
||||
"keywords": [
|
||||
"ffmpeg",
|
||||
"media conversion",
|
||||
"video conversion",
|
||||
"audio conversion",
|
||||
"transcoding",
|
||||
"video processing",
|
||||
"audio processing",
|
||||
"media encoding",
|
||||
"typescript",
|
||||
"nodejs"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
45
package.json
Normal file
45
package.json
Normal file
@@ -0,0 +1,45 @@
|
||||
{
|
||||
"name": "@push.rocks/smartffmpeg",
|
||||
"version": "1.0.0",
|
||||
"private": false,
|
||||
"description": "A fast Node.js module for media file conversion using ffmpeg",
|
||||
"main": "dist_ts/index.js",
|
||||
"typings": "dist_ts/index.d.ts",
|
||||
"type": "module",
|
||||
"author": "Task Venture Capital GmbH",
|
||||
"license": "MIT",
|
||||
"scripts": {
|
||||
"test": "(tstest test/ --verbose)",
|
||||
"build": "(tsbuild --web --allowimplicitany)",
|
||||
"buildDocs": "tsdoc"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@git.zone/tsbuild": "^3.1.2",
|
||||
"@git.zone/tsrun": "^2.0.0",
|
||||
"@git.zone/tstest": "^3.1.3",
|
||||
"@types/ffprobe-static": "^2.0.3",
|
||||
"@types/node": "^25.0.0"
|
||||
},
|
||||
"dependencies": {
|
||||
"@push.rocks/smartfs": "^1.2.0",
|
||||
"@push.rocks/smartpath": "^6.0.0",
|
||||
"@push.rocks/smartpromise": "^4.0.0",
|
||||
"ffmpeg-static": "^5.3.0",
|
||||
"ffprobe-static": "^3.1.0"
|
||||
},
|
||||
"files": [
|
||||
"ts/**/*",
|
||||
"ts_web/**/*",
|
||||
"dist/**/*",
|
||||
"dist_*/**/*",
|
||||
"dist_ts/**/*",
|
||||
"dist_ts_web/**/*",
|
||||
"assets/**/*",
|
||||
"cli.js",
|
||||
"npmextra.json",
|
||||
"readme.md"
|
||||
],
|
||||
"browserslist": [
|
||||
"last 1 chrome versions"
|
||||
]
|
||||
}
|
||||
8123
pnpm-lock.yaml
generated
Normal file
8123
pnpm-lock.yaml
generated
Normal file
File diff suppressed because it is too large
Load Diff
70
readme.hints.md
Normal file
70
readme.hints.md
Normal file
@@ -0,0 +1,70 @@
|
||||
# Implementation Notes
|
||||
|
||||
## Architecture
|
||||
|
||||
- Main class: `SmartFfmpeg` in `ts/classes.smartffmpeg.ts`
|
||||
- Builder class: `FfmpegCommand` in `ts/classes.ffmpegcommand.ts`
|
||||
- Entry point: `ts/index.ts`
|
||||
- Uses `ffmpeg-static` and `ffprobe-static` for bundled binaries
|
||||
- Direct child_process spawning via `createRequire` for CommonJS compatibility
|
||||
|
||||
## Key Design Decisions
|
||||
|
||||
1. **Modern Builder API**: Fluent chainable API similar to modern JS libraries (e.g., Knex, Prisma)
|
||||
|
||||
2. **Web Streams API**: Uses Web Streams (`ReadableStream`/`WritableStream`) for cross-platform compatibility
|
||||
- Input: `string | Buffer | Uint8Array | ReadableStream<Uint8Array>`
|
||||
- Output: `toStream()` returns `ReadableStream<Uint8Array>`
|
||||
- Piping: `pipe()` accepts `WritableStream<Uint8Array>`
|
||||
- Internally converts between Web Streams and Node streams using `Readable.toWeb()`/`Readable.fromWeb()`
|
||||
|
||||
3. **No fluent-ffmpeg**: The deprecated fluent-ffmpeg library was avoided in favor of direct ffmpeg invocation
|
||||
|
||||
4. **Backward Compatibility**: Legacy API methods are preserved alongside the new builder API
|
||||
|
||||
5. **Static binaries**: Uses ffmpeg-static and ffprobe-static for cross-platform binary distribution
|
||||
|
||||
## API Patterns
|
||||
|
||||
### Builder API (Recommended)
|
||||
```typescript
|
||||
const result = await ffmpeg.input(source)
|
||||
.videoCodec('libx264')
|
||||
.output('output.mp4')
|
||||
.run();
|
||||
|
||||
// Web Streams
|
||||
const webStream: ReadableStream<Uint8Array> = ffmpeg.input(buffer, { format: 'mp4' })
|
||||
.videoCodec('libx264')
|
||||
.toStream('webm');
|
||||
```
|
||||
|
||||
### Legacy API
|
||||
```typescript
|
||||
await ffmpeg.convert('input.mp4', 'output.mp4', { videoCodec: 'libx264' });
|
||||
```
|
||||
|
||||
## Dependencies
|
||||
|
||||
- `ffmpeg-static`: Provides ffmpeg binary
|
||||
- `ffprobe-static`: Provides ffprobe binary for media analysis
|
||||
- `@push.rocks/smartfs`: Filesystem operations
|
||||
- `@push.rocks/smartpath`: Path utilities
|
||||
- `@push.rocks/smartpromise`: Promise utilities
|
||||
|
||||
## Development
|
||||
|
||||
- Run tests: `pnpm test`
|
||||
- Build: `pnpm build`
|
||||
|
||||
## File Structure
|
||||
|
||||
```
|
||||
ts/
|
||||
├── 00_commitinfo_data.ts # Package metadata
|
||||
├── plugins.ts # Centralized imports
|
||||
├── interfaces.ts # TypeScript types
|
||||
├── classes.smartffmpeg.ts # Main SmartFfmpeg class
|
||||
├── classes.ffmpegcommand.ts # Builder command class
|
||||
└── index.ts # Public exports
|
||||
```
|
||||
443
readme.md
Normal file
443
readme.md
Normal file
@@ -0,0 +1,443 @@
|
||||
# @push.rocks/smartffmpeg
|
||||
|
||||
A powerful, modern Node.js wrapper for FFmpeg with a fluent builder API, memory stream support, and zero filesystem dependencies for in-memory operations. 🎬
|
||||
|
||||
## Features
|
||||
|
||||
- 🔗 **Fluent Builder API** – Chain methods for clean, readable code
|
||||
- 💾 **Memory Streams** – Process media directly from/to Buffers without touching the filesystem
|
||||
- 📊 **Progress Tracking** – Real-time progress callbacks with percentage, fps, bitrate, and speed
|
||||
- 🎯 **TypeScript First** – Full type safety with comprehensive interfaces
|
||||
- 📦 **Bundled Binaries** – Ships with `ffmpeg-static` and `ffprobe-static` for zero-config setup
|
||||
- 🔄 **Web Streams Support** – Native `ReadableStream` and `WritableStream` compatibility
|
||||
- 🎛️ **Dual API** – Modern builder API + legacy methods for backward compatibility
|
||||
|
||||
## Install
|
||||
|
||||
```bash
|
||||
pnpm install @push.rocks/smartffmpeg
|
||||
# or
|
||||
npm install @push.rocks/smartffmpeg
|
||||
```
|
||||
|
||||
## Issue Reporting and Security
|
||||
|
||||
For reporting bugs, issues, or security vulnerabilities, please visit [community.foss.global/](https://community.foss.global/). This is the central community hub for all issue reporting. Developers who sign and comply with our contribution agreement and go through identification can also get a [code.foss.global/](https://code.foss.global/) account to submit Pull Requests directly.
|
||||
|
||||
## Usage
|
||||
|
||||
### Quick Start
|
||||
|
||||
```typescript
|
||||
import { SmartFfmpeg } from '@push.rocks/smartffmpeg';
|
||||
|
||||
const ffmpeg = new SmartFfmpeg();
|
||||
|
||||
// Convert a video with the fluent API
|
||||
await ffmpeg.input('/path/to/input.mp4')
|
||||
.videoCodec('libx264')
|
||||
.audioCodec('aac')
|
||||
.videoBitrate('2M')
|
||||
.audioBitrate('128k')
|
||||
.size(1920, 1080)
|
||||
.crf(23)
|
||||
.preset('fast')
|
||||
.output('/path/to/output.mp4')
|
||||
.run();
|
||||
```
|
||||
|
||||
### Builder API (Recommended) 🏗️
|
||||
|
||||
The modern builder API provides a fluent, chainable interface for constructing FFmpeg commands:
|
||||
|
||||
```typescript
|
||||
const ffmpeg = new SmartFfmpeg();
|
||||
|
||||
// File-to-file conversion with progress
|
||||
await ffmpeg.input('/path/to/input.mp4')
|
||||
.videoCodec('libx264')
|
||||
.audioBitrate('128k')
|
||||
.crf(23)
|
||||
.onProgress(progress => {
|
||||
console.log(`⏳ Progress: ${progress.percent?.toFixed(1)}%`);
|
||||
console.log(` Speed: ${progress.speed}, FPS: ${progress.fps}`);
|
||||
})
|
||||
.output('/path/to/output.mp4')
|
||||
.run();
|
||||
```
|
||||
|
||||
### Memory Stream Support 💾
|
||||
|
||||
Process media entirely in memory – perfect for serverless functions, APIs, and pipelines:
|
||||
|
||||
```typescript
|
||||
import fs from 'fs/promises';
|
||||
|
||||
// Buffer → Buffer conversion
|
||||
const inputBuffer = await fs.readFile('input.mp4');
|
||||
const outputBuffer = await ffmpeg.input(inputBuffer, { format: 'mp4' })
|
||||
.videoCodec('libx264')
|
||||
.toBuffer('webm');
|
||||
|
||||
// File → Buffer
|
||||
const buffer = await ffmpeg.input('/path/to/input.mp4')
|
||||
.videoCodec('libx264')
|
||||
.toBuffer('mp4');
|
||||
|
||||
// Buffer → File
|
||||
await ffmpeg.input(inputBuffer, { format: 'mp4' })
|
||||
.videoCodec('libx264')
|
||||
.output('/path/to/output.mp4')
|
||||
.run();
|
||||
|
||||
// Get a Web ReadableStream
|
||||
const stream = ffmpeg.input('/path/to/input.mp4')
|
||||
.videoCodec('libx264')
|
||||
.toStream('mp4');
|
||||
|
||||
// Pipe to a Web WritableStream
|
||||
await ffmpeg.input('/path/to/input.mp4')
|
||||
.videoCodec('libx264')
|
||||
.pipe(writableStream, 'mp4');
|
||||
|
||||
// Web ReadableStream input (e.g., from fetch response)
|
||||
const response = await fetch('https://example.com/video.mp4');
|
||||
const webStream = response.body; // ReadableStream<Uint8Array>
|
||||
const convertedBuffer = await ffmpeg.input(webStream, { format: 'mp4' })
|
||||
.videoCodec('libx264')
|
||||
.toBuffer('webm');
|
||||
|
||||
// Uint8Array input
|
||||
const uint8Data = new Uint8Array(videoBytes);
|
||||
const output = await ffmpeg.input(uint8Data, { format: 'mp4' })
|
||||
.videoCodec('libx264')
|
||||
.toBuffer('webm');
|
||||
```
|
||||
|
||||
### Video Operations 🎥
|
||||
|
||||
```typescript
|
||||
// Scale/resize video
|
||||
await ffmpeg.input('input.mp4')
|
||||
.scale(1280, 720)
|
||||
.output('720p.mp4')
|
||||
.run();
|
||||
|
||||
// Crop video (width, height, x, y)
|
||||
await ffmpeg.input('input.mp4')
|
||||
.crop(640, 480, 100, 50)
|
||||
.output('cropped.mp4')
|
||||
.run();
|
||||
|
||||
// Flip video
|
||||
await ffmpeg.input('input.mp4')
|
||||
.flipHorizontal()
|
||||
.flipVertical()
|
||||
.output('flipped.mp4')
|
||||
.run();
|
||||
|
||||
// Change frame rate
|
||||
await ffmpeg.input('input.mp4')
|
||||
.fps(30)
|
||||
.output('30fps.mp4')
|
||||
.run();
|
||||
|
||||
// Add padding
|
||||
await ffmpeg.input('input.mp4')
|
||||
.pad(1920, 1080, 0, 0, 'black')
|
||||
.output('padded.mp4')
|
||||
.run();
|
||||
|
||||
// Rotate video
|
||||
await ffmpeg.input('input.mp4')
|
||||
.rotate('PI/2') // 90 degrees
|
||||
.output('rotated.mp4')
|
||||
.run();
|
||||
|
||||
// Custom video filter
|
||||
await ffmpeg.input('input.mp4')
|
||||
.videoFilter('eq=brightness=0.1:saturation=1.5')
|
||||
.output('adjusted.mp4')
|
||||
.run();
|
||||
```
|
||||
|
||||
### Audio Operations 🎵
|
||||
|
||||
```typescript
|
||||
// Extract audio from video
|
||||
await ffmpeg.input('video.mp4')
|
||||
.noVideo()
|
||||
.audioCodec('libmp3lame')
|
||||
.audioBitrate('320k')
|
||||
.format('mp3')
|
||||
.output('audio.mp3')
|
||||
.run();
|
||||
|
||||
// Remove audio from video
|
||||
await ffmpeg.input('video.mp4')
|
||||
.noAudio()
|
||||
.output('silent.mp4')
|
||||
.run();
|
||||
|
||||
// Adjust volume
|
||||
await ffmpeg.input('input.mp4')
|
||||
.volume(1.5) // 150% volume
|
||||
.output('louder.mp4')
|
||||
.run();
|
||||
|
||||
// Normalize audio (loudnorm filter)
|
||||
await ffmpeg.input('input.mp4')
|
||||
.normalize()
|
||||
.output('normalized.mp4')
|
||||
.run();
|
||||
|
||||
// Custom audio filter
|
||||
await ffmpeg.input('input.mp4')
|
||||
.audioFilter('aecho=0.8:0.88:60:0.4')
|
||||
.output('echo.mp4')
|
||||
.run();
|
||||
|
||||
// Set sample rate and channels
|
||||
await ffmpeg.input('input.mp4')
|
||||
.sampleRate(44100)
|
||||
.audioChannels(2) // Stereo
|
||||
.output('resampled.mp4')
|
||||
.run();
|
||||
```
|
||||
|
||||
### Trimming and Seeking ✂️
|
||||
|
||||
```typescript
|
||||
// Seek to position and set duration
|
||||
await ffmpeg.input('input.mp4')
|
||||
.seek(10) // Start at 10 seconds
|
||||
.duration(30) // Output 30 seconds
|
||||
.output('clip.mp4')
|
||||
.run();
|
||||
|
||||
// Trim helper (start to end)
|
||||
await ffmpeg.input('input.mp4')
|
||||
.trim(10, 40) // From 10s to 40s
|
||||
.output('clip.mp4')
|
||||
.run();
|
||||
|
||||
// Fast seek (before input decoding)
|
||||
await ffmpeg.input('input.mp4')
|
||||
.seekInput(60) // Fast seek to 60s
|
||||
.duration(10)
|
||||
.output('clip.mp4')
|
||||
.run();
|
||||
```
|
||||
|
||||
### Complex Filters 🎨
|
||||
|
||||
```typescript
|
||||
// Custom complex filter graph (e.g., for high-quality GIFs)
|
||||
await ffmpeg.input('input.mp4')
|
||||
.complexFilter('[0:v]split[s0][s1];[s0]palettegen[p];[s1][p]paletteuse')
|
||||
.output('output.gif')
|
||||
.run();
|
||||
```
|
||||
|
||||
### Debug: Get Generated Arguments 🔍
|
||||
|
||||
Inspect the FFmpeg command that would be generated:
|
||||
|
||||
```typescript
|
||||
const args = ffmpeg.input('input.mp4')
|
||||
.videoCodec('libx264')
|
||||
.crf(23)
|
||||
.output('output.mp4')
|
||||
.getArgs('output.mp4');
|
||||
|
||||
console.log(args);
|
||||
// ['-y', '-i', 'input.mp4', '-c:v', 'libx264', '-crf', '23', 'output.mp4']
|
||||
```
|
||||
|
||||
### Legacy API (Still Supported) 📜
|
||||
|
||||
The original API remains available for backward compatibility:
|
||||
|
||||
```typescript
|
||||
const ffmpeg = new SmartFfmpeg();
|
||||
|
||||
// Get media info
|
||||
const info = await ffmpeg.getMediaInfo('input.mp4');
|
||||
console.log(`Duration: ${info.format.duration}s`);
|
||||
console.log(`Streams: ${info.streams.length}`);
|
||||
|
||||
// Convert with options
|
||||
await ffmpeg.convert('input.mp4', 'output.webm', {
|
||||
videoCodec: 'libvpx-vp9',
|
||||
audioCodec: 'libopus',
|
||||
videoBitrate: '1M',
|
||||
audioBitrate: '128k',
|
||||
});
|
||||
|
||||
// Convert with progress
|
||||
await ffmpeg.convertWithProgress('input.mp4', 'output.mp4', {
|
||||
videoCodec: 'libx264',
|
||||
}, progress => {
|
||||
console.log(`${progress.percent?.toFixed(1)}%`);
|
||||
});
|
||||
|
||||
// Extract audio
|
||||
await ffmpeg.extractAudio('video.mp4', 'audio.mp3', {
|
||||
audioCodec: 'libmp3lame',
|
||||
audioBitrate: '320k',
|
||||
});
|
||||
|
||||
// Screenshot at specific time
|
||||
await ffmpeg.screenshot('video.mp4', 'thumb.png', {
|
||||
time: 10,
|
||||
width: 1280,
|
||||
});
|
||||
|
||||
// Generate multiple thumbnails
|
||||
const thumbs = await ffmpeg.generateThumbnails('video.mp4', './thumbs', {
|
||||
count: 5,
|
||||
width: 320,
|
||||
});
|
||||
|
||||
// Resize video
|
||||
await ffmpeg.resize('input.mp4', 'output.mp4', 1920, 1080);
|
||||
|
||||
// Trim video
|
||||
await ffmpeg.trim('input.mp4', 'clip.mp4', 10, 30);
|
||||
|
||||
// Convert to GIF
|
||||
await ffmpeg.toGif('video.mp4', 'animation.gif', {
|
||||
width: 480,
|
||||
fps: 15,
|
||||
startTime: 5,
|
||||
duration: 3,
|
||||
});
|
||||
|
||||
// Concatenate files
|
||||
await ffmpeg.concat(['part1.mp4', 'part2.mp4'], 'combined.mp4');
|
||||
|
||||
// Add audio to video
|
||||
await ffmpeg.addAudio('video.mp4', 'music.mp3', 'output.mp4');
|
||||
|
||||
// Get FFmpeg capabilities
|
||||
const encoders = await ffmpeg.getEncoders();
|
||||
const decoders = await ffmpeg.getDecoders();
|
||||
const formats = await ffmpeg.getFormats();
|
||||
|
||||
// Run raw FFmpeg command
|
||||
await ffmpeg.runRaw(['-i', 'input.mp4', '-vf', 'hflip', 'output.mp4']);
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### Builder API Methods
|
||||
|
||||
#### Input Methods
|
||||
| Method | Description |
|
||||
|--------|-------------|
|
||||
| `input(source, options?)` | Set input (file path, Buffer, Uint8Array, or ReadableStream) |
|
||||
| `seekInput(time)` | Seek before input (fast seek) |
|
||||
| `inputArgs(...args)` | Add custom input arguments |
|
||||
|
||||
#### Video Methods
|
||||
| Method | Description |
|
||||
|--------|-------------|
|
||||
| `videoCodec(codec)` | Set video codec (`libx264`, `libx265`, `libvpx-vp9`, `libaom-av1`, etc.) |
|
||||
| `videoBitrate(bitrate)` | Set video bitrate (e.g., `'1M'`, `'2000k'`) |
|
||||
| `size(width, height?)` | Set output dimensions |
|
||||
| `fps(rate)` | Set frame rate |
|
||||
| `crf(value)` | Set Constant Rate Factor (0-51, lower = better quality) |
|
||||
| `preset(value)` | Set encoding preset (`ultrafast` → `veryslow`) |
|
||||
| `noVideo()` | Remove video stream |
|
||||
| `videoFilter(filter)` | Add custom video filter |
|
||||
| `scale(w, h)` | Scale video |
|
||||
| `crop(w, h, x, y)` | Crop video |
|
||||
| `rotate(angle)` | Rotate video |
|
||||
| `flipHorizontal()` | Flip horizontally |
|
||||
| `flipVertical()` | Flip vertically |
|
||||
| `pad(w, h, x, y, color)` | Add padding |
|
||||
|
||||
#### Audio Methods
|
||||
| Method | Description |
|
||||
|--------|-------------|
|
||||
| `audioCodec(codec)` | Set audio codec (`aac`, `libmp3lame`, `libopus`, etc.) |
|
||||
| `audioBitrate(bitrate)` | Set audio bitrate (e.g., `'128k'`, `'320k'`) |
|
||||
| `sampleRate(rate)` | Set sample rate in Hz |
|
||||
| `audioChannels(count)` | Set channel count (1=mono, 2=stereo) |
|
||||
| `noAudio()` | Remove audio stream |
|
||||
| `audioFilter(filter)` | Add custom audio filter |
|
||||
| `volume(level)` | Set volume multiplier |
|
||||
| `normalize()` | Apply loudnorm filter |
|
||||
|
||||
#### Timing Methods
|
||||
| Method | Description |
|
||||
|--------|-------------|
|
||||
| `seek(time)` | Seek to position |
|
||||
| `duration(time)` | Set output duration |
|
||||
| `trim(start, end)` | Trim to time range |
|
||||
|
||||
#### Output Methods
|
||||
| Method | Description |
|
||||
|--------|-------------|
|
||||
| `format(fmt)` | Set output format (`mp4`, `webm`, `mkv`, `mp3`, etc.) |
|
||||
| `output(path)` | Set output file path |
|
||||
| `outputArgs(...args)` | Add custom output arguments |
|
||||
| `overwrite(bool)` | Overwrite existing file (default: `true`) |
|
||||
| `complexFilter(graph)` | Set complex filter graph |
|
||||
|
||||
#### Execution Methods
|
||||
| Method | Description |
|
||||
|--------|-------------|
|
||||
| `run()` | Execute and write to file |
|
||||
| `toBuffer(format?)` | Execute and return Buffer |
|
||||
| `toStream(format?)` | Execute and return Web ReadableStream |
|
||||
| `pipe(writable, format?)` | Pipe to Web WritableStream |
|
||||
| `getArgs(outputPath?)` | Get FFmpeg arguments (debugging) |
|
||||
|
||||
#### Callbacks
|
||||
| Method | Description |
|
||||
|--------|-------------|
|
||||
| `onProgress(callback)` | Set progress callback |
|
||||
|
||||
### Supported Codecs
|
||||
|
||||
#### Video Codecs
|
||||
- `libx264` – H.264 (most compatible)
|
||||
- `libx265` – H.265/HEVC (better compression)
|
||||
- `libvpx` – VP8
|
||||
- `libvpx-vp9` – VP9 (web-friendly)
|
||||
- `libaom-av1` – AV1 (best compression, slower)
|
||||
- `copy` – Copy without re-encoding
|
||||
|
||||
#### Audio Codecs
|
||||
- `aac` – AAC (most compatible)
|
||||
- `libmp3lame` – MP3
|
||||
- `libopus` – Opus (excellent quality)
|
||||
- `libvorbis` – Vorbis
|
||||
- `flac` – Lossless
|
||||
- `copy` – Copy without re-encoding
|
||||
|
||||
### Encoding Presets
|
||||
Speed/quality tradeoff for x264/x265:
|
||||
- `ultrafast` → `superfast` → `veryfast` → `faster` → `fast` → `medium` → `slow` → `slower` → `veryslow`
|
||||
|
||||
## License and Legal Information
|
||||
|
||||
This repository contains open-source code licensed under the MIT License. A copy of the license can be found in the [LICENSE](./LICENSE) file.
|
||||
|
||||
**Please note:** The MIT License does not grant permission to use the trade names, trademarks, service marks, or product names of the project, except as required for reasonable and customary use in describing the origin of the work and reproducing the content of the NOTICE file.
|
||||
|
||||
### Trademarks
|
||||
|
||||
This project is owned and maintained by Task Venture Capital GmbH. The names and logos associated with Task Venture Capital GmbH and any related products or services are trademarks of Task Venture Capital GmbH or third parties, and are not included within the scope of the MIT license granted herein.
|
||||
|
||||
Use of these trademarks must comply with Task Venture Capital GmbH's Trademark Guidelines or the guidelines of the respective third-party owners, and any usage must be approved in writing. Third-party trademarks used herein are the property of their respective owners and used only in a descriptive manner, e.g. for an implementation of an API or similar.
|
||||
|
||||
### Company Information
|
||||
|
||||
Task Venture Capital GmbH
|
||||
Registered at District Court Bremen HRB 35230 HB, Germany
|
||||
|
||||
For any legal inquiries or further information, please contact us via email at hello@task.vc.
|
||||
|
||||
By using this repository, you acknowledge that you have read this section, agree to comply with its terms, and understand that the licensing of the code does not imply endorsement by Task Venture Capital GmbH of any derivative works.
|
||||
191
test/test.ts
Normal file
191
test/test.ts
Normal file
@@ -0,0 +1,191 @@
|
||||
import { expect, tap } from '@git.zone/tstest/tapbundle';
|
||||
import * as smartffmpeg from '../ts/index.js';
|
||||
|
||||
let ffmpegInstance: smartffmpeg.SmartFfmpeg;
|
||||
|
||||
tap.test('should create SmartFfmpeg instance', async () => {
|
||||
ffmpegInstance = new smartffmpeg.SmartFfmpeg();
|
||||
expect(ffmpegInstance).toBeInstanceOf(smartffmpeg.SmartFfmpeg);
|
||||
});
|
||||
|
||||
// ==================== BUILDER API TESTS ====================
|
||||
|
||||
tap.test('should create FfmpegCommand via create()', async () => {
|
||||
const command = ffmpegInstance.create();
|
||||
expect(command).toBeInstanceOf(smartffmpeg.FfmpegCommand);
|
||||
});
|
||||
|
||||
tap.test('should create FfmpegCommand via input() shorthand', async () => {
|
||||
const command = ffmpegInstance.input('/path/to/input.mp4');
|
||||
expect(command).toBeInstanceOf(smartffmpeg.FfmpegCommand);
|
||||
});
|
||||
|
||||
tap.test('should build correct args with builder API', async () => {
|
||||
const args = ffmpegInstance.create()
|
||||
.input('/input.mp4')
|
||||
.videoCodec('libx264')
|
||||
.audioCodec('aac')
|
||||
.videoBitrate('1M')
|
||||
.audioBitrate('128k')
|
||||
.size(1920, 1080)
|
||||
.fps(30)
|
||||
.crf(23)
|
||||
.preset('fast')
|
||||
.output('/output.mp4')
|
||||
.getArgs('/output.mp4');
|
||||
|
||||
expect(args).toContain('-i');
|
||||
expect(args).toContain('/input.mp4');
|
||||
expect(args).toContain('-c:v');
|
||||
expect(args).toContain('libx264');
|
||||
expect(args).toContain('-c:a');
|
||||
expect(args).toContain('aac');
|
||||
expect(args).toContain('-b:v');
|
||||
expect(args).toContain('1M');
|
||||
expect(args).toContain('-b:a');
|
||||
expect(args).toContain('128k');
|
||||
expect(args).toContain('-crf');
|
||||
expect(args).toContain('23');
|
||||
expect(args).toContain('-preset');
|
||||
expect(args).toContain('fast');
|
||||
expect(args).toContain('-r');
|
||||
expect(args).toContain('30');
|
||||
console.log('Builder API generates correct ffmpeg arguments');
|
||||
});
|
||||
|
||||
tap.test('should support video filters', async () => {
|
||||
const args = ffmpegInstance.create()
|
||||
.input('/input.mp4')
|
||||
.scale(1280, 720)
|
||||
.flipHorizontal()
|
||||
.output('/output.mp4')
|
||||
.getArgs('/output.mp4');
|
||||
|
||||
expect(args).toContain('-vf');
|
||||
const vfIndex = args.indexOf('-vf');
|
||||
const filterString = args[vfIndex + 1];
|
||||
expect(filterString).toInclude('scale=1280:720');
|
||||
expect(filterString).toInclude('hflip');
|
||||
console.log('Video filters work correctly');
|
||||
});
|
||||
|
||||
tap.test('should support audio options', async () => {
|
||||
const args = ffmpegInstance.create()
|
||||
.input('/input.mp4')
|
||||
.noVideo()
|
||||
.audioCodec('libmp3lame')
|
||||
.audioBitrate('320k')
|
||||
.sampleRate(44100)
|
||||
.audioChannels(2)
|
||||
.volume(1.5)
|
||||
.format('mp3')
|
||||
.getArgs('/output.mp3');
|
||||
|
||||
expect(args).toContain('-vn');
|
||||
expect(args).toContain('-c:a');
|
||||
expect(args).toContain('libmp3lame');
|
||||
expect(args).toContain('-ar');
|
||||
expect(args).toContain('44100');
|
||||
expect(args).toContain('-ac');
|
||||
expect(args).toContain('2');
|
||||
expect(args).toContain('-af');
|
||||
expect(args).toContain('-f');
|
||||
expect(args).toContain('mp3');
|
||||
console.log('Audio extraction with builder works correctly');
|
||||
});
|
||||
|
||||
tap.test('should support seek and duration', async () => {
|
||||
const args = ffmpegInstance.create()
|
||||
.input('/input.mp4')
|
||||
.seek(10)
|
||||
.duration(30)
|
||||
.output('/output.mp4')
|
||||
.getArgs('/output.mp4');
|
||||
|
||||
expect(args).toContain('-ss');
|
||||
expect(args).toContain('-t');
|
||||
console.log('Seek and duration work correctly');
|
||||
});
|
||||
|
||||
// ==================== WEB STREAMS API TESTS ====================
|
||||
|
||||
tap.test('should return Web ReadableStream from toStream()', async () => {
|
||||
// Just verify the method signature and types - don't execute
|
||||
const command = ffmpegInstance.input('/input.mp4')
|
||||
.videoCodec('libx264')
|
||||
.format('mp4');
|
||||
|
||||
// Verify getArgs works (doesn't execute ffmpeg)
|
||||
const args = command.getArgs('pipe:1');
|
||||
expect(args).toContain('-f');
|
||||
expect(args).toContain('mp4');
|
||||
console.log('toStream() method returns correct type signature');
|
||||
});
|
||||
|
||||
tap.test('should accept Uint8Array input', async () => {
|
||||
const uint8Input = new Uint8Array([0, 1, 2, 3, 4]); // Dummy data
|
||||
const command = ffmpegInstance.input(uint8Input, { format: 'mp4' })
|
||||
.videoCodec('libx264')
|
||||
.format('webm');
|
||||
|
||||
// Just verify args are built correctly for memory input
|
||||
const args = command.getArgs('pipe:1');
|
||||
expect(args).toContain('-i');
|
||||
expect(args).toContain('pipe:0'); // Memory input uses stdin
|
||||
console.log('Uint8Array input accepted correctly');
|
||||
});
|
||||
|
||||
tap.test('should build correct args for memory output', async () => {
|
||||
const args = ffmpegInstance.input('/input.mp4')
|
||||
.videoCodec('libx264')
|
||||
.format('webm')
|
||||
.getArgs('pipe:1');
|
||||
|
||||
expect(args).toContain('-f');
|
||||
expect(args).toContain('webm');
|
||||
expect(args).toContain('pipe:1');
|
||||
console.log('Memory output args built correctly');
|
||||
});
|
||||
|
||||
// ==================== LEGACY API TESTS ====================
|
||||
|
||||
tap.test('should get available encoders', async () => {
|
||||
const encoders = await ffmpegInstance.getEncoders();
|
||||
expect(encoders).toBeArray();
|
||||
expect(encoders.length).toBeGreaterThan(0);
|
||||
// Common encoders should be available
|
||||
expect(encoders).toContain('libx264');
|
||||
console.log(`Found ${encoders.length} encoders`);
|
||||
});
|
||||
|
||||
tap.test('should get available decoders', async () => {
|
||||
const decoders = await ffmpegInstance.getDecoders();
|
||||
expect(decoders).toBeArray();
|
||||
expect(decoders.length).toBeGreaterThan(0);
|
||||
console.log(`Found ${decoders.length} decoders`);
|
||||
});
|
||||
|
||||
tap.test('should get available formats', async () => {
|
||||
const formats = await ffmpegInstance.getFormats();
|
||||
expect(formats).toBeArray();
|
||||
expect(formats.length).toBeGreaterThan(0);
|
||||
// Common formats should be available
|
||||
expect(formats).toContain('mp4');
|
||||
expect(formats).toContain('mp3');
|
||||
console.log(`Found ${formats.length} formats`);
|
||||
});
|
||||
|
||||
tap.test('should run raw ffmpeg command', async () => {
|
||||
const result = await ffmpegInstance.runRaw(['-version']);
|
||||
expect(result.stdout).toInclude('ffmpeg');
|
||||
console.log('FFmpeg version info retrieved successfully');
|
||||
});
|
||||
|
||||
tap.test('should run raw ffprobe command', async () => {
|
||||
const result = await ffmpegInstance.runProbeRaw(['-version']);
|
||||
expect(result.stdout).toInclude('ffprobe');
|
||||
console.log('FFprobe version info retrieved successfully');
|
||||
});
|
||||
|
||||
// IMPORTANT: Always end with tap.start()
|
||||
export default tap.start();
|
||||
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;
|
||||
11
tsconfig.json
Normal file
11
tsconfig.json
Normal file
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"module": "NodeNext",
|
||||
"moduleResolution": "NodeNext",
|
||||
"esModuleInterop": true,
|
||||
"verbatimModuleSyntax": true,
|
||||
"paths": {}
|
||||
},
|
||||
"exclude": ["dist_*/**/*.d.ts"]
|
||||
}
|
||||
Reference in New Issue
Block a user