This commit is contained in:
2025-12-11 23:03:14 +00:00
commit 3906df1502
27 changed files with 12769 additions and 0 deletions

2
.gitignore vendored Normal file
View File

@@ -0,0 +1,2 @@
.nogit/
node_modules/

8
dist_ts/00_commitinfo_data.d.ts vendored Normal file
View File

@@ -0,0 +1,8 @@
/**
* autocreated commitinfo by @push.rocks/commitinfo
*/
export declare const commitinfo: {
name: string;
version: string;
description: string;
};

View 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
View 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;
}

File diff suppressed because one or more lines are too long

185
dist_ts/classes.smartffmpeg.d.ts vendored Normal file
View 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;
}

File diff suppressed because one or more lines are too long

3
dist_ts/index.d.ts vendored Normal file
View 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
View 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
View 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
View File

@@ -0,0 +1,2 @@
export {};
//# sourceMappingURL=data:application/json;base64,eyJ2ZXJzaW9uIjozLCJmaWxlIjoiaW50ZXJmYWNlcy5qcyIsInNvdXJjZVJvb3QiOiIiLCJzb3VyY2VzIjpbIi4uL3RzL2ludGVyZmFjZXMudHMiXSwibmFtZXMiOltdLCJtYXBwaW5ncyI6IiJ9

9
dist_ts/plugins.d.ts vendored Normal file
View 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
View 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
View 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
View 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
View 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

File diff suppressed because it is too large Load Diff

70
readme.hints.md Normal file
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View File

@@ -0,0 +1,11 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "NodeNext",
"moduleResolution": "NodeNext",
"esModuleInterop": true,
"verbatimModuleSyntax": true,
"paths": {}
},
"exclude": ["dist_*/**/*.d.ts"]
}