n8n-nodes-mediafx
Version:
N8N custom nodes for video editing and media processing
458 lines (457 loc) • 28.6 kB
JavaScript
"use strict";
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.MediaFX = void 0;
const n8n_workflow_1 = require("n8n-workflow");
const fs = __importStar(require("fs-extra"));
const path = __importStar(require("path"));
// import ffmpeg = require('fluent-ffmpeg');
const utils_1 = require("./utils");
const operations_1 = require("./operations");
const audio_properties_1 = require("./properties/audio.properties");
const font_properties_1 = require("./properties/font.properties");
const image_properties_1 = require("./properties/image.properties");
const resources_properties_1 = require("./properties/resources.properties");
const subtitle_properties_1 = require("./properties/subtitle.properties");
const video_properties_1 = require("./properties/video.properties");
// --- OPERATION EXECUTORS ---
// ALL OPERATION EXECUTORS MOVED TO ./operations/*
class MediaFX {
constructor() {
this.description = {
displayName: 'MediaFX',
name: 'mediaFX',
icon: 'file:mediafx.png',
group: ['transform'],
version: 1,
description: 'Process videos, audio, and media files with FFmpeg',
defaults: {
name: 'MediaFX',
},
inputs: ['main'],
outputs: ['main'],
// No credentials needed for local processing
properties: [
...resources_properties_1.resourceSelection,
...video_properties_1.videoProperties,
...audio_properties_1.audioProperties,
...subtitle_properties_1.subtitleProperties,
...image_properties_1.imageProperties,
...font_properties_1.fontProperties,
],
};
this.methods = {
loadOptions: {
// Load available fonts from API
async getFonts() {
try {
const allFonts = (0, utils_1.getAvailableFonts)();
return Object.entries(allFonts).map(([key, font]) => ({
name: `${font.name || key} (${font.type})`,
value: key,
description: font.description || '',
}));
}
catch (error) {
return [];
}
},
// Load available transition effects from a static list
async getTransitionEffects() {
// Mark effects that require FFmpeg 4.3+
const effects = [
{ name: 'Fade', value: 'fade', description: 'Works with all FFmpeg versions' },
{ name: 'Fade Black', value: 'fadeblack', description: 'Works with all FFmpeg versions' },
{ name: 'Fade White', value: 'fadewhite', description: 'Works with all FFmpeg versions' },
{ name: 'Wipe Left', value: 'wipeleft', description: 'Requires FFmpeg 4.3+' },
{ name: 'Wipe Right', value: 'wiperight', description: 'Requires FFmpeg 4.3+' },
{ name: 'Wipe Up', value: 'wipeup', description: 'Requires FFmpeg 4.3+' },
{ name: 'Wipe Down', value: 'wipedown', description: 'Requires FFmpeg 4.3+' },
{ name: 'Slide Left', value: 'slideleft', description: 'Requires FFmpeg 4.3+' },
{ name: 'Slide Right', value: 'slideright', description: 'Requires FFmpeg 4.3+' },
{ name: 'Slide Up', value: 'slideup', description: 'Requires FFmpeg 4.3+' },
{ name: 'Slide Down', value: 'slidedown', description: 'Requires FFmpeg 4.3+' },
{ name: 'Circle Crop', value: 'circlecrop', description: 'Requires FFmpeg 4.3+' },
{ name: 'Rect Crop', value: 'rectcrop', description: 'Requires FFmpeg 4.3+' },
{ name: 'Distance', value: 'distance', description: 'Requires FFmpeg 4.3+' },
{ name: 'Fade Grayscale', value: 'fadegrays', description: 'Requires FFmpeg 4.3+' },
{ name: 'Radial', value: 'radial', description: 'Requires FFmpeg 4.3+' },
{ name: 'Circle Open', value: 'circleopen', description: 'Requires FFmpeg 4.3+' },
{ name: 'Circle Close', value: 'circleclose', description: 'Requires FFmpeg 4.3+' },
{ name: 'Pixelize', value: 'pixelize', description: 'Requires FFmpeg 4.3+' },
{ name: 'Dissolve', value: 'dissolve', description: 'Requires FFmpeg 4.3+' },
{ name: 'Checkerboard', value: 'diagtl', description: 'Requires FFmpeg 4.3+' },
{ name: 'Box-in', value: 'boxin', description: 'Requires FFmpeg 4.3+' },
{ name: 'Iris', value: 'iris', description: 'Requires FFmpeg 4.3+' },
];
return effects;
},
async getUserFonts() {
try {
const userFonts = (0, utils_1.getUserFonts)();
return Object.entries(userFonts).map(([key, font]) => ({
name: `${font.name || key} (user)`,
value: key,
description: font.description || 'User uploaded font',
}));
}
catch (error) {
// This is optional, so return empty on error
return [];
}
},
},
};
}
async execute() {
const items = this.getInputData();
const returnData = [];
// Periodically clean up old temporary files (every 10th execution)
if (Math.random() < 0.1) {
(0, utils_1.cleanupOldTempFiles)(24).catch(() => {
// Ignore cleanup errors to avoid disrupting main operation
});
}
for (let i = 0; i < items.length; i++) {
let cleanup = async () => { }; // Initialize cleanup function for each iteration
let resource = ''; // Initialize resource variable for error handling
try {
resource = this.getNodeParameter('resource', i);
const operation = this.getNodeParameter('operation', i);
let resultData = null;
let outputPath = null;
// ===================================
// FONT RESOURCE OPERATIONS
// ===================================
if (resource === 'font') {
switch (operation) {
case 'list': {
const filterOptions = this.getNodeParameter('filterOptions', i, {});
const fontTypeFilter = filterOptions.fontType || 'all';
const allFonts = (0, utils_1.getAvailableFonts)();
if (fontTypeFilter === 'all') {
resultData = allFonts;
}
else {
resultData = Object.fromEntries(Object.entries(allFonts).filter(([, font]) => font.type === fontTypeFilter));
}
break;
}
case 'upload': {
const fontSource = this.getNodeParameter('fontSource', i);
const fontKey = this.getNodeParameter('fontKeyUpload', i);
const fontName = this.getNodeParameter('fontName', i, '');
const description = this.getNodeParameter('description', i, '');
let buffer;
let originalname;
if (fontSource === 'binary') {
const binaryProperty = this.getNodeParameter('binaryProperty', i);
const binaryData = items[i].binary;
if (!binaryData || !binaryData[binaryProperty]) {
throw new n8n_workflow_1.NodeOperationError(this.getNode(), `No binary data found in property '${binaryProperty}'`, { itemIndex: i });
}
buffer = await this.helpers.getBinaryDataBuffer(i, binaryProperty);
originalname = binaryData[binaryProperty].fileName || 'font.ttf';
}
else {
// filepath
const filePath = this.getNodeParameter('filePath', i);
if (!fs.existsSync(filePath)) {
throw new n8n_workflow_1.NodeOperationError(this.getNode(), `Font file not found at path: ${filePath}`, { itemIndex: i });
}
buffer = fs.readFileSync(filePath);
originalname = path.basename(filePath);
}
resultData = (0, utils_1.saveUserFont)(fontKey, fontName, description, originalname, buffer);
break;
}
case 'delete': {
const fontKey = this.getNodeParameter('fontKey', i);
(0, utils_1.deleteUserFont)(fontKey);
resultData = { message: `Font '${fontKey}' deleted successfully.` };
break;
}
}
}
// ===================================
// MEDIA RESOURCE OPERATIONS
// ===================================
else {
switch (operation) {
// Video Operations
case 'merge': {
const sourcesParam = this.getNodeParameter('videoSources', i, {});
const sourcesConfig = sourcesParam.sources || [];
const { paths, cleanup: c } = await (0, utils_1.resolveInputs)(this, i, sourcesConfig);
cleanup = c;
const mergeOutputFormat = this.getNodeParameter('videoOutputFormat', i);
outputPath = await operations_1.executeMerge.call(this, paths, mergeOutputFormat, i);
break;
}
case 'trim': {
const sourceParam = this.getNodeParameter('source', i, {});
const { paths, cleanup: c } = await (0, utils_1.resolveInputs)(this, i, [sourceParam.source]);
cleanup = c;
const startTime = this.getNodeParameter('startTime', i, 0);
const endTime = this.getNodeParameter('endTime', i, 10);
const outputFormat = this.getNodeParameter('videoOutputFormat', i, 'mp4');
outputPath = await operations_1.executeTrim.call(this, paths[0], startTime, endTime, outputFormat, i);
break;
}
// Audio Operations
case 'extract': {
const sourceParam = this.getNodeParameter('source', i, {});
const { paths, cleanup: c } = await (0, utils_1.resolveInputs)(this, i, [sourceParam]);
cleanup = c;
const extractFormat = this.getNodeParameter('audioOutputFormat', i);
const advancedOptions = this.getNodeParameter('advancedOptions', i, {});
const extractCodec = advancedOptions.audioCodec || 'copy';
const extractBitrate = advancedOptions.audioBitrate || '192k';
outputPath = await operations_1.executeExtractAudio.call(this, paths[0], extractFormat, extractCodec, extractBitrate, i);
break;
}
case 'mixAudio': {
// Construct source objects from flattened properties
const videoSourceType = this.getNodeParameter('mixVideoSourceType', i, 'url');
const videoSourceParam = {
sourceType: videoSourceType,
value: videoSourceType === 'url'
? this.getNodeParameter('mixVideoSourceUrl', i, '')
: '',
binaryProperty: videoSourceType === 'binary'
? this.getNodeParameter('mixVideoSourceBinary', i, 'data')
: '',
};
const audioSourceType = this.getNodeParameter('mixAudioSourceType', i, 'url');
const audioSourceParam = {
sourceType: audioSourceType,
value: audioSourceType === 'url'
? this.getNodeParameter('mixAudioSourceUrl', i, '')
: '',
binaryProperty: audioSourceType === 'binary'
? this.getNodeParameter('mixAudioSourceBinary', i, 'data')
: '',
};
const { paths: videoPaths, cleanup: videoCleanup } = await (0, utils_1.resolveInputs)(this, i, [
videoSourceParam,
]);
const { paths: audioPaths, cleanup: audioCleanup } = await (0, utils_1.resolveInputs)(this, i, [
audioSourceParam,
]);
cleanup = async () => {
await videoCleanup();
await audioCleanup();
};
const videoVol = this.getNodeParameter('videoVolume', i, 1.0);
const audioVol = this.getNodeParameter('audioVolume', i, 1.0);
const matchLength = this.getNodeParameter('matchLength', i, 'shortest');
// Get advanced mixing parameters directly
const enablePartialMix = this.getNodeParameter('enablePartialMix', i, false);
const advancedMixing = {
enablePartialMix,
startTime: enablePartialMix ? this.getNodeParameter('startTime', i, 0) : 0,
duration: enablePartialMix ? this.getNodeParameter('duration', i, undefined) : undefined,
loop: enablePartialMix ? this.getNodeParameter('loop', i, false) : false,
enableFadeIn: this.getNodeParameter('enableFadeIn', i, false),
fadeInDuration: this.getNodeParameter('fadeInDuration', i, 1),
enableFadeOut: this.getNodeParameter('enableFadeOut', i, false),
fadeOutDuration: this.getNodeParameter('fadeOutDuration', i, 1),
};
outputPath = await operations_1.executeMixAudio.call(this, videoPaths[0], audioPaths[0], videoVol, audioVol, matchLength, advancedMixing, i);
break;
}
// Subtitle Operations
case 'addSubtitle': {
const videoSourceParam = this.getNodeParameter('source', i);
const { paths: videoPaths, cleanup: videoCleanup } = await (0, utils_1.resolveInputs)(this, i, [videoSourceParam.source]);
const subFileParam = this.getNodeParameter('subtitleFileSource', i);
const { paths: subFilePaths, cleanup: subFileCleanup } = await (0, utils_1.resolveInputs)(this, i, [subFileParam.source]);
cleanup = async () => {
await videoCleanup();
await subFileCleanup();
};
// Collect style options from individual parameters
const style = {
fontKey: this.getNodeParameter('fontKey', i, 'noto-sans-kr'),
size: this.getNodeParameter('size', i, 48),
color: this.getNodeParameter('color', i, 'white'),
outlineWidth: this.getNodeParameter('outlineWidth', i, 1),
positionType: this.getNodeParameter('positionType', i, 'alignment'),
horizontalAlign: this.getNodeParameter('horizontalAlign', i, 'center'),
verticalAlign: this.getNodeParameter('verticalAlign', i, 'bottom'),
paddingX: this.getNodeParameter('paddingX', i, 20),
paddingY: this.getNodeParameter('paddingY', i, 20),
x: this.getNodeParameter('x', i, '(w-text_w)/2'),
y: this.getNodeParameter('y', i, 'h-th-50'),
};
outputPath = await operations_1.executeAddSubtitle.call(this, videoPaths[0], subFilePaths[0], style, i);
break;
}
case 'addText': {
const sourceParam = this.getNodeParameter('source', i);
const { paths, cleanup: c } = await (0, utils_1.resolveInputs)(this, i, [sourceParam.source]);
cleanup = c;
// Get text content and timing
const text = this.getNodeParameter('text', i, 'Hello, n8n!');
const startTime = this.getNodeParameter('startTime', i, 0);
const endTime = this.getNodeParameter('endTime', i, 5);
// Collect style options from individual parameters
const textOptions = {
fontKey: this.getNodeParameter('fontKey', i, 'noto-sans-kr'),
size: this.getNodeParameter('size', i, 48),
color: this.getNodeParameter('color', i, 'white'),
outlineWidth: this.getNodeParameter('outlineWidth', i, 1),
positionType: this.getNodeParameter('positionType', i, 'alignment'),
horizontalAlign: this.getNodeParameter('horizontalAlign', i, 'center'),
verticalAlign: this.getNodeParameter('verticalAlign', i, 'bottom'),
paddingX: this.getNodeParameter('paddingX', i, 20),
paddingY: this.getNodeParameter('paddingY', i, 20),
x: this.getNodeParameter('x', i, '(w-text_w)/2'),
y: this.getNodeParameter('y', i, 'h-th-10'),
startTime,
endTime,
};
outputPath = await operations_1.executeAddText.call(this, paths[0], text, textOptions, i);
break;
}
case 'multiTransition': {
const sourcesParam = this.getNodeParameter('transitionSources', i, {});
const sourcesConfig = sourcesParam.sources || [];
const { paths, cleanup: c } = await (0, utils_1.resolveInputs)(this, i, sourcesConfig);
cleanup = c;
const transitionEffect = this.getNodeParameter('transitionEffect', i);
const transitionDuration = this.getNodeParameter('transitionDuration', i);
const transitionOutputFormat = this.getNodeParameter('transitionOutputFormat', i, 'mp4');
outputPath = await operations_1.executeMultiVideoTransition.call(this, paths, transitionEffect, transitionDuration, transitionOutputFormat, i);
break;
}
case 'singleFade': {
const sourceParam = this.getNodeParameter('fadeSource', i);
const { paths, cleanup: c } = await (0, utils_1.resolveInputs)(this, i, [sourceParam.source]);
cleanup = c;
const fadeEffect = this.getNodeParameter('fadeEffect', i);
const fadeStartTime = this.getNodeParameter('fadeStartTime', i);
const fadeDuration = this.getNodeParameter('fadeDuration', i);
const outputFormat = this.getNodeParameter('transitionOutputFormat', i, 'mp4');
outputPath = await operations_1.executeSingleVideoFade.call(this, paths[0], fadeEffect, fadeStartTime, fadeDuration, outputFormat, i);
break;
}
case 'imageToVideo': {
const sourceParam = this.getNodeParameter('sourceImage', i, {});
const { paths, cleanup: c } = await (0, utils_1.resolveInputs)(this, i, [sourceParam.source]);
cleanup = c;
const duration = this.getNodeParameter('duration', i, 10);
const videoSize = this.getNodeParameter('videoSize', i);
const outputFormat = this.getNodeParameter('imageOutputFormat', i, 'mp4');
outputPath = await operations_1.executeImageToVideo.call(this, paths[0], duration, videoSize, outputFormat, i);
break;
}
case 'stampImage': {
const sourceVideo = this.getNodeParameter('sourceVideo.source', i);
const stampImage = this.getNodeParameter('stampImage.source', i);
// Get individual stamp options
const stampOptions = {
width: this.getNodeParameter('width', i, 150),
height: this.getNodeParameter('height', i, -1),
x: this.getNodeParameter('x', i, '10'),
y: this.getNodeParameter('y', i, '10'),
rotation: this.getNodeParameter('rotation', i, 0),
enableTimeControl: this.getNodeParameter('enableTimeControl', i, false),
startTime: this.getNodeParameter('startTime', i, 0),
endTime: this.getNodeParameter('endTime', i, 5),
opacity: this.getNodeParameter('opacity', i, 1.0),
};
const { paths: videoPaths, cleanup: videoCleanup } = await (0, utils_1.resolveInputs)(this, i, [
sourceVideo,
]);
const { paths: imagePaths, cleanup: imageCleanup } = await (0, utils_1.resolveInputs)(this, i, [
stampImage,
]);
cleanup = async () => {
await videoCleanup();
await imageCleanup();
};
outputPath = await operations_1.executeStampImage.call(this, videoPaths[0], imagePaths[0], stampOptions, i);
break;
}
}
// Always cleanup after operations
await cleanup();
}
// ===================================
// FINAL OUTPUT PROCESSING
// ===================================
if (outputPath) {
// Operation resulted in a file to be returned
const binaryData = await fs.readFile(outputPath);
const fileName = path.basename(outputPath);
const binary = await this.helpers.prepareBinaryData(binaryData, fileName);
// Get the output field name from parameters (default to 'data')
const outputFieldName = this.getNodeParameter('outputFieldName', i, 'data');
await fs.remove(outputPath); // Clean up temp file
returnData.push({ json: {}, binary: { [outputFieldName]: binary }, pairedItem: { item: i } });
}
else if (resultData) {
// Operation resulted in JSON data
returnData.push({
json: {
success: true,
operation: this.getNodeParameter('operation', i),
data: resultData,
},
pairedItem: { item: i },
});
}
else if (resource !== 'font') {
// This case handles non-font operations that might not produce output
throw new n8n_workflow_1.NodeOperationError(this.getNode(), `Operation "${operation}" on resource "${resource}" did not produce an output.`, { itemIndex: i });
}
}
catch (error) {
// Ensure cleanup is called even if an error occurs
// Note: cleanup is only available for media operations, not font operations
if (resource !== 'font' && typeof cleanup === 'function') {
await cleanup().catch(() => {
// Ignore cleanup errors to avoid masking the original error
});
}
if (this.continueOnFail()) {
returnData.push({
json: {
error: error instanceof Error ? error.message : String(error),
operation: this.getNodeParameter('operation', i),
success: false,
},
pairedItem: { item: i },
});
continue;
}
throw error;
}
}
return [returnData];
}
}
exports.MediaFX = MediaFX;