@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, '\\\\\\\\')
|
|
39
|
-
.replace(/:/g, '\\:')
|
|
40
|
-
.replace(/\[/g, '\\[')
|
|
37
|
+
.replace(/\\/g, '\\\\\\\\')
|
|
38
|
+
.replace(/:/g, '\\:')
|
|
39
|
+
.replace(/\[/g, '\\[')
|
|
41
40
|
.replace(/\]/g, '\\]')
|
|
42
|
-
.replace(/'/g, "\\'");
|
|
41
|
+
.replace(/'/g, "\\'");
|
|
43
42
|
}
|
|
44
43
|
/**
|
|
45
|
-
*
|
|
46
|
-
*
|
|
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
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
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
|
-
|
|
84
|
-
|
|
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
|
-
|
|
87
|
-
|
|
88
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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 = (
|
|
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
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
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