731 lines
45 KiB
JavaScript
731 lines
45 KiB
JavaScript
|
|
import * as plugins from './plugins.js';
|
||
|
|
import { Readable, Writable } from 'stream';
|
||
|
|
/**
|
||
|
|
* 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 {
|
||
|
|
ffmpegPath;
|
||
|
|
ffprobePath;
|
||
|
|
// Input configuration
|
||
|
|
inputSource = null;
|
||
|
|
inputOpts = {};
|
||
|
|
customInputArgs = [];
|
||
|
|
// Output configuration
|
||
|
|
outputDest = null;
|
||
|
|
outputFormat = null;
|
||
|
|
customOutputArgs = [];
|
||
|
|
// Video settings
|
||
|
|
_videoCodec = null;
|
||
|
|
_videoBitrate = null;
|
||
|
|
_width = null;
|
||
|
|
_height = null;
|
||
|
|
_fps = null;
|
||
|
|
_crf = null;
|
||
|
|
_preset = null;
|
||
|
|
_noVideo = false;
|
||
|
|
// Audio settings
|
||
|
|
_audioCodec = null;
|
||
|
|
_audioBitrate = null;
|
||
|
|
_sampleRate = null;
|
||
|
|
_audioChannels = null;
|
||
|
|
_noAudio = false;
|
||
|
|
// Timing
|
||
|
|
_startTime = null;
|
||
|
|
_duration = null;
|
||
|
|
// Video filters
|
||
|
|
videoFilters = [];
|
||
|
|
// Audio filters
|
||
|
|
audioFilters = [];
|
||
|
|
// Complex filter
|
||
|
|
_complexFilter = null;
|
||
|
|
// Extra arguments
|
||
|
|
extraInputArgs = [];
|
||
|
|
extraOutputArgs = [];
|
||
|
|
// Callbacks
|
||
|
|
progressCallback = null;
|
||
|
|
// Options
|
||
|
|
_overwrite = true;
|
||
|
|
constructor(ffmpegPath, ffprobePath) {
|
||
|
|
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, options = {}) {
|
||
|
|
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) {
|
||
|
|
this._startTime = time;
|
||
|
|
return this;
|
||
|
|
}
|
||
|
|
/**
|
||
|
|
* Add custom input arguments
|
||
|
|
*/
|
||
|
|
inputArgs(...args) {
|
||
|
|
this.extraInputArgs.push(...args);
|
||
|
|
return this;
|
||
|
|
}
|
||
|
|
// ==================== VIDEO METHODS ====================
|
||
|
|
/**
|
||
|
|
* Set video codec
|
||
|
|
*/
|
||
|
|
videoCodec(codec) {
|
||
|
|
this._videoCodec = codec;
|
||
|
|
return this;
|
||
|
|
}
|
||
|
|
/**
|
||
|
|
* Set video bitrate
|
||
|
|
* @param bitrate - e.g., '1M', '2000k'
|
||
|
|
*/
|
||
|
|
videoBitrate(bitrate) {
|
||
|
|
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, height) {
|
||
|
|
this._width = width;
|
||
|
|
this._height = height ?? null;
|
||
|
|
return this;
|
||
|
|
}
|
||
|
|
/**
|
||
|
|
* Set frame rate
|
||
|
|
*/
|
||
|
|
fps(rate) {
|
||
|
|
this._fps = rate;
|
||
|
|
return this;
|
||
|
|
}
|
||
|
|
/**
|
||
|
|
* Set Constant Rate Factor (quality)
|
||
|
|
* @param value - 0-51, lower is better quality
|
||
|
|
*/
|
||
|
|
crf(value) {
|
||
|
|
this._crf = value;
|
||
|
|
return this;
|
||
|
|
}
|
||
|
|
/**
|
||
|
|
* Set encoding preset
|
||
|
|
*/
|
||
|
|
preset(value) {
|
||
|
|
this._preset = value;
|
||
|
|
return this;
|
||
|
|
}
|
||
|
|
/**
|
||
|
|
* Remove video stream
|
||
|
|
*/
|
||
|
|
noVideo() {
|
||
|
|
this._noVideo = true;
|
||
|
|
return this;
|
||
|
|
}
|
||
|
|
/**
|
||
|
|
* Add a video filter
|
||
|
|
* @param filter - Filter string (e.g., 'scale=1920:1080', 'hflip')
|
||
|
|
*/
|
||
|
|
videoFilter(filter) {
|
||
|
|
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, height) {
|
||
|
|
// 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, height, x = 0, y = 0) {
|
||
|
|
this.videoFilters.push(`crop=${width}:${height}:${x}:${y}`);
|
||
|
|
return this;
|
||
|
|
}
|
||
|
|
/**
|
||
|
|
* Rotate video
|
||
|
|
* @param angle - Rotation in radians or 'PI/2', 'PI', etc.
|
||
|
|
*/
|
||
|
|
rotate(angle) {
|
||
|
|
this.videoFilters.push(`rotate=${angle}`);
|
||
|
|
return this;
|
||
|
|
}
|
||
|
|
/**
|
||
|
|
* Flip video horizontally
|
||
|
|
*/
|
||
|
|
flipHorizontal() {
|
||
|
|
this.videoFilters.push('hflip');
|
||
|
|
return this;
|
||
|
|
}
|
||
|
|
/**
|
||
|
|
* Flip video vertically
|
||
|
|
*/
|
||
|
|
flipVertical() {
|
||
|
|
this.videoFilters.push('vflip');
|
||
|
|
return this;
|
||
|
|
}
|
||
|
|
/**
|
||
|
|
* Add padding to video
|
||
|
|
*/
|
||
|
|
pad(width, height, x = 0, y = 0, color = 'black') {
|
||
|
|
this.videoFilters.push(`pad=${width}:${height}:${x}:${y}:${color}`);
|
||
|
|
return this;
|
||
|
|
}
|
||
|
|
// ==================== AUDIO METHODS ====================
|
||
|
|
/**
|
||
|
|
* Set audio codec
|
||
|
|
*/
|
||
|
|
audioCodec(codec) {
|
||
|
|
this._audioCodec = codec;
|
||
|
|
return this;
|
||
|
|
}
|
||
|
|
/**
|
||
|
|
* Set audio bitrate
|
||
|
|
* @param bitrate - e.g., '128k', '320k'
|
||
|
|
*/
|
||
|
|
audioBitrate(bitrate) {
|
||
|
|
this._audioBitrate = bitrate;
|
||
|
|
return this;
|
||
|
|
}
|
||
|
|
/**
|
||
|
|
* Set audio sample rate
|
||
|
|
* @param rate - Sample rate in Hz (e.g., 44100, 48000)
|
||
|
|
*/
|
||
|
|
sampleRate(rate) {
|
||
|
|
this._sampleRate = rate;
|
||
|
|
return this;
|
||
|
|
}
|
||
|
|
/**
|
||
|
|
* Set number of audio channels
|
||
|
|
* @param channels - 1 for mono, 2 for stereo
|
||
|
|
*/
|
||
|
|
audioChannels(channels) {
|
||
|
|
this._audioChannels = channels;
|
||
|
|
return this;
|
||
|
|
}
|
||
|
|
/**
|
||
|
|
* Remove audio stream
|
||
|
|
*/
|
||
|
|
noAudio() {
|
||
|
|
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) {
|
||
|
|
this.audioFilters.push(filter);
|
||
|
|
return this;
|
||
|
|
}
|
||
|
|
/**
|
||
|
|
* Set audio volume
|
||
|
|
* @param level - Volume multiplier (e.g., 2 for double, 0.5 for half)
|
||
|
|
*/
|
||
|
|
volume(level) {
|
||
|
|
this.audioFilters.push(`volume=${level}`);
|
||
|
|
return this;
|
||
|
|
}
|
||
|
|
/**
|
||
|
|
* Normalize audio
|
||
|
|
*/
|
||
|
|
normalize() {
|
||
|
|
this.audioFilters.push('loudnorm');
|
||
|
|
return this;
|
||
|
|
}
|
||
|
|
// ==================== TIMING METHODS ====================
|
||
|
|
/**
|
||
|
|
* Set start time (seek)
|
||
|
|
* @param time - Time in seconds or timecode string
|
||
|
|
*/
|
||
|
|
seek(time) {
|
||
|
|
this._startTime = time;
|
||
|
|
return this;
|
||
|
|
}
|
||
|
|
/**
|
||
|
|
* Set output duration
|
||
|
|
* @param time - Duration in seconds or timecode string
|
||
|
|
*/
|
||
|
|
duration(time) {
|
||
|
|
this._duration = time;
|
||
|
|
return this;
|
||
|
|
}
|
||
|
|
/**
|
||
|
|
* Set both start and end time
|
||
|
|
*/
|
||
|
|
trim(start, end) {
|
||
|
|
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) {
|
||
|
|
this._complexFilter = filterGraph;
|
||
|
|
return this;
|
||
|
|
}
|
||
|
|
// ==================== OUTPUT METHODS ====================
|
||
|
|
/**
|
||
|
|
* Set output format
|
||
|
|
*/
|
||
|
|
format(fmt) {
|
||
|
|
this.outputFormat = fmt;
|
||
|
|
return this;
|
||
|
|
}
|
||
|
|
/**
|
||
|
|
* Set output destination (file path)
|
||
|
|
*/
|
||
|
|
output(dest) {
|
||
|
|
this.outputDest = dest;
|
||
|
|
return this;
|
||
|
|
}
|
||
|
|
/**
|
||
|
|
* Add custom output arguments
|
||
|
|
*/
|
||
|
|
outputArgs(...args) {
|
||
|
|
this.extraOutputArgs.push(...args);
|
||
|
|
return this;
|
||
|
|
}
|
||
|
|
/**
|
||
|
|
* Set overwrite flag
|
||
|
|
*/
|
||
|
|
overwrite(value = true) {
|
||
|
|
this._overwrite = value;
|
||
|
|
return this;
|
||
|
|
}
|
||
|
|
// ==================== CALLBACK METHODS ====================
|
||
|
|
/**
|
||
|
|
* Set progress callback
|
||
|
|
*/
|
||
|
|
onProgress(callback) {
|
||
|
|
this.progressCallback = callback;
|
||
|
|
return this;
|
||
|
|
}
|
||
|
|
// ==================== EXECUTION METHODS ====================
|
||
|
|
/**
|
||
|
|
* Run the command and output to file
|
||
|
|
*/
|
||
|
|
async run() {
|
||
|
|
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) {
|
||
|
|
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) {
|
||
|
|
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);
|
||
|
|
}
|
||
|
|
/**
|
||
|
|
* Pipe output to a Web WritableStream
|
||
|
|
* @param writable - Web WritableStream
|
||
|
|
* @param format - Output format
|
||
|
|
*/
|
||
|
|
async pipe(writable, format) {
|
||
|
|
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);
|
||
|
|
await webReadable.pipeTo(writable);
|
||
|
|
}
|
||
|
|
/**
|
||
|
|
* Get the ffmpeg arguments that would be used (for debugging)
|
||
|
|
*/
|
||
|
|
getArgs(outputPath = 'output') {
|
||
|
|
return this.buildArgs(outputPath);
|
||
|
|
}
|
||
|
|
// ==================== PRIVATE METHODS ====================
|
||
|
|
buildArgs(outputPath) {
|
||
|
|
const args = [];
|
||
|
|
// 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;
|
||
|
|
}
|
||
|
|
async execute(args) {
|
||
|
|
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) => {
|
||
|
|
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);
|
||
|
|
});
|
||
|
|
}
|
||
|
|
executeToBuffer(args) {
|
||
|
|
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 = [];
|
||
|
|
proc.stdout?.on('data', (chunk) => {
|
||
|
|
chunks.push(chunk);
|
||
|
|
});
|
||
|
|
// Handle progress on stderr
|
||
|
|
if (this.progressCallback && proc.stderr) {
|
||
|
|
this.parseProgress(proc.stderr, inputDuration);
|
||
|
|
}
|
||
|
|
let stderr = '';
|
||
|
|
proc.stderr?.on('data', (data) => {
|
||
|
|
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);
|
||
|
|
});
|
||
|
|
}
|
||
|
|
executeToNodeStream(args) {
|
||
|
|
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;
|
||
|
|
}
|
||
|
|
parseProgress(stderr, duration) {
|
||
|
|
let progressData = {};
|
||
|
|
stderr.on('data', (data) => {
|
||
|
|
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);
|
||
|
|
}
|
||
|
|
break;
|
||
|
|
}
|
||
|
|
}
|
||
|
|
});
|
||
|
|
}
|
||
|
|
async getInputDuration() {
|
||
|
|
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;
|
||
|
|
}
|
||
|
|
}
|
||
|
|
runProbe(args) {
|
||
|
|
return new Promise((resolve, reject) => {
|
||
|
|
const proc = plugins.child_process.spawn(this.ffprobePath, args);
|
||
|
|
let stdout = '';
|
||
|
|
let stderr = '';
|
||
|
|
proc.stdout?.on('data', (data) => {
|
||
|
|
stdout += data.toString();
|
||
|
|
});
|
||
|
|
proc.stderr?.on('data', (data) => {
|
||
|
|
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);
|
||
|
|
});
|
||
|
|
}
|
||
|
|
formatTime(time) {
|
||
|
|
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')}`;
|
||
|
|
}
|
||
|
|
isNodeStream(value) {
|
||
|
|
return value !== null &&
|
||
|
|
typeof value === 'object' &&
|
||
|
|
typeof value.pipe === 'function' &&
|
||
|
|
!(value instanceof ReadableStream);
|
||
|
|
}
|
||
|
|
isWebReadableStream(value) {
|
||
|
|
return value instanceof ReadableStream;
|
||
|
|
}
|
||
|
|
isMemoryInput() {
|
||
|
|
return Buffer.isBuffer(this.inputSource) ||
|
||
|
|
this.inputSource instanceof Uint8Array ||
|
||
|
|
this.isNodeStream(this.inputSource) ||
|
||
|
|
this.isWebReadableStream(this.inputSource);
|
||
|
|
}
|
||
|
|
pipeInputToProcess(proc) {
|
||
|
|
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);
|
||
|
|
nodeReadable.pipe(proc.stdin);
|
||
|
|
}
|
||
|
|
else if (this.isNodeStream(this.inputSource)) {
|
||
|
|
this.inputSource.pipe(proc.stdin);
|
||
|
|
}
|
||
|
|
else if (typeof this.inputSource === 'string') {
|
||
|
|
proc.stdin?.end();
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
//# sourceMappingURL=data:application/json;base64,eyJ2ZXJzaW9uIjozLCJmaWxlIjoiY2xhc3Nlcy5mZm1wZWdjb21tYW5kLmpzIiwic291cmNlUm9vdCI6IiIsInNvdXJjZXMiOlsiLi4vdHMvY2xhc3Nlcy5mZm1wZWdjb21tYW5kLnRzIl0sIm5hbWVzIjpbXSwibWFwcGluZ3MiOiJBQUFBLE9BQU8sS0FBSyxPQUFPLE1BQU0sY0FBYyxDQUFDO0FBRXhDLE9BQU8sRUFBRSxRQUFRLEVBQUUsUUFBUSxFQUFFLE1BQU0sUUFBUSxDQUFDO0FBa0Q1Qzs7Ozs7Ozs7Ozs7Ozs7Ozs7Ozs7Ozs7Ozs7O0dBMkJHO0FBQ0gsTUFBTSxPQUFPLGFBQWE7SUFDaEIsVUFBVSxDQUFTO0lBQ25CLFdBQVcsQ0FBUztJQUU1QixzQkFBc0I7SUFDZCxXQUFXLEdBQXdCLElBQUksQ0FBQztJQUN4QyxTQUFTLEdBQWtCLEVBQUUsQ0FBQztJQUM5QixlQUFlLEdBQWEsRUFBRSxDQUFDO0lBRXZDLHVCQUF1QjtJQUNmLFVBQVUsR0FBeUIsSUFBSSxDQUFDO0lBQ3hDLFlBQVksR0FBb0MsSUFBSSxDQUFDO0lBQ3JELGdCQUFnQixHQUFhLEVBQUUsQ0FBQztJQUV4QyxpQkFBaUI7SUFDVCxXQUFXLEdBQWtDLElBQUksQ0FBQztJQUNsRCxhQUFhLEdBQWtCLElBQUksQ0FBQztJQUNwQyxNQUFNLEdBQWtCLElBQUksQ0FBQztJQUM3QixPQUFPLEdBQWtCLElBQUksQ0FBQztJQUM5QixJQUFJLEdBQWtCLElBQUksQ0FBQztJQUMzQixJQUFJLEdBQWtCLElBQUksQ0FBQztJQUMzQixPQUFPLEdBQThCLElBQUksQ0FBQztJQUMxQyxRQUFRLEdBQUcsS0FBSyxDQUFDO0lBRXpCLGlCQUFpQjtJQUNULFdBQVcsR0FBa0MsSUFBSSxDQUFDO0lBQ2xELGFBQWEsR0FBa0IsSUFBSSxDQUFDO0lBQ3BDLFdBQVcsR0FBa0IsSUFBSSxDQUFDO0lBQ2xDLGNBQWMsR0FBa0IsSUFBSSxDQUFDO0lBQ3JDLFFBQVEsR0FBRyxLQUFLLENBQUM7SUFFekIsU0FBUztJQUNELFVBQVUsR0FBMkIsSUFBSSxD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
|