UNPKG

n8n-nodes-mediafx

Version:

N8N custom nodes for video editing and media processing

411 lines (410 loc) 19.3 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; }; var __importDefault = (this && this.__importDefault) || function (mod) { return (mod && mod.__esModule) ? mod : { "default": mod }; }; Object.defineProperty(exports, "__esModule", { value: true }); exports.checkTransitionSupport = exports.getFFmpegCapabilities = exports.deleteUserFont = exports.saveUserFont = exports.validateFontKey = exports.getAvailableFonts = exports.getUserFonts = exports.REGISTERED_FONTS = exports.fileHasAudio = exports.getVideoStreamInfo = exports.runFfmpeg = exports.createSilentAudio = exports.getDuration = exports.resolveInputs = exports.createTempFileFromBuffer = exports.downloadSource = exports.cleanupOldTempFiles = exports.getTempFile = exports.verifyFfmpegAvailability = void 0; const fs = __importStar(require("fs-extra")); const path = __importStar(require("path")); const uuid_1 = require("uuid"); const ffmpeg = require("fluent-ffmpeg"); const axios_1 = __importDefault(require("axios")); const n8n_workflow_1 = require("n8n-workflow"); const os = __importStar(require("os")); // Initialize FFmpeg with comprehensive fallback strategy let ffmpegPath = null; let ffmpegInitialized = false; function tryInitializeFfmpeg() { if (ffmpegInitialized) return true; try { // eslint-disable-next-line @typescript-eslint/no-require-imports const ffmpegInstaller = require('@ffmpeg-installer/ffmpeg'); // eslint-disable-next-line @typescript-eslint/no-require-imports const ffprobeInstaller = require('@ffprobe-installer/ffprobe'); const ffmpegInstallerPath = ffmpegInstaller.path; const ffprobeInstallerPath = ffprobeInstaller.path; if (ffmpegInstallerPath && fs.existsSync(ffmpegInstallerPath) && ffprobeInstallerPath && fs.existsSync(ffprobeInstallerPath)) { // Set executable permissions dynamically if (os.platform() !== 'win32') { try { fs.chmodSync(ffmpegInstallerPath, '755'); fs.chmodSync(ffprobeInstallerPath, '755'); console.log('Dynamically set permissions for ffmpeg and ffprobe.'); } catch (permissionError) { console.warn('Failed to set executable permissions dynamically:', permissionError); } } ffmpeg.setFfmpegPath(ffmpegInstallerPath); ffmpeg.setFfprobePath(ffprobeInstallerPath); ffmpegPath = ffmpegInstallerPath; ffmpegInitialized = true; console.log(`FFmpeg initialized with @ffmpeg-installer: ${ffmpegInstallerPath}`); console.log(`FFprobe initialized with @ffprobe-installer: ${ffprobeInstallerPath}`); return true; } } catch (error) { // This is the only strategy, so if it fails, we throw. console.error('Failed to load FFmpeg/FFprobe from node_modules.', error); throw new n8n_workflow_1.NodeOperationError( // We can't use `this.getNode()` here as we are in a utility function. // A generic error is sufficient. { name: 'MediaFX', type: 'n8n-nodes-mediafx.mediaFX' }, 'Could not load the required FFmpeg executable from the package. ' + 'This might be due to a restricted execution environment or a broken installation. ' + 'Please check your n8n environment permissions. ' + `Original error: ${error.message}`); } // If we get here, something went wrong, but the catch didn't trigger. console.error('FFmpeg binaries were not found in the expected package path.'); return false; } // Try to initialize FFmpeg on module load tryInitializeFfmpeg(); const TEMP_DIR = path.resolve(__dirname, '..', '..', 'temp_mediafx'); fs.ensureDirSync(TEMP_DIR); // Function to verify FFmpeg is available function verifyFfmpegAvailability() { if (!ffmpegInitialized) { const success = tryInitializeFfmpeg(); if (!success) { // The error is now thrown inside tryInitializeFfmpeg, but as a fallback: throw new n8n_workflow_1.NodeOperationError({ name: 'MediaFX', type: 'n8n-nodes-mediafx.mediaFX' }, 'FFmpeg is not available. The node failed to initialize it from its internal dependencies.'); } } // The rest of the verification logic can be simplified or removed // as we now only trust our single source of truth. console.log(`FFmpeg verification successful: ${ffmpegPath}`); } exports.verifyFfmpegAvailability = verifyFfmpegAvailability; function getTempFile(extension) { return path.join(TEMP_DIR, `${(0, uuid_1.v4)()}${extension}`); } exports.getTempFile = getTempFile; // Utility function to clean up old temporary files async function cleanupOldTempFiles(maxAgeHours = 24) { try { if (!fs.existsSync(TEMP_DIR)) { return; } const files = await fs.readdir(TEMP_DIR); const now = Date.now(); const maxAge = maxAgeHours * 60 * 60 * 1000; // Convert hours to milliseconds for (const file of files) { const filePath = path.join(TEMP_DIR, file); try { const stats = await fs.stat(filePath); const age = now - stats.mtime.getTime(); if (age > maxAge) { await fs.remove(filePath); console.log(`Cleaned up old temp file: ${file}`); } } catch (error) { // Ignore errors for individual files (file might be in use, etc.) console.warn(`Could not clean up temp file ${file}:`, error.message); } } } catch (error) { console.warn('Error during temp file cleanup:', error.message); } } exports.cleanupOldTempFiles = cleanupOldTempFiles; async function downloadSource(url) { const tempPath = getTempFile(path.extname(new URL(url).pathname) || '.tmp'); const writer = fs.createWriteStream(tempPath); const response = await (0, axios_1.default)({ url, method: 'GET', responseType: 'stream', }); response.data.pipe(writer); return new Promise((resolve, reject) => { writer.on('finish', () => resolve({ filePath: tempPath, cleanup: () => fs.remove(tempPath) })); writer.on('error', (err) => { fs.remove(tempPath).catch(() => { }); reject(err); }); }); } exports.downloadSource = downloadSource; async function createTempFileFromBuffer(buffer, originalFilename) { const extension = path.extname(originalFilename || '.tmp'); const tempPath = getTempFile(extension); await fs.writeFile(tempPath, buffer); return { filePath: tempPath, cleanup: () => fs.remove(tempPath) }; } exports.createTempFileFromBuffer = createTempFileFromBuffer; async function resolveInputs(executeFunctions, itemIndex, sourcesConfig) { var _a, _b, _c; const cleanupFunctions = []; const paths = []; for (const source of sourcesConfig) { let inputPath; switch (source.sourceType) { case 'url': { const { filePath, cleanup } = await downloadSource(source.value); inputPath = filePath; cleanupFunctions.push(cleanup); break; } case 'binary': { if (!source.binaryProperty) { throw new n8n_workflow_1.NodeOperationError(executeFunctions.getNode(), 'Binary property name is not defined for binary source.'); } const inputData = executeFunctions.getInputData(); let binaryData = (_a = inputData[itemIndex]) === null || _a === void 0 ? void 0 : _a.binary; let actualItemIndex = itemIndex; // Check if binary data exists in current item if (!binaryData || !binaryData[source.binaryProperty]) { // For merged inputs, check the first item (index 0) as well // This handles cases where Merge node combines multiple inputs into one item if (itemIndex !== 0 && ((_b = inputData[0]) === null || _b === void 0 ? void 0 : _b.binary) && inputData[0].binary[source.binaryProperty]) { binaryData = inputData[0].binary; actualItemIndex = 0; console.log(`[MediaFX] Using binary data from item 0 for property "${source.binaryProperty}"`); } else { // Provide helpful debugging information const currentItemProps = binaryData ? Object.keys(binaryData) : []; const firstItemProps = ((_c = inputData[0]) === null || _c === void 0 ? void 0 : _c.binary) ? Object.keys(inputData[0].binary) : []; const allProps = [...new Set([...currentItemProps, ...firstItemProps])]; const errorMessage = `Binary data not found in property "${source.binaryProperty}". ` + `Available properties in current item: ${currentItemProps.length > 0 ? currentItemProps.join(', ') : 'none'}. ` + (itemIndex !== 0 && firstItemProps.length > 0 ? `Available in first item: ${firstItemProps.join(', ')}. ` : '') + `All available: ${allProps.length > 0 ? allProps.join(', ') : 'none'}`; throw new n8n_workflow_1.NodeOperationError(executeFunctions.getNode(), errorMessage, { itemIndex, description: 'Make sure the binary property name matches the output from the previous node. ' + 'If using a Merge node, check that the binary properties are correctly named (e.g., data1, data2).' }); } } const originalFilename = binaryData[source.binaryProperty].fileName; const buffer = await executeFunctions.helpers.getBinaryDataBuffer(actualItemIndex, source.binaryProperty); const { filePath, cleanup } = await createTempFileFromBuffer(buffer, originalFilename); inputPath = filePath; cleanupFunctions.push(cleanup); break; } default: throw new n8n_workflow_1.NodeOperationError(executeFunctions.getNode(), `Unsupported source type: ${source.sourceType}`); } paths.push(inputPath); } const cleanup = async () => { for (const func of cleanupFunctions) { await func(); } }; return { paths, cleanup }; } exports.resolveInputs = resolveInputs; function getDuration(filePath) { return new Promise((resolve, reject) => { ffmpeg.ffprobe(filePath, (err, metadata) => { if (err) { return reject(new Error(`Failed to get video duration: ${err.message}`)); } const duration = metadata.format.duration; if (typeof duration !== 'number' || !isFinite(duration)) { // Fallback for streams with no duration metadata (like image inputs) const videoStream = metadata.streams.find(s => s.codec_type === 'video'); if (videoStream && videoStream.duration && isFinite(Number(videoStream.duration))) { return resolve(Number(videoStream.duration)); } // If it's an image or format without duration, default to a sensible value like 0 // The caller should handle this case. return resolve(0); } resolve(duration); }); }); } exports.getDuration = getDuration; async function createSilentAudio(duration) { const outputPath = getTempFile('.aac'); const cleanup = () => fs.remove(outputPath); if (duration <= 0) { // Create a very short, almost zero-length silent audio file for inputs like images. duration = 0.01; } const command = ffmpeg() .input('anullsrc') .inputOptions('-f', 'lavfi') .audioCodec('aac') .duration(duration) .save(outputPath); await runFfmpeg(command); return { filePath: outputPath, cleanup }; } exports.createSilentAudio = createSilentAudio; function runFfmpeg(command) { return new Promise((resolve, reject) => { command.on('end', () => resolve()); // @ts-ignore - The type definitions for fluent-ffmpeg seem to have an issue here. command.on('error', (err, stdout, stderr) => { const errorMessage = `${err.message} (ffmpeg stderr: ${stderr})`; reject(new Error(errorMessage)); }); command.run(); }); } exports.runFfmpeg = runFfmpeg; function getVideoStreamInfo(filePath) { return new Promise((resolve, reject) => { ffmpeg.ffprobe(filePath, (err, metadata) => { if (err) { return reject(new Error(`Failed to probe file: ${err.message}`)); } const videoStream = metadata.streams.find((s) => s.codec_type === 'video'); resolve(videoStream); }); }); } exports.getVideoStreamInfo = getVideoStreamInfo; function fileHasAudio(filePath) { return new Promise((resolve, reject) => { ffmpeg.ffprobe(filePath, (err, metadata) => { if (err) { return reject(new Error(`Failed to probe file: ${err.message}`)); } const hasAudio = metadata.streams.some((s) => s.codec_type === 'audio'); resolve(hasAudio); }); }); } exports.fileHasAudio = fileHasAudio; // ==================================================================== // FONT MANAGEMENT HELPERS // ==================================================================== // Define paths for font management const BASE_FONTS_DIR = path.resolve(__dirname, '..', '..', 'fonts'); const USER_FONTS_DIR = path.join(BASE_FONTS_DIR, 'user'); const USER_FONTS_JSON = path.join(USER_FONTS_DIR, 'user-fonts.json'); // System-registered fonts (must exist in BASE_FONTS_DIR) exports.REGISTERED_FONTS = { 'noto-sans-kr': { name: 'Noto Sans KR', filename: 'NotoSansKR-Regular.ttf', description: 'Google Noto Sans KR', type: 'korean' }, 'nanum-gothic': { name: 'Nanum Gothic', filename: 'NanumGothic-Regular.ttf', description: 'Naver Nanum Gothic', type: 'korean' }, 'pretendard': { name: 'Pretendard', filename: 'Pretendard-Regular.otf', description: 'Pretendard', type: 'korean' }, 'roboto': { name: 'Roboto', filename: 'Roboto-Regular.ttf', description: 'Google Roboto', type: 'global' }, 'inter': { name: 'Inter', filename: 'Inter-Regular.ttf', description: 'Inter UI Font', type: 'global' }, 'dejavu-sans': { name: 'DejaVu Sans', filename: 'DejaVuSans.ttf', description: 'Default fallback font', type: 'fallback' }, }; // Helper functions for font management function ensureUserFontsDirectory() { if (!fs.existsSync(USER_FONTS_DIR)) { fs.mkdirSync(USER_FONTS_DIR, { recursive: true }); } } function getUserFonts() { ensureUserFontsDirectory(); if (!fs.existsSync(USER_FONTS_JSON)) { return {}; } try { const data = fs.readFileSync(USER_FONTS_JSON, 'utf8'); return JSON.parse(data); } catch (error) { return {}; } } exports.getUserFonts = getUserFonts; function saveUserFonts(userFonts) { ensureUserFontsDirectory(); fs.writeFileSync(USER_FONTS_JSON, JSON.stringify(userFonts, null, 2)); } function getAvailableFonts() { const fonts = {}; // Add registered fonts for (const [key, font] of Object.entries(exports.REGISTERED_FONTS)) { const fontPath = path.join(BASE_FONTS_DIR, font.filename); if (fs.existsSync(fontPath)) { fonts[key] = { ...font, path: fontPath, type: font.type || 'system' }; } } // Add user fonts const userFonts = getUserFonts(); for (const [key, font] of Object.entries(userFonts)) { const fontPath = path.join(USER_FONTS_DIR, font.filename); if (fs.existsSync(fontPath)) { fonts[key] = { ...font, path: fontPath, type: 'user' }; } } return fonts; } exports.getAvailableFonts = getAvailableFonts; function validateFontKey(fontKey) { const keyPattern = /^[a-zA-Z0-9_-]{3,50}$/; if (!keyPattern.test(fontKey)) { throw new Error('Font key must be 3-50 characters, containing only letters, numbers, hyphens, and underscores.'); } const allFonts = getAvailableFonts(); if (allFonts[fontKey]) { throw new Error('Font key already exists. Please use a different key.'); } } exports.validateFontKey = validateFontKey; function saveUserFont(fontKey, fontName, description, originalFilename, buffer) { validateFontKey(fontKey); const ext = path.extname(originalFilename); const filename = `${fontKey}${ext}`; const fontPath = path.join(USER_FONTS_DIR, filename); fs.writeFileSync(fontPath, buffer); const userFonts = getUserFonts(); userFonts[fontKey] = { name: fontName || fontKey, filename, description, createdAt: new Date().toISOString(), }; saveUserFonts(userFonts); return { fontKey, path: fontPath, metadata: userFonts[fontKey] }; } exports.saveUserFont = saveUserFont; function deleteUserFont(fontKey) { const userFonts = getUserFonts(); const font = userFonts[fontKey]; if (!font) { throw new Error(`User font with key '${fontKey}' not found.`); } const fontPath = path.join(USER_FONTS_DIR, font.filename); if (fs.existsSync(fontPath)) { fs.unlinkSync(fontPath); } delete userFonts[fontKey]; saveUserFonts(userFonts); return true; } exports.deleteUserFont = deleteUserFont; // Re-export ffmpeg version utilities var ffmpegVersion_1 = require("./utils/ffmpegVersion"); Object.defineProperty(exports, "getFFmpegCapabilities", { enumerable: true, get: function () { return ffmpegVersion_1.getFFmpegCapabilities; } }); Object.defineProperty(exports, "checkTransitionSupport", { enumerable: true, get: function () { return ffmpegVersion_1.checkTransitionSupport; } });