@lee-jisoo/n8n-nodes-mediafx 1.6.1 → 1.6.2

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/CHANGELOG.md ADDED
@@ -0,0 +1,65 @@
1
+ # Changelog
2
+
3
+ All notable changes to this project will be documented in this file.
4
+
5
+ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
6
+ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
+
8
+ ## [1.6.0] - 2025-01-25
9
+
10
+ ### Added
11
+ - **Speed operation**: Adjust video playback speed (slow motion or fast forward)
12
+ - Support speed range from 0.25x to 4x
13
+ - Option to adjust audio speed along with video
14
+ - Option to maintain original audio pitch when changing speed
15
+ - Output format selection (MP4, MOV, AVI, MKV)
16
+
17
+ ### Fixed
18
+ - **addSubtitle operation**: Fixed subtitle display issue with slideshow/image-sequence videos
19
+ - Changed from `drawtext` filter chain to `subtitles` filter
20
+ - The `drawtext` filter's `enable='between(t,X,Y)'` condition was unreliable with image-sequence videos
21
+ - Now uses FFmpeg's native SRT parsing which handles timing correctly
22
+ - Added ASS/SSA style support for better subtitle formatting
23
+
24
+ ## [1.5.0] - 2025-01-20
25
+
26
+ ### Added
27
+ - **Overlay Video operation**: Overlay a video on top of another video as a layer
28
+
29
+ ## [1.4.0] - 2025-01-13
30
+
31
+ ### Added
32
+ - **Customizable output field names**: Users can now specify custom binary output field names for all media operations
33
+ - Added "Output Field Name" option to Video, Audio, Image, and Text/Subtitle resources
34
+ - Default field name remains "data" for backward compatibility
35
+ - Allows better organization when chaining multiple MediaFX nodes in workflows
36
+
37
+ ### Fixed
38
+ - **Merge node compatibility**: Improved binary data handling when using n8n's Merge node
39
+ - Automatically detects binary properties in merged items (data1, data2, etc.)
40
+ - Searches both current item and first item for binary data
41
+ - Enhanced error messages with available properties list
42
+ - **Video transition resolution handling**: Fixed concat filter errors with different video resolutions
43
+ - Automatically scales all videos to common resolution before transitions
44
+ - Maintains aspect ratio with padding when needed
45
+ - Ensures compatibility with FFmpeg 4.4 and earlier versions
46
+
47
+ ### Changed
48
+ - Updated MediaFX.node.ts to use dynamic output field names based on user configuration
49
+ - Improved Binary Property field descriptions in UI with Merge node usage guidance
50
+ - Enhanced error messages for better debugging of binary data issues
51
+
52
+ ## [1.3.2] - Previous Release
53
+
54
+ ### Fixed
55
+ - Resolved parameter issues in Video operations
56
+
57
+ ## [1.3.1] - Previous Release
58
+
59
+ ### Fixed
60
+ - Restored missing mixAudio operation for Audio resource
61
+
62
+ ## [1.3.0] - Previous Release
63
+
64
+ ### Added
65
+ - Improved resource and action naming for better UX
@@ -31,78 +31,112 @@ const ffmpeg = require("fluent-ffmpeg");
31
31
  const utils_1 = require("../utils");
32
32
  /**
33
33
  * Escape special characters in file path for FFmpeg subtitles filter.
34
- * FFmpeg subtitles filter requires escaping: \ : [ ] '
35
34
  */
36
35
  function escapeSubtitlePath(filePath) {
37
36
  return filePath
38
- .replace(/\\/g, '\\\\\\\\') // Backslash (Windows paths)
39
- .replace(/:/g, '\\:') // Colon
40
- .replace(/\[/g, '\\[') // Square brackets
37
+ .replace(/\\/g, '\\\\\\\\')
38
+ .replace(/:/g, '\\:')
39
+ .replace(/\[/g, '\\[')
41
40
  .replace(/\]/g, '\\]')
42
- .replace(/'/g, "\\'"); // Single quote
41
+ .replace(/'/g, "\\'");
43
42
  }
44
43
  /**
45
- * Build ASS/SSA style string for subtitles filter force_style option.
46
- * This converts our style options to FFmpeg's force_style format.
44
+ * Convert color to ASS format (&HAABBGGRR)
45
+ * @param color - CSS color (hex #RRGGBB or named color)
46
+ * @param opacity - Opacity 0-1 (0=transparent, 1=solid)
47
47
  */
48
- function buildForceStyle(fontName, fontSize, fontColor, horizontalAlign, verticalAlign, marginV) {
49
- // Convert color from CSS format to ASS format (AABBGGRR)
50
- // Default white = &H00FFFFFF
51
- let assColor = '&H00FFFFFF';
52
- if (fontColor && fontColor.startsWith('#') && fontColor.length === 7) {
53
- // Convert #RRGGBB to &H00BBGGRR (ASS uses BGR order with alpha prefix)
54
- const r = fontColor.substring(1, 3);
55
- const g = fontColor.substring(3, 5);
56
- const b = fontColor.substring(5, 7);
57
- assColor = `&H00${b}${g}${r}`.toUpperCase();
58
- }
59
- else if (fontColor === 'white') {
60
- assColor = '&H00FFFFFF';
61
- }
62
- else if (fontColor === 'black') {
63
- assColor = '&H00000000';
64
- }
65
- else if (fontColor === 'yellow') {
66
- assColor = '&H0000FFFF';
67
- }
68
- else if (fontColor === 'red') {
69
- assColor = '&H000000FF';
70
- }
71
- // Alignment mapping for ASS (numpad style)
72
- // 1=bottom-left, 2=bottom-center, 3=bottom-right
73
- // 4=middle-left, 5=middle-center, 6=middle-right
74
- // 7=top-left, 8=top-center, 9=top-right
75
- let alignment = 2; // default: bottom-center
76
- if (verticalAlign === 'top') {
77
- alignment = horizontalAlign === 'left' ? 7 : horizontalAlign === 'right' ? 9 : 8;
78
- }
79
- else if (verticalAlign === 'middle') {
80
- alignment = horizontalAlign === 'left' ? 4 : horizontalAlign === 'right' ? 6 : 5;
48
+ function colorToASS(color, opacity = 1) {
49
+ let r = 'FF', g = 'FF', b = 'FF';
50
+ if (color && color.startsWith('#') && color.length === 7) {
51
+ r = color.substring(1, 3);
52
+ g = color.substring(3, 5);
53
+ b = color.substring(5, 7);
81
54
  }
82
55
  else {
83
- // bottom (default)
84
- alignment = horizontalAlign === 'left' ? 1 : horizontalAlign === 'right' ? 3 : 2;
56
+ const namedColors = {
57
+ white: 'FFFFFF',
58
+ black: '000000',
59
+ red: 'FF0000',
60
+ green: '00FF00',
61
+ blue: '0000FF',
62
+ yellow: 'FFFF00',
63
+ cyan: '00FFFF',
64
+ magenta: 'FF00FF',
65
+ orange: 'FFA500',
66
+ };
67
+ const hex = namedColors[color.toLowerCase()] || 'FFFFFF';
68
+ r = hex.substring(0, 2);
69
+ g = hex.substring(2, 4);
70
+ b = hex.substring(4, 6);
85
71
  }
86
- const styleParams = [
87
- `FontName=${fontName}`,
88
- `FontSize=${fontSize}`,
89
- `PrimaryColour=${assColor}`,
90
- `OutlineColour=&H00000000`,
91
- `BackColour=&H80000000`,
92
- `Bold=0`,
93
- `Italic=0`,
94
- `BorderStyle=4`,
95
- `Outline=1`,
96
- `Shadow=0`,
97
- `Alignment=${alignment}`,
98
- `MarginV=${marginV}`,
99
- ];
100
- return styleParams.join(',');
72
+ // ASS format: &HAABBGGRR (AA = alpha, 00=solid, FF=transparent)
73
+ const alpha = Math.round((1 - opacity) * 255).toString(16).padStart(2, '0').toUpperCase();
74
+ return `&H${alpha}${b}${g}${r}`.toUpperCase();
101
75
  }
102
- async function executeAddSubtitle(video, subtitleFile, style, itemIndex) {
76
+ /**
77
+ * Get ASS alignment number (numpad style)
78
+ */
79
+ function getASSAlignment(horizontalAlign, verticalAlign) {
103
80
  var _a, _b;
81
+ const alignMap = {
82
+ top: { left: 7, center: 8, right: 9 },
83
+ middle: { left: 4, center: 5, right: 6 },
84
+ bottom: { left: 1, center: 2, right: 3 },
85
+ };
86
+ return (_b = (_a = alignMap[verticalAlign]) === null || _a === void 0 ? void 0 : _a[horizontalAlign]) !== null && _b !== void 0 ? _b : 2;
87
+ }
88
+ /**
89
+ * Parse SRT file and convert to ASS format
90
+ */
91
+ function convertSRTtoASS(srtContent, fontName, fontSize, primaryColor, outlineColor, outlineWidth, backgroundColor, backgroundOpacity, enableBackground, alignment, marginV) {
92
+ // ASS Header
93
+ const borderStyle = enableBackground ? 4 : 1; // 4=box, 1=outline+shadow
94
+ const backColor = colorToASS(backgroundColor, backgroundOpacity);
95
+ const assHeader = `[Script Info]
96
+ Title: Converted from SRT
97
+ ScriptType: v4.00+
98
+ PlayResX: 1920
99
+ PlayResY: 1080
100
+ WrapStyle: 0
101
+
102
+ [V4+ Styles]
103
+ Format: Name, Fontname, Fontsize, PrimaryColour, SecondaryColour, OutlineColour, BackColour, Bold, Italic, Underline, StrikeOut, ScaleX, ScaleY, Spacing, Angle, BorderStyle, Outline, Shadow, Alignment, MarginL, MarginR, MarginV, Encoding
104
+ Style: Default,${fontName},${fontSize},${primaryColor},${primaryColor},${outlineColor},${backColor},0,0,0,0,100,100,0,0,${borderStyle},${outlineWidth},0,${alignment},20,20,${marginV},1
105
+
106
+ [Events]
107
+ Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text
108
+ `;
109
+ // Parse SRT content
110
+ const srtBlocks = srtContent.trim().split(/\n\s*\n/);
111
+ const dialogueLines = [];
112
+ for (const block of srtBlocks) {
113
+ const lines = block.trim().split('\n');
114
+ if (lines.length < 3)
115
+ continue;
116
+ // Parse timestamp line (format: 00:00:00,000 --> 00:00:00,000)
117
+ const timestampLine = lines[1];
118
+ const timestampMatch = timestampLine.match(/(\d{2}):(\d{2}):(\d{2})[,.](\d{3})\s*-->\s*(\d{2}):(\d{2}):(\d{2})[,.](\d{3})/);
119
+ if (!timestampMatch)
120
+ continue;
121
+ const startTime = `${timestampMatch[1]}:${timestampMatch[2]}:${timestampMatch[3]}.${timestampMatch[4].substring(0, 2)}`;
122
+ const endTime = `${timestampMatch[5]}:${timestampMatch[6]}:${timestampMatch[7]}.${timestampMatch[8].substring(0, 2)}`;
123
+ // Get text (lines 3+)
124
+ const text = lines.slice(2).join('\\N').replace(/\r/g, '');
125
+ dialogueLines.push(`Dialogue: 0,${startTime},${endTime},Default,,0,0,0,,${text}`);
126
+ }
127
+ return assHeader + dialogueLines.join('\n');
128
+ }
129
+ /**
130
+ * Check if file is ASS/SSA format
131
+ */
132
+ function isASSFile(filePath) {
133
+ const ext = path.extname(filePath).toLowerCase();
134
+ return ext === '.ass' || ext === '.ssa';
135
+ }
136
+ async function executeAddSubtitle(video, subtitleFile, style, itemIndex) {
137
+ var _a, _b, _c, _d;
104
138
  const outputPath = (0, utils_1.getTempFile)(path.extname(video));
105
- // 1. Get Font from fontKey
139
+ // 1. Get Font
106
140
  const allFonts = (0, utils_1.getAvailableFonts)();
107
141
  const fontKey = style.fontKey || 'noto-sans-kr';
108
142
  const font = allFonts[fontKey];
@@ -112,7 +146,12 @@ async function executeAddSubtitle(video, subtitleFile, style, itemIndex) {
112
146
  const fontName = font.name || 'Sans';
113
147
  const fontSize = style.size || 48;
114
148
  const fontColor = style.color || 'white';
115
- // 2. Handle position based on position type
149
+ const outlineWidth = (_a = style.outlineWidth) !== null && _a !== void 0 ? _a : 1;
150
+ const outlineColor = style.outlineColor || 'black';
151
+ const enableBackground = (_b = style.enableBackground) !== null && _b !== void 0 ? _b : false;
152
+ const backgroundColor = style.backgroundColor || 'black';
153
+ const backgroundOpacity = (_c = style.backgroundOpacity) !== null && _c !== void 0 ? _c : 0.5;
154
+ // 2. Get alignment
116
155
  const positionType = style.positionType || 'alignment';
117
156
  let horizontalAlign = 'center';
118
157
  let verticalAlign = 'bottom';
@@ -120,15 +159,27 @@ async function executeAddSubtitle(video, subtitleFile, style, itemIndex) {
120
159
  if (positionType === 'alignment') {
121
160
  horizontalAlign = style.horizontalAlign || 'center';
122
161
  verticalAlign = style.verticalAlign || 'bottom';
123
- marginV = (_b = (_a = style.paddingY) !== null && _a !== void 0 ? _a : style.padding) !== null && _b !== void 0 ? _b : 20;
162
+ marginV = (_d = style.paddingY) !== null && _d !== void 0 ? _d : 20;
163
+ }
164
+ const alignment = getASSAlignment(horizontalAlign, verticalAlign);
165
+ // 3. Prepare subtitle file
166
+ let finalSubtitlePath = subtitleFile;
167
+ let tempAssFile = null;
168
+ if (isASSFile(subtitleFile)) {
169
+ // ASS file: use directly (ignore style options)
170
+ finalSubtitlePath = subtitleFile;
124
171
  }
125
- // 3. Build force_style for subtitles filter
126
- const forceStyle = buildForceStyle(fontName, fontSize, fontColor, horizontalAlign, verticalAlign, marginV);
127
- // 4. Escape subtitle file path for FFmpeg
128
- const escapedSubtitlePath = escapeSubtitlePath(subtitleFile);
129
- // 5. Build subtitles filter (much more reliable than drawtext chain)
130
- // Using subtitles filter processes the entire SRT file at once
131
- const subtitlesFilter = `subtitles='${escapedSubtitlePath}':force_style='${forceStyle}'`;
172
+ else {
173
+ // SRT file: convert to ASS with styles
174
+ const srtContent = await fs.readFile(subtitleFile, 'utf-8');
175
+ const assContent = convertSRTtoASS(srtContent, fontName, fontSize, colorToASS(fontColor, 1), colorToASS(outlineColor, 1), outlineWidth, backgroundColor, backgroundOpacity, enableBackground, alignment, marginV);
176
+ tempAssFile = (0, utils_1.getTempFile)('.ass');
177
+ await fs.writeFile(tempAssFile, assContent, 'utf-8');
178
+ finalSubtitlePath = tempAssFile;
179
+ }
180
+ // 4. Build FFmpeg command
181
+ const escapedPath = escapeSubtitlePath(finalSubtitlePath);
182
+ const subtitlesFilter = `subtitles='${escapedPath}'`;
132
183
  const command = ffmpeg(video)
133
184
  .videoFilters([subtitlesFilter])
134
185
  .audioCodec('copy')
@@ -138,9 +189,14 @@ async function executeAddSubtitle(video, subtitleFile, style, itemIndex) {
138
189
  return outputPath;
139
190
  }
140
191
  catch (error) {
141
- // Clean up output file if creation failed
142
192
  await fs.remove(outputPath).catch(() => { });
143
193
  throw new n8n_workflow_1.NodeOperationError(this.getNode(), `Error adding subtitles to video. FFmpeg error: ${error.message}`, { itemIndex });
144
194
  }
195
+ finally {
196
+ // Clean up temp ASS file
197
+ if (tempAssFile) {
198
+ await fs.remove(tempAssFile).catch(() => { });
199
+ }
200
+ }
145
201
  }
146
202
  exports.executeAddSubtitle = executeAddSubtitle;
@@ -178,6 +178,61 @@ exports.subtitleProperties = [
178
178
  },
179
179
  description: 'Width of the text border/outline',
180
180
  },
181
+ {
182
+ displayName: 'Outline Color',
183
+ name: 'outlineColor',
184
+ type: 'string',
185
+ default: 'black',
186
+ displayOptions: {
187
+ show: {
188
+ resource: ['subtitle'],
189
+ },
190
+ },
191
+ description: 'Color of the text outline (e.g., black, #000000)',
192
+ },
193
+ {
194
+ displayName: 'Enable Background Box',
195
+ name: 'enableBackground',
196
+ type: 'boolean',
197
+ default: false,
198
+ displayOptions: {
199
+ show: {
200
+ resource: ['subtitle'],
201
+ },
202
+ },
203
+ description: 'Whether to show a background box behind the text',
204
+ },
205
+ {
206
+ displayName: 'Background Color',
207
+ name: 'backgroundColor',
208
+ type: 'string',
209
+ default: 'black',
210
+ displayOptions: {
211
+ show: {
212
+ resource: ['subtitle'],
213
+ enableBackground: [true],
214
+ },
215
+ },
216
+ description: 'Color of the background box (e.g., black, #000000)',
217
+ },
218
+ {
219
+ displayName: 'Background Opacity',
220
+ name: 'backgroundOpacity',
221
+ type: 'number',
222
+ typeOptions: {
223
+ minValue: 0,
224
+ maxValue: 1,
225
+ numberStepSize: 0.1,
226
+ },
227
+ default: 0.5,
228
+ displayOptions: {
229
+ show: {
230
+ resource: ['subtitle'],
231
+ enableBackground: [true],
232
+ },
233
+ },
234
+ description: 'Opacity of the background box (0 = transparent, 1 = solid)',
235
+ },
181
236
  // ===================
182
237
  // POSITION OPTIONS
183
238
  // ===================
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lee-jisoo/n8n-nodes-mediafx",
3
- "version": "1.6.1",
3
+ "version": "1.6.2",
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": {