UNPKG

n8n-nodes-mediafx

Version:

N8N custom nodes for video editing and media processing

458 lines (457 loc) 28.6 kB
"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;