n8n-nodes-mediafx
Version:
N8N custom nodes for video editing and media processing
411 lines (410 loc) • 19.3 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;
};
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; } });