ffmpeg-toolkit
Version:
A modern FFmpeg toolkit for Node.js
301 lines • 13.8 kB
JavaScript
export class VideoModule {
constructor(base) {
this.base = base;
}
calculateCropFilters(inputWidth, inputHeight, targetWidth, targetHeight) {
try {
if (inputWidth === undefined ||
inputHeight === undefined ||
targetWidth === undefined ||
targetHeight === undefined ||
isNaN(inputWidth) ||
isNaN(inputHeight) ||
isNaN(targetWidth) ||
isNaN(targetHeight)) {
throw new Error('Invalid input dimensions');
}
if (inputWidth <= 0 || inputHeight <= 0 || targetWidth <= 0 || targetHeight <= 0) {
throw new Error('Dimensions must be positive numbers');
}
const aspectRatio = targetWidth / targetHeight;
const inputAspectRatio = inputWidth / inputHeight;
if (Math.abs(inputAspectRatio - aspectRatio) < 0.001) {
return {
scaleFilter: `scale=${targetWidth}:${targetHeight},setsar=1:1`,
cropFilter: '',
};
}
const isLandscapeToPortrait = inputAspectRatio > aspectRatio;
const scaledHeight = isLandscapeToPortrait
? targetHeight
: Math.round(targetWidth / inputAspectRatio);
const scaledWidth = isLandscapeToPortrait
? Math.round(scaledHeight * inputAspectRatio)
: targetWidth;
const scaleFilter = `scale=${scaledWidth}:${scaledHeight},setsar=1:1`;
const cropFilter = isLandscapeToPortrait
? `crop=${targetWidth}:${targetHeight}:${Math.floor((scaledWidth - targetWidth) / 2)}:0`
: `crop=${targetWidth}:${targetHeight}:0:${Math.floor((scaledHeight - targetHeight) / 2)}`;
return { scaleFilter, cropFilter };
}
catch (error) {
this.base.handleModuleError(error, 'Failed to calculate crop filters');
}
}
async cropVideo(options) {
try {
const { width, height, duration: durationOptions, inputPath } = options;
const videoInfo = await this.base.getVideoInfoPublic(inputPath);
const durationVideo = durationOptions && durationOptions < (Number(videoInfo.duration) ?? 0)
? durationOptions
: Number(videoInfo.duration) ?? 0;
const filters = [];
if (width !== videoInfo.width || height !== videoInfo.height) {
const { scaleFilter, cropFilter } = this.calculateCropFilters(videoInfo.width ?? 0, videoInfo.height ?? 0, width, height);
if (scaleFilter) {
filters.push(scaleFilter);
}
if (cropFilter) {
filters.push(cropFilter);
}
}
return await this.base.process({
callback: () => {
const command = this.base.ffmpeg(inputPath).noAudio().setDuration(durationVideo);
if (filters.length > 0) {
command.videoFilters(filters);
}
return command;
},
data: {
pathOutput: options.pathOutput,
},
inputPath: options.inputPath,
pathOutput: options.pathOutput,
isDefaultOptions: false,
});
}
catch (error) {
this.base.handleModuleError(error, 'Failed to crop video');
}
}
async convertVideoToPlatform(options) {
try {
return await this.base.process({
callback: () => {
const command = this.base.ffmpeg();
command.input(options.inputPath);
return command.output(options.pathOutput);
},
data: {
pathOutput: options.pathOutput,
},
inputPath: options.inputPath,
platform: options.platform,
});
}
catch (error) {
this.base.handleModuleError(error, 'Failed to convert video to platform');
}
}
async concatVideos(options) {
try {
const { inputPaths, isNoAudio = false, pathOutput } = options;
return await this.base.process({
callback: () => {
const command = this.base.ffmpeg();
inputPaths.forEach((path) => {
command.input(path);
});
const n = inputPaths.length;
// Build filter complex string
const filters = [];
// Scale and pad video streams
const scaleFilters = Array.from({ length: n }, (_, i) => `[${i}:v]scale=1080:1920:force_original_aspect_ratio=decrease,pad=1080:1920:(ow-iw)/2:(oh-ih)/2[v${i}]`);
filters.push(scaleFilters.join(';'));
if (!isNoAudio) {
const audioInputs = Array.from({ length: n }, (_, i) => `[${i}:a]`).join('');
filters.push(`${audioInputs}concat=n=${n}:v=0:a=1[aout]`);
}
const concatInputs = Array.from({ length: n }, (_, i) => `[v${i}]`).join('');
filters.push(`${concatInputs}concat=n=${n}:v=1[outv]`);
command.complexFilter(filters.join(';'));
command.map('[outv]');
if (!isNoAudio) {
command.map('[aout]');
}
return command.output(pathOutput);
},
data: {
pathOutput,
},
isDefaultOptions: false,
});
}
catch (error) {
this.base.handleModuleError(error, 'Failed to concat videos');
}
}
async getVideoDuration(inputPath) {
try {
return new Promise((resolve, reject) => {
this.base
.ffmpeg()
.input(inputPath)
.ffprobe((err, data) => {
if (err)
reject(err);
resolve(data.format.duration || 0);
});
});
}
catch (error) {
this.base.handleModuleError(error, 'Failed to get video duration');
}
}
async concatVideosWithTransition(options) {
try {
const transitionDuration = options.transitionDuration || 1;
const transitionType = options.transitionType || 'fade';
let filterComplex = '';
switch (transitionType) {
case 'fade': {
const trans = transitionDuration;
const durations = await Promise.all(options.inputPaths.map((path) => this.getVideoDuration(path)));
const steps = [];
const concatInputs = [];
let labelIndex = 0;
for (let i = 0; i < options.inputPaths.length; i++) {
const d = durations[i];
const dStr = d.toFixed(3);
const startFade = Math.max(0, d - trans).toFixed(3);
steps.push(`[${i}:v]trim=0:${startFade},setpts=PTS-STARTPTS,format=yuv420p[v${labelIndex}]`);
concatInputs.push(`[v${labelIndex}]`);
labelIndex++;
if (i < options.inputPaths.length - 1) {
steps.push(`[${i}:v]trim=${startFade}:${dStr},setpts=PTS-STARTPTS,fade=t=out:st=0:d=${trans}[v${labelIndex}]`);
concatInputs.push(`[v${labelIndex}]`);
labelIndex++;
steps.push(`[${i + 1}:v]trim=0:${trans},setpts=PTS-STARTPTS,fade=t=in:st=0:d=${trans}[v${labelIndex}]`);
concatInputs.push(`[v${labelIndex}]`);
labelIndex++;
}
else {
steps.push(`[${i}:v]trim=${startFade}:${dStr},setpts=PTS-STARTPTS[v${labelIndex}]`);
concatInputs.push(`[v${labelIndex}]`);
labelIndex++;
}
}
filterComplex = `
${steps.join(';')}
;
${concatInputs.join('')}
concat=n=${concatInputs.length}:v=1[outv]
`;
break;
}
case 'zoom':
filterComplex =
options.inputPaths
.map((_, i) => {
const input = `[${i}:v]`;
if (i === 0)
return `${input}[v${i}]`;
return `${input}scale=iw*1.5:ih*1.5,zoompan=z='min(zoom+0.0015,1.5)':d=125:x='iw/2-(iw/zoom/2)':y='ih/2-(ih/zoom/2)':s=1080x1918[v${i}]`;
})
.join(';') +
';' +
options.inputPaths.map((_, i) => `[v${i}]`).join('') +
`concat=n=${options.inputPaths.length}:v=1[outv]`;
break;
case 'rotate':
filterComplex =
options.inputPaths
.map((_, i) => {
const input = `[${i}:v]`;
if (i === 0)
return `${input}[v${i}]`;
return `${input}rotate=angle='if(lt(t,${transitionDuration}),t*360/${transitionDuration},360)':fillcolor=black[v${i}]`;
})
.join(';') +
';' +
options.inputPaths.map((_, i) => `[v${i}]`).join('') +
`concat=n=${options.inputPaths.length}:v=1[outv]`;
break;
case 'pixelate':
filterComplex =
options.inputPaths
.map((_, i) => {
const input = `[${i}:v]`;
if (i === 0)
return `${input}[v${i}]`;
return `${input}scale=iw/10:ih/10,scale=iw*10:ih*10:flags=neighbor[v${i}]`;
})
.join(';') +
';' +
options.inputPaths.map((_, i) => `[v${i}]`).join('') +
`concat=n=${options.inputPaths.length}:v=1[outv]`;
break;
case 'wipe':
filterComplex =
options.inputPaths
.map((_, i) => {
const input = `[${i}:v]`;
if (i === 0)
return `${input}[v${i}]`;
return `${input}transpose=1,transpose=2[v${i}]`;
})
.join(';') +
';' +
options.inputPaths.map((_, i) => `[v${i}]`).join('') +
`concat=n=${options.inputPaths.length}:v=1[outv]`;
break;
case 'dissolve':
filterComplex =
options.inputPaths
.map((_, i) => {
const input = `[${i}:v]`;
if (i === 0)
return `${input}[v${i}]`;
return `${input}format=rgba,colorchannelmixer=aa=0.5[v${i}]`;
})
.join(';') +
';' +
options.inputPaths.map((_, i) => `[v${i}]`).join('') +
`concat=n=${options.inputPaths.length}:v=1[outv]`;
break;
case 'slide':
filterComplex =
options.inputPaths
.map((_, i) => {
const input = `[${i}:v]`;
if (i === 0)
return `${input}[v${i}]`;
return `${input}crop=iw:ih:0:0,pad=iw:ih*2:0:0[v${i}]`;
})
.join(';') +
';' +
options.inputPaths.map((_, i) => `[v${i}]`).join('') +
`concat=n=${options.inputPaths.length}:v=1[outv]`;
break;
}
filterComplex = filterComplex.replace(/\s+/g, '').trim();
return await this.base.process({
callback: () => {
const command = this.base.ffmpeg();
options.inputPaths.forEach((path) => {
command.input(path);
});
return command.complexFilter(filterComplex).map('[outv]').output(options.pathOutput);
},
data: {
pathOutput: options.pathOutput,
},
});
}
catch (error) {
this.base.handleModuleError(error, 'Failed to concat videos with transition');
}
}
}
//# sourceMappingURL=video.js.map