n8n-nodes-mediafx
Version:
N8N custom nodes for video editing and media processing
246 lines (245 loc) • 11.7 kB
JavaScript
;
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
if (k2 === undefined) k2 = k;
var desc = Object.getOwnPropertyDescriptor(m, k);
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
desc = { enumerable: true, get: function() { return m[k]; } };
}
Object.defineProperty(o, k2, desc);
}) : (function(o, m, k, k2) {
if (k2 === undefined) k2 = k;
o[k2] = m[k];
}));
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
Object.defineProperty(o, "default", { enumerable: true, value: v });
}) : function(o, v) {
o["default"] = v;
});
var __importStar = (this && this.__importStar) || function (mod) {
if (mod && mod.__esModule) return mod;
var result = {};
if (mod != null) for (var k in mod) if (k !== "default" && Object.prototype.hasOwnProperty.call(mod, k)) __createBinding(result, mod, k);
__setModuleDefault(result, mod);
return result;
};
Object.defineProperty(exports, "__esModule", { value: true });
exports.executeMultiVideoTransition = void 0;
const n8n_workflow_1 = require("n8n-workflow");
const ffmpeg = require("fluent-ffmpeg");
const utils_1 = require("../utils");
const ffmpegVersion_1 = require("../utils/ffmpegVersion");
const fs = __importStar(require("fs-extra"));
async function executeMultiVideoTransition(inputs, transition, duration, outputFormat, itemIndex) {
if (inputs.length < 2) {
throw new n8n_workflow_1.NodeOperationError(this.getNode(), 'Transition (Apply) operation requires at least two source videos.', { itemIndex });
}
// Check if the requested transition is supported
const transitionSupport = await (0, ffmpegVersion_1.checkTransitionSupport)(transition);
let effectiveTransition = transition;
if (!transitionSupport.supported) {
if (transitionSupport.alternative) {
effectiveTransition = transitionSupport.alternative;
console.warn(transitionSupport.message);
}
else {
throw new n8n_workflow_1.NodeOperationError(this.getNode(), transitionSupport.message || 'Unsupported transition effect', { itemIndex });
}
}
const outputPath = (0, utils_1.getTempFile)(`.${outputFormat}`);
const command = ffmpeg();
inputs.forEach((input) => command.addInput(input));
const durations = await Promise.all(inputs.map((input) => (0, utils_1.getDuration)(input)));
if (durations.some((d) => d === null)) {
throw new n8n_workflow_1.NodeOperationError(this.getNode(), 'Could not get duration of one or more videos.', {
itemIndex,
});
}
// Check if all input videos have audio
const audioChecks = await Promise.all(inputs.map((input) => (0, utils_1.fileHasAudio)(input)));
const allHaveAudio = audioChecks.every((hasAudio) => hasAudio);
const someHaveAudio = audioChecks.some((hasAudio) => hasAudio);
// Warn if only some videos have audio
if (someHaveAudio && !allHaveAudio) {
console.warn('Warning: Not all input videos have audio tracks. Audio will be excluded from the output.');
}
// Get video dimensions using ffprobe to determine common resolution
const getDimensions = async (inputPath) => {
return new Promise((resolve, reject) => {
ffmpeg.ffprobe(inputPath, (err, metadata) => {
if (err) {
reject(err);
return;
}
const videoStream = metadata.streams.find(s => s.codec_type === 'video');
if (!videoStream || !videoStream.width || !videoStream.height) {
reject(new Error('Could not get video dimensions'));
return;
}
resolve({ width: videoStream.width, height: videoStream.height });
});
});
};
// Get dimensions of all input videos
const dimensions = await Promise.all(inputs.map(input => getDimensions(input)));
// Find the maximum dimensions to use as target resolution
const targetWidth = Math.max(...dimensions.map(d => d.width));
const targetHeight = Math.max(...dimensions.map(d => d.height));
console.log(`[MediaFX] Target resolution for transition: ${targetWidth}x${targetHeight}`);
// Initialize video streams with scaling to common resolution
const filterGraph = inputs.map((_, i) => {
const dim = dimensions[i];
if (dim.width !== targetWidth || dim.height !== targetHeight) {
// Scale and pad to maintain aspect ratio
return `[${i}:v]scale=${targetWidth}:${targetHeight}:force_original_aspect_ratio=decrease,pad=${targetWidth}:${targetHeight}:(ow-iw)/2:(oh-ih)/2:black,settb=AVTB[v${i}]`;
}
else {
// No scaling needed
return `[${i}:v]settb=AVTB[v${i}]`;
}
});
// Initialize audio streams only if audio exists
if (allHaveAudio) {
inputs.forEach((_, i) => {
filterGraph.push(`[${i}:a]aformat=sample_fmts=fltp:sample_rates=44100:channel_layouts=stereo[a${i}]`);
});
}
let lastVideoOut = 'v0';
let lastAudioOut = 'a0';
let cumulativeDuration = durations[0];
// Use different filter strategies based on transition type
if (effectiveTransition === 'fade' || effectiveTransition === 'fadeblack' || effectiveTransition === 'fadewhite') {
// Fallback implementation for fade transitions without xfade
// Simply concatenate videos with fade effects applied to each segment
const videoSegments = [];
const audioSegments = [];
for (let i = 0; i < inputs.length; i++) {
const videoDuration = durations[i];
const isFirst = i === 0;
const isLast = i === inputs.length - 1;
const videoSegment = `vseg${i}`;
if (isFirst && isLast) {
// Only one video - no transitions needed
videoSegments.push(`v${i}`);
}
else if (isFirst) {
// First video: fade out at the end
const fadeOutStart = videoDuration - duration;
filterGraph.push(`[v${i}]fade=t=out:st=${fadeOutStart}:d=${duration}[${videoSegment}]`);
videoSegments.push(videoSegment);
}
else if (isLast) {
// Last video: fade in at the beginning
filterGraph.push(`[v${i}]fade=t=in:st=0:d=${duration}[${videoSegment}]`);
videoSegments.push(videoSegment);
}
else {
// Middle videos: fade in at start, fade out at end
const fadeOutStart = videoDuration - duration;
filterGraph.push(`[v${i}]fade=t=in:st=0:d=${duration},fade=t=out:st=${fadeOutStart}:d=${duration}[${videoSegment}]`);
videoSegments.push(videoSegment);
}
}
// Concatenate all video segments
if (videoSegments.length === 1) {
lastVideoOut = videoSegments[0];
}
else {
lastVideoOut = 'vconcat';
const concatFilter = videoSegments.map(seg => `[${seg}]`).join('') +
`concat=n=${videoSegments.length}:v=1:a=0[${lastVideoOut}]`;
filterGraph.push(concatFilter);
}
// Handle audio if it exists
if (allHaveAudio) {
for (let i = 0; i < inputs.length; i++) {
const audioDuration = durations[i];
const isFirst = i === 0;
const isLast = i === inputs.length - 1;
const audioSegment = `aseg${i}`;
if (isFirst && isLast) {
audioSegments.push(`a${i}`);
}
else if (isFirst) {
const fadeOutStart = audioDuration - duration;
filterGraph.push(`[a${i}]afade=t=out:st=${fadeOutStart}:d=${duration}[${audioSegment}]`);
audioSegments.push(audioSegment);
}
else if (isLast) {
filterGraph.push(`[a${i}]afade=t=in:st=0:d=${duration}[${audioSegment}]`);
audioSegments.push(audioSegment);
}
else {
const fadeOutStart = audioDuration - duration;
filterGraph.push(`[a${i}]afade=t=in:st=0:d=${duration},afade=t=out:st=${fadeOutStart}:d=${duration}[${audioSegment}]`);
audioSegments.push(audioSegment);
}
}
// Concatenate all audio segments
if (audioSegments.length === 1) {
lastAudioOut = audioSegments[0];
}
else {
lastAudioOut = 'aconcat';
const audioConcatFilter = audioSegments.map(seg => `[${seg}]`).join('') +
`concat=n=${audioSegments.length}:v=0:a=1[${lastAudioOut}]`;
filterGraph.push(audioConcatFilter);
}
}
}
else {
// Use xfade for supported transitions
for (let i = 1; i < inputs.length; i++) {
const nextVideo = `v${i}`;
const nextAudio = `a${i}`;
const currentVideoOut = `vout${i}`;
const currentAudioOut = `aout${i}`;
const offset = cumulativeDuration - duration;
filterGraph.push(`[${lastVideoOut}][${nextVideo}]xfade=transition=${effectiveTransition}:duration=${duration}:offset=${offset}[${currentVideoOut}]`);
if (allHaveAudio) {
filterGraph.push(`[${lastAudioOut}][${nextAudio}]acrossfade=d=${duration}[${currentAudioOut}]`);
}
lastVideoOut = currentVideoOut;
if (allHaveAudio) {
lastAudioOut = currentAudioOut;
}
cumulativeDuration += durations[i] - duration;
}
}
// Build output options based on available streams
const outputOptions = ['-map', `[${lastVideoOut}]`];
if (allHaveAudio) {
outputOptions.push('-map', `[${lastAudioOut}]`);
}
command
.complexFilter(filterGraph)
.outputOptions(outputOptions)
.videoCodec('libx264');
if (allHaveAudio) {
command.audioCodec('aac');
}
command.save(outputPath);
try {
await (0, utils_1.runFfmpeg)(command);
// If we used a fallback transition, add a note to the result
if (effectiveTransition !== transition && transitionSupport.message) {
console.log(`Transition fallback: ${transitionSupport.message}`);
}
return outputPath;
}
catch (error) {
// Clean up output file if creation failed
await fs.remove(outputPath).catch(() => { });
const errorMessage = error.message;
let helpfulMessage = 'Error applying transition.';
// Check for specific error patterns
if (errorMessage.includes('No such filter: \'xfade\'')) {
helpfulMessage = `Your FFmpeg version doesn't support the 'xfade' filter (requires FFmpeg 4.3+). ` +
`The transition '${transition}' requires xfade. Please upgrade FFmpeg or use basic transitions like 'fade'.`;
}
else if (errorMessage.includes('Invalid argument')) {
helpfulMessage = `Invalid transition parameters. The '${transition}' effect may not be supported by your FFmpeg version.`;
}
throw new n8n_workflow_1.NodeOperationError(this.getNode(), `${helpfulMessage} FFmpeg error: ${errorMessage}`, { itemIndex });
}
}
exports.executeMultiVideoTransition = executeMultiVideoTransition;