@lee-jisoo/n8n-nodes-mediafx 1.6.25 → 1.6.27

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -10,12 +10,32 @@ This is a custom n8n node for comprehensive, local media processing using FFmpeg
10
10
 
11
11
  ## 🆕 What's New in This Fork
12
12
 
13
- ### v1.6.25
13
+ ### v1.6.27
14
+ **Improvements**
15
+
16
+ - **🔧 FFmpeg Bundling Fix**: Now uses `ffmpeg-static` (FFmpeg 5.x) as primary source
17
+ - More reliable cross-platform support
18
+ - Fixes issues with old system FFmpeg being used instead of bundled version
19
+ - All advanced features (text_align, line_spacing) now work properly
20
+
21
+ ### v1.6.26
14
22
  **New Features**
15
23
 
16
- - **📝 Multi-line Text Alignment**: Added text alignment option for Add Text to Image
17
- - Left, Center, Right alignment for multi-line text
18
- - Useful when text contains line breaks (`\n`)
24
+ - **📐 Auto Font Size**: Automatically calculate font size based on image dimensions
25
+ - `Auto - Small`: Fits text at 50% of calculated size
26
+ - `Auto - Medium`: Fits text at 75% of calculated size
27
+ - `Auto - Large`: Fits text at 100% (fills available width)
28
+ - Supports Korean/CJK characters with proper width weighting
29
+
30
+ - **😀 Emoji Auto-Removal**: Automatically removes emojis from text
31
+ - Prevents broken/garbled characters in rendered output
32
+ - Emojis are stripped before rendering
33
+
34
+ - **↕️ Line Spacing**: Adjustable spacing between lines
35
+ - Default 10px, configurable for multi-line text
36
+
37
+ - **📝 Multi-line Text Alignment**: Text alignment for multi-line text
38
+ - Left, Center, Right alignment options
19
39
 
20
40
  ### v1.6.24
21
41
  **Bug Fixes**
@@ -414,11 +414,15 @@ class MediaFX {
414
414
  const { paths, cleanup: c } = await (0, utils_1.resolveInputs)(this, i, [sourceParam.source]);
415
415
  cleanup = c;
416
416
  const text = this.getNodeParameter('imageText', i, 'Hello, n8n!');
417
+ const sizeMode = this.getNodeParameter('imageTextSizeMode', i, 'fixed');
417
418
  const textOptions = {
418
419
  fontKey: this.getNodeParameter('imageTextFontKey', i, 'noto-sans-kr'),
419
- size: this.getNodeParameter('imageTextSize', i, 48),
420
+ size: sizeMode === 'fixed'
421
+ ? this.getNodeParameter('imageTextSize', i, 48)
422
+ : sizeMode,
420
423
  color: this.getNodeParameter('imageTextColor', i, 'white'),
421
424
  textAlign: this.getNodeParameter('imageTextAlign', i, 'left'),
425
+ lineSpacing: this.getNodeParameter('imageTextLineSpacing', i, 10),
422
426
  outlineWidth: this.getNodeParameter('imageTextOutlineWidth', i, 0),
423
427
  outlineColor: this.getNodeParameter('imageTextOutlineColor', i, 'black'),
424
428
  enableBackground: this.getNodeParameter('imageTextEnableBackground', i, false),
@@ -52,6 +52,99 @@ function detectImageFormat(filePath) {
52
52
  });
53
53
  });
54
54
  }
55
+ // Get image dimensions using ffprobe
56
+ function getImageDimensions(filePath) {
57
+ return new Promise((resolve, reject) => {
58
+ ffmpeg.ffprobe(filePath, (err, metadata) => {
59
+ if (err) {
60
+ return reject(new Error(`Failed to probe image: ${err.message}`));
61
+ }
62
+ const videoStream = metadata.streams.find(s => s.codec_type === 'video');
63
+ if (!videoStream || !videoStream.width || !videoStream.height) {
64
+ return reject(new Error('Could not determine image dimensions'));
65
+ }
66
+ resolve({ width: videoStream.width, height: videoStream.height });
67
+ });
68
+ });
69
+ }
70
+ // Calculate effective text length considering character widths
71
+ // Korean/CJK characters are roughly 2x width of Latin characters
72
+ function calculateEffectiveTextLength(text) {
73
+ let length = 0;
74
+ for (const char of text) {
75
+ const code = char.charCodeAt(0);
76
+ // CJK characters (Korean, Chinese, Japanese)
77
+ if ((code >= 0xAC00 && code <= 0xD7AF) || // Korean Hangul
78
+ (code >= 0x3130 && code <= 0x318F) || // Korean Jamo
79
+ (code >= 0x4E00 && code <= 0x9FFF) || // CJK Unified Ideographs
80
+ (code >= 0x3040 && code <= 0x309F) || // Hiragana
81
+ (code >= 0x30A0 && code <= 0x30FF) // Katakana
82
+ ) {
83
+ length += 1.8; // CJK characters are wider
84
+ }
85
+ else if (char === '\n') {
86
+ // Newlines don't contribute to width
87
+ length += 0;
88
+ }
89
+ else {
90
+ length += 1; // Latin and other characters
91
+ }
92
+ }
93
+ return length;
94
+ }
95
+ // Remove emojis from text (emojis cause rendering issues with most fonts)
96
+ function removeEmojis(text) {
97
+ // Comprehensive emoji regex pattern covering:
98
+ // - Emoticons, Dingbats, Symbols
99
+ // - Transport, Map, Alchemical symbols
100
+ // - Flags, Skin tone modifiers
101
+ // - ZWJ sequences, Variation selectors
102
+ const emojiPattern = /[\u{1F600}-\u{1F64F}]|[\u{1F300}-\u{1F5FF}]|[\u{1F680}-\u{1F6FF}]|[\u{1F700}-\u{1F77F}]|[\u{1F780}-\u{1F7FF}]|[\u{1F800}-\u{1F8FF}]|[\u{1F900}-\u{1F9FF}]|[\u{1FA00}-\u{1FA6F}]|[\u{1FA70}-\u{1FAFF}]|[\u{2600}-\u{26FF}]|[\u{2700}-\u{27BF}]|[\u{2300}-\u{23FF}]|[\u{2B50}]|[\u{2B55}]|[\u{231A}-\u{231B}]|[\u{23E9}-\u{23F3}]|[\u{23F8}-\u{23FA}]|[\u{25AA}-\u{25AB}]|[\u{25B6}]|[\u{25C0}]|[\u{25FB}-\u{25FE}]|[\u{2614}-\u{2615}]|[\u{2648}-\u{2653}]|[\u{267F}]|[\u{2693}]|[\u{26A1}]|[\u{26AA}-\u{26AB}]|[\u{26BD}-\u{26BE}]|[\u{26C4}-\u{26C5}]|[\u{26CE}]|[\u{26D4}]|[\u{26EA}]|[\u{26F2}-\u{26F3}]|[\u{26F5}]|[\u{26FA}]|[\u{26FD}]|[\u{2702}]|[\u{2705}]|[\u{2708}-\u{270D}]|[\u{270F}]|[\u{2712}]|[\u{2714}]|[\u{2716}]|[\u{271D}]|[\u{2721}]|[\u{2728}]|[\u{2733}-\u{2734}]|[\u{2744}]|[\u{2747}]|[\u{274C}]|[\u{274E}]|[\u{2753}-\u{2755}]|[\u{2757}]|[\u{2763}-\u{2764}]|[\u{2795}-\u{2797}]|[\u{27A1}]|[\u{27B0}]|[\u{27BF}]|[\u{FE00}-\u{FE0F}]|[\u{200D}]|[\u{20E3}]|[\u{E0020}-\u{E007F}]/gu;
103
+ // Remove emojis and clean up any resulting double spaces (but preserve newlines)
104
+ return text
105
+ .replace(emojiPattern, '')
106
+ .split('\n')
107
+ .map(line => line.replace(/ +/g, ' ').trim())
108
+ .join('\n');
109
+ }
110
+ // Calculate auto font size based on image dimensions and text
111
+ function calculateAutoFontSize(imageWidth, imageHeight, text, sizeOption, // 'auto-small', 'auto-medium', 'auto-large'
112
+ paddingX = 20) {
113
+ // Split by newlines and find the longest line
114
+ const lines = text.split('\n');
115
+ let maxLineLength = 0;
116
+ for (const line of lines) {
117
+ const lineLength = calculateEffectiveTextLength(line);
118
+ if (lineLength > maxLineLength) {
119
+ maxLineLength = lineLength;
120
+ }
121
+ }
122
+ // If empty or only newlines, use a default
123
+ if (maxLineLength === 0) {
124
+ maxLineLength = 1;
125
+ }
126
+ // Available width (with padding on both sides)
127
+ const availableWidth = imageWidth - (paddingX * 2);
128
+ // Base calculation: fit text within available width
129
+ // Approximate: 1 character ≈ 0.6 * fontSize in width for most fonts
130
+ const charWidthRatio = 0.55;
131
+ let baseFontSize = availableWidth / (maxLineLength * charWidthRatio);
132
+ // Apply size multiplier
133
+ const sizeMultipliers = {
134
+ 'auto-small': 0.5,
135
+ 'auto-medium': 0.75,
136
+ 'auto-large': 1.0,
137
+ };
138
+ const multiplier = sizeMultipliers[sizeOption] || 0.75;
139
+ let fontSize = Math.floor(baseFontSize * multiplier);
140
+ // Also consider height constraint (text shouldn't be taller than 80% of image)
141
+ const lineCount = lines.length;
142
+ const maxHeightFontSize = Math.floor((imageHeight * 0.8) / (lineCount * 1.2)); // 1.2 for line spacing
143
+ fontSize = Math.min(fontSize, maxHeightFontSize);
144
+ // Clamp to reasonable range
145
+ fontSize = Math.max(12, Math.min(fontSize, 500));
146
+ return fontSize;
147
+ }
55
148
  function getPositionFromAlignment(horizontalAlign, verticalAlign, paddingX, paddingY) {
56
149
  let x;
57
150
  let y;
@@ -84,7 +177,7 @@ function getPositionFromAlignment(horizontalAlign, verticalAlign, paddingX, padd
84
177
  return { x, y };
85
178
  }
86
179
  async function executeAddTextToImage(imagePath, text, options, itemIndex) {
87
- var _a, _b, _c;
180
+ var _a, _b, _c, _d;
88
181
  // Get font (includes bundled, user, and system fonts)
89
182
  const allFonts = (0, utils_1.getAvailableFonts)(true);
90
183
  const fontKey = options.fontKey || 'noto-sans-kr';
@@ -93,17 +186,33 @@ async function executeAddTextToImage(imagePath, text, options, itemIndex) {
93
186
  throw new n8n_workflow_1.NodeOperationError(this.getNode(), `Selected font '${fontKey}' is not valid or its file path is missing.`, { itemIndex });
94
187
  }
95
188
  const fontPath = font.path;
96
- // Set default values for text options
97
- const fontSize = options.size || 48;
189
+ // Remove emojis from text (they cause rendering issues with most fonts)
190
+ const cleanedText = removeEmojis(text);
191
+ // Get padding values early (needed for auto font size calculation)
192
+ const paddingX = (_a = options.paddingX) !== null && _a !== void 0 ? _a : 20;
193
+ const paddingY = (_b = options.paddingY) !== null && _b !== void 0 ? _b : 20;
194
+ // Handle font size - support auto sizing options
195
+ let fontSize;
196
+ const sizeOption = options.size;
197
+ const autoSizeOptions = ['auto-small', 'auto-medium', 'auto-large'];
198
+ if (typeof sizeOption === 'string' && autoSizeOptions.includes(sizeOption)) {
199
+ // Auto font size - need to get image dimensions first
200
+ const dimensions = await getImageDimensions(imagePath);
201
+ fontSize = calculateAutoFontSize(dimensions.width, dimensions.height, cleanedText, sizeOption, paddingX);
202
+ }
203
+ else {
204
+ fontSize = sizeOption || 48;
205
+ }
98
206
  const fontColor = options.color || 'white';
99
207
  const textAlign = options.textAlign || 'left';
208
+ const lineSpacing = (_c = options.lineSpacing) !== null && _c !== void 0 ? _c : 10;
100
209
  // Outline options
101
210
  const outlineWidth = options.outlineWidth || 0;
102
211
  const outlineColor = options.outlineColor || 'black';
103
212
  // Background box options
104
213
  const enableBackground = options.enableBackground || false;
105
214
  const backgroundColor = options.backgroundColor || 'black';
106
- const backgroundOpacity = (_a = options.backgroundOpacity) !== null && _a !== void 0 ? _a : 0.5;
215
+ const backgroundOpacity = (_d = options.backgroundOpacity) !== null && _d !== void 0 ? _d : 0.5;
107
216
  const boxPadding = options.boxPadding || 5;
108
217
  // Handle position based on position type
109
218
  let positionX;
@@ -112,8 +221,6 @@ async function executeAddTextToImage(imagePath, text, options, itemIndex) {
112
221
  if (positionType === 'alignment') {
113
222
  const horizontalAlign = options.horizontalAlign || 'center';
114
223
  const verticalAlign = options.verticalAlign || 'middle';
115
- const paddingX = (_b = options.paddingX) !== null && _b !== void 0 ? _b : 20;
116
- const paddingY = (_c = options.paddingY) !== null && _c !== void 0 ? _c : 20;
117
224
  const position = getPositionFromAlignment(horizontalAlign, verticalAlign, paddingX, paddingY);
118
225
  positionX = position.x;
119
226
  positionY = position.y;
@@ -138,12 +245,12 @@ async function executeAddTextToImage(imagePath, text, options, itemIndex) {
138
245
  }
139
246
  const outputPath = (0, utils_1.getTempFile)(outputExt);
140
247
  // Escape single quotes in text
141
- const escapedText = text.replace(/'/g, `''`);
248
+ const escapedText = cleanedText.replace(/'/g, `''`);
142
249
  // Map text alignment to FFmpeg format (L=left, C=center, R=right)
143
250
  const textAlignMap = { left: 'L', center: 'C', right: 'R' };
144
251
  const ffmpegTextAlign = textAlignMap[textAlign] || 'L';
145
252
  // Build drawtext filter
146
- let drawtext = `drawtext=fontfile=${fontPath}:text='${escapedText}':fontsize=${fontSize}:fontcolor=${fontColor}:x=${positionX}:y=${positionY}:text_align=${ffmpegTextAlign}`;
253
+ let drawtext = `drawtext=fontfile=${fontPath}:text='${escapedText}':fontsize=${fontSize}:fontcolor=${fontColor}:x=${positionX}:y=${positionY}:text_align=${ffmpegTextAlign}:line_spacing=${lineSpacing}`;
147
254
  // Add outline (border) if width > 0
148
255
  if (outlineWidth > 0) {
149
256
  drawtext += `:borderw=${outlineWidth}:bordercolor=${outlineColor}`;
@@ -93,6 +93,25 @@ exports.imageProperties = [
93
93
  },
94
94
  description: 'Select a font for the text',
95
95
  },
96
+ {
97
+ displayName: 'Font Size Mode',
98
+ name: 'imageTextSizeMode',
99
+ type: 'options',
100
+ options: [
101
+ { name: 'Fixed (px)', value: 'fixed' },
102
+ { name: 'Auto - Small', value: 'auto-small', description: 'Automatically fit text, smaller size' },
103
+ { name: 'Auto - Medium', value: 'auto-medium', description: 'Automatically fit text, medium size' },
104
+ { name: 'Auto - Large', value: 'auto-large', description: 'Automatically fit text, fills available width' },
105
+ ],
106
+ default: 'fixed',
107
+ displayOptions: {
108
+ show: {
109
+ resource: ['image'],
110
+ operation: ['addTextToImage'],
111
+ },
112
+ },
113
+ description: 'How to determine font size. Auto modes calculate size based on image dimensions and text length.',
114
+ },
96
115
  {
97
116
  displayName: 'Font Size',
98
117
  name: 'imageTextSize',
@@ -105,6 +124,7 @@ exports.imageProperties = [
105
124
  show: {
106
125
  resource: ['image'],
107
126
  operation: ['addTextToImage'],
127
+ imageTextSizeMode: ['fixed'],
108
128
  },
109
129
  },
110
130
  description: 'Font size in pixels',
@@ -140,6 +160,22 @@ exports.imageProperties = [
140
160
  },
141
161
  description: 'Text alignment for multi-line text (when text contains line breaks)',
142
162
  },
163
+ {
164
+ displayName: 'Line Spacing',
165
+ name: 'imageTextLineSpacing',
166
+ type: 'number',
167
+ default: 10,
168
+ typeOptions: {
169
+ minValue: 0,
170
+ },
171
+ displayOptions: {
172
+ show: {
173
+ resource: ['image'],
174
+ operation: ['addTextToImage'],
175
+ },
176
+ },
177
+ description: 'Additional spacing between lines in pixels (for multi-line text)',
178
+ },
143
179
  {
144
180
  displayName: 'Outline Width',
145
181
  name: 'imageTextOutlineWidth',
@@ -40,48 +40,74 @@ let ffmpegInitialized = false;
40
40
  function tryInitializeFfmpeg() {
41
41
  if (ffmpegInitialized)
42
42
  return true;
43
+ let ffmpegBinaryPath = null;
44
+ let ffprobeBinaryPath = null;
45
+ // Strategy 1: Try ffmpeg-static (recommended, includes FFmpeg 5.x+)
43
46
  try {
44
47
  // eslint-disable-next-line @typescript-eslint/no-require-imports
45
- const ffmpegInstaller = require('@ffmpeg-installer/ffmpeg');
48
+ const ffmpegStatic = require('ffmpeg-static');
49
+ if (ffmpegStatic && fs.existsSync(ffmpegStatic)) {
50
+ ffmpegBinaryPath = ffmpegStatic;
51
+ console.log(`FFmpeg found via ffmpeg-static: ${ffmpegStatic}`);
52
+ }
53
+ }
54
+ catch (e) {
55
+ console.warn('ffmpeg-static not available, trying fallback...');
56
+ }
57
+ // Strategy 2: Fallback to @ffmpeg-installer/ffmpeg
58
+ if (!ffmpegBinaryPath) {
59
+ try {
60
+ // eslint-disable-next-line @typescript-eslint/no-require-imports
61
+ const ffmpegInstaller = require('@ffmpeg-installer/ffmpeg');
62
+ if (ffmpegInstaller.path && fs.existsSync(ffmpegInstaller.path)) {
63
+ ffmpegBinaryPath = ffmpegInstaller.path;
64
+ console.log(`FFmpeg found via @ffmpeg-installer: ${ffmpegInstaller.path}`);
65
+ }
66
+ }
67
+ catch (e) {
68
+ console.warn('@ffmpeg-installer/ffmpeg not available');
69
+ }
70
+ }
71
+ // Get ffprobe from @ffprobe-installer/ffprobe
72
+ try {
46
73
  // eslint-disable-next-line @typescript-eslint/no-require-imports
47
74
  const ffprobeInstaller = require('@ffprobe-installer/ffprobe');
48
- const ffmpegInstallerPath = ffmpegInstaller.path;
49
- const ffprobeInstallerPath = ffprobeInstaller.path;
50
- if (ffmpegInstallerPath && fs.existsSync(ffmpegInstallerPath) && ffprobeInstallerPath && fs.existsSync(ffprobeInstallerPath)) {
51
- // Set executable permissions dynamically
52
- if (os.platform() !== 'win32') {
53
- try {
54
- fs.chmodSync(ffmpegInstallerPath, '755');
55
- fs.chmodSync(ffprobeInstallerPath, '755');
56
- console.log('Dynamically set permissions for ffmpeg and ffprobe.');
57
- }
58
- catch (permissionError) {
59
- console.warn('Failed to set executable permissions dynamically:', permissionError);
60
- }
61
- }
62
- ffmpeg.setFfmpegPath(ffmpegInstallerPath);
63
- ffmpeg.setFfprobePath(ffprobeInstallerPath);
64
- ffmpegPath = ffmpegInstallerPath;
65
- ffmpegInitialized = true;
66
- console.log(`FFmpeg initialized with @ffmpeg-installer: ${ffmpegInstallerPath}`);
67
- console.log(`FFprobe initialized with @ffprobe-installer: ${ffprobeInstallerPath}`);
68
- return true;
75
+ if (ffprobeInstaller.path && fs.existsSync(ffprobeInstaller.path)) {
76
+ ffprobeBinaryPath = ffprobeInstaller.path;
77
+ console.log(`FFprobe found via @ffprobe-installer: ${ffprobeInstaller.path}`);
69
78
  }
70
79
  }
71
- catch (error) {
72
- // This is the only strategy, so if it fails, we throw.
73
- console.error('Failed to load FFmpeg/FFprobe from node_modules.', error);
74
- throw new n8n_workflow_1.NodeOperationError(
75
- // We can't use `this.getNode()` here as we are in a utility function.
76
- // A generic error is sufficient.
77
- { name: 'MediaFX', type: 'n8n-nodes-mediafx.mediaFX' }, 'Could not load the required FFmpeg executable from the package. ' +
78
- 'This might be due to a restricted execution environment or a broken installation. ' +
79
- 'Please check your n8n environment permissions. ' +
80
- `Original error: ${error.message}`);
80
+ catch (e) {
81
+ console.warn('@ffprobe-installer/ffprobe not available');
82
+ }
83
+ // Validate and set paths
84
+ if (!ffmpegBinaryPath) {
85
+ console.error('FFmpeg binary not found in any package.');
86
+ throw new n8n_workflow_1.NodeOperationError({ name: 'MediaFX', type: 'n8n-nodes-mediafx.mediaFX' }, 'Could not find FFmpeg executable. Please ensure ffmpeg-static or @ffmpeg-installer/ffmpeg is properly installed.');
87
+ }
88
+ if (!ffprobeBinaryPath) {
89
+ console.error('FFprobe binary not found.');
90
+ throw new n8n_workflow_1.NodeOperationError({ name: 'MediaFX', type: 'n8n-nodes-mediafx.mediaFX' }, 'Could not find FFprobe executable. Please ensure @ffprobe-installer/ffprobe is properly installed.');
81
91
  }
82
- // If we get here, something went wrong, but the catch didn't trigger.
83
- console.error('FFmpeg binaries were not found in the expected package path.');
84
- return false;
92
+ // Set executable permissions on non-Windows systems
93
+ if (os.platform() !== 'win32') {
94
+ try {
95
+ fs.chmodSync(ffmpegBinaryPath, '755');
96
+ fs.chmodSync(ffprobeBinaryPath, '755');
97
+ console.log('Set executable permissions for ffmpeg and ffprobe.');
98
+ }
99
+ catch (permissionError) {
100
+ console.warn('Failed to set executable permissions:', permissionError);
101
+ }
102
+ }
103
+ // Configure fluent-ffmpeg
104
+ ffmpeg.setFfmpegPath(ffmpegBinaryPath);
105
+ ffmpeg.setFfprobePath(ffprobeBinaryPath);
106
+ ffmpegPath = ffmpegBinaryPath;
107
+ ffmpegInitialized = true;
108
+ console.log(`FFmpeg initialized: ${ffmpegBinaryPath}`);
109
+ console.log(`FFprobe initialized: ${ffprobeBinaryPath}`);
110
+ return true;
85
111
  }
86
112
  // Try to initialize FFmpeg on module load
87
113
  tryInitializeFfmpeg();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lee-jisoo/n8n-nodes-mediafx",
3
- "version": "1.6.25",
3
+ "version": "1.6.27",
4
4
  "description": "N8N custom nodes for video editing and media processing (Enhanced fork with Speed control and Subtitle fixes)",
5
5
  "license": "MIT",
6
6
  "author": {