n8n-nodes-sb-render 1.3.25 → 1.3.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/LICENSE +21 -21
- package/README.md +474 -474
- package/dist/nodes/SbRender/SbRender.node.d.ts.map +1 -1
- package/dist/nodes/SbRender/SbRender.node.js +17 -2
- package/dist/nodes/SbRender/SbRender.node.js.map +1 -1
- package/dist/nodes/SbRender/sbrender.svg +7 -7
- package/dist/nodes/SbRender/services/AudioMixer.d.ts.map +1 -1
- package/dist/nodes/SbRender/services/AudioMixer.js +7 -2
- package/dist/nodes/SbRender/services/AudioMixer.js.map +1 -1
- package/dist/nodes/SbRender/services/SubtitleEngine.js +8 -8
- package/dist/nodes/SbRender/services/VideoComposer.d.ts +0 -5
- package/dist/nodes/SbRender/services/VideoComposer.d.ts.map +1 -1
- package/dist/nodes/SbRender/services/VideoComposer.js +175 -306
- package/dist/nodes/SbRender/services/VideoComposer.js.map +1 -1
- package/index.js +3 -3
- package/package.json +75 -75
- package/scripts/fix-ffprobe-permissions.js +209 -209
|
@@ -1,4 +1,37 @@
|
|
|
1
1
|
"use strict";
|
|
2
|
+
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
|
|
3
|
+
if (k2 === undefined) k2 = k;
|
|
4
|
+
var desc = Object.getOwnPropertyDescriptor(m, k);
|
|
5
|
+
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
|
|
6
|
+
desc = { enumerable: true, get: function() { return m[k]; } };
|
|
7
|
+
}
|
|
8
|
+
Object.defineProperty(o, k2, desc);
|
|
9
|
+
}) : (function(o, m, k, k2) {
|
|
10
|
+
if (k2 === undefined) k2 = k;
|
|
11
|
+
o[k2] = m[k];
|
|
12
|
+
}));
|
|
13
|
+
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
|
|
14
|
+
Object.defineProperty(o, "default", { enumerable: true, value: v });
|
|
15
|
+
}) : function(o, v) {
|
|
16
|
+
o["default"] = v;
|
|
17
|
+
});
|
|
18
|
+
var __importStar = (this && this.__importStar) || (function () {
|
|
19
|
+
var ownKeys = function(o) {
|
|
20
|
+
ownKeys = Object.getOwnPropertyNames || function (o) {
|
|
21
|
+
var ar = [];
|
|
22
|
+
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
|
|
23
|
+
return ar;
|
|
24
|
+
};
|
|
25
|
+
return ownKeys(o);
|
|
26
|
+
};
|
|
27
|
+
return function (mod) {
|
|
28
|
+
if (mod && mod.__esModule) return mod;
|
|
29
|
+
var result = {};
|
|
30
|
+
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
|
|
31
|
+
__setModuleDefault(result, mod);
|
|
32
|
+
return result;
|
|
33
|
+
};
|
|
34
|
+
})();
|
|
2
35
|
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
3
36
|
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
4
37
|
};
|
|
@@ -6,32 +39,12 @@ Object.defineProperty(exports, "__esModule", { value: true });
|
|
|
6
39
|
exports.VideoComposer = void 0;
|
|
7
40
|
const fs_1 = require("fs");
|
|
8
41
|
const path_1 = require("path");
|
|
9
|
-
const child_process_1 = require("child_process");
|
|
10
42
|
const fluent_ffmpeg_1 = __importDefault(require("fluent-ffmpeg"));
|
|
43
|
+
const ffmpeg_1 = __importDefault(require("@ffmpeg-installer/ffmpeg"));
|
|
44
|
+
const ffprobeInstaller = __importStar(require("@ffprobe-installer/ffprobe"));
|
|
11
45
|
// Debug mode: set SB_RENDER_DEBUG=true to enable file-based debug logging
|
|
12
46
|
const DEBUG_MODE = process.env.SB_RENDER_DEBUG === 'true';
|
|
13
47
|
const DEBUG_LOG_PATH = '/tmp/sb-render-debug.log';
|
|
14
|
-
// Configuration constants
|
|
15
|
-
const CONFIG = {
|
|
16
|
-
// Video defaults
|
|
17
|
-
DEFAULT_WIDTH: 1920,
|
|
18
|
-
DEFAULT_HEIGHT: 1080,
|
|
19
|
-
DEFAULT_FPS: 24,
|
|
20
|
-
DEFAULT_DURATION: 30,
|
|
21
|
-
// Audio defaults
|
|
22
|
-
AUDIO_SAMPLE_RATE: 44100,
|
|
23
|
-
AUDIO_CHANNELS: 2,
|
|
24
|
-
AUDIO_BITRATE: '192k',
|
|
25
|
-
DEFAULT_BGM_VOLUME: 30,
|
|
26
|
-
DEFAULT_NARRATION_VOLUME: 100,
|
|
27
|
-
// Timeouts (in milliseconds)
|
|
28
|
-
FFMPEG_TIMEOUT_MS: 3600000, // 1 hour for long videos
|
|
29
|
-
FFPROBE_TIMEOUT_MS: 30000, // 30 seconds for metadata
|
|
30
|
-
// Limits
|
|
31
|
-
MAX_BGM_LOOPS: 100,
|
|
32
|
-
MIN_SUBTITLE_DURATION: 0.1,
|
|
33
|
-
SUBTITLE_GAP: 0.05,
|
|
34
|
-
};
|
|
35
48
|
// Helper function for debug logging
|
|
36
49
|
function debugLog(message) {
|
|
37
50
|
if (DEBUG_MODE) {
|
|
@@ -39,125 +52,64 @@ function debugLog(message) {
|
|
|
39
52
|
(0, fs_1.appendFileSync)(DEBUG_LOG_PATH, `${timestamp} ${message}\n`);
|
|
40
53
|
}
|
|
41
54
|
}
|
|
42
|
-
|
|
43
|
-
* Create a timeout wrapper for FFmpeg commands
|
|
44
|
-
*/
|
|
45
|
-
function createTimeoutPromise(promise, timeoutMs, operationName, cleanup) {
|
|
46
|
-
return new Promise((resolve, reject) => {
|
|
47
|
-
const timeoutId = setTimeout(() => {
|
|
48
|
-
if (cleanup)
|
|
49
|
-
cleanup();
|
|
50
|
-
reject(new Error(`[${operationName}] Operation timed out after ${timeoutMs / 1000}s`));
|
|
51
|
-
}, timeoutMs);
|
|
52
|
-
promise
|
|
53
|
-
.then((result) => {
|
|
54
|
-
clearTimeout(timeoutId);
|
|
55
|
-
resolve(result);
|
|
56
|
-
})
|
|
57
|
-
.catch((error) => {
|
|
58
|
-
clearTimeout(timeoutId);
|
|
59
|
-
reject(error);
|
|
60
|
-
});
|
|
61
|
-
});
|
|
62
|
-
}
|
|
63
|
-
/**
|
|
64
|
-
* Check if a command is available in the system
|
|
65
|
-
*/
|
|
66
|
-
function isCommandAvailable(command) {
|
|
67
|
-
try {
|
|
68
|
-
(0, child_process_1.execSync)(`which ${command}`, { stdio: 'ignore' });
|
|
69
|
-
return true;
|
|
70
|
-
}
|
|
71
|
-
catch {
|
|
72
|
-
return false;
|
|
73
|
-
}
|
|
74
|
-
}
|
|
75
|
-
/**
|
|
76
|
-
* Fix permissions for npm-installed binaries
|
|
77
|
-
*/
|
|
78
|
-
function fixBinaryPermissions(binaryPath) {
|
|
79
|
-
try {
|
|
80
|
-
if ((0, fs_1.existsSync)(binaryPath)) {
|
|
81
|
-
(0, child_process_1.execSync)(`chmod +x "${binaryPath}"`, { stdio: 'ignore' });
|
|
82
|
-
return true;
|
|
83
|
-
}
|
|
84
|
-
}
|
|
85
|
-
catch (error) {
|
|
86
|
-
debugLog(`[VideoComposer] Failed to fix permissions for ${binaryPath}: ${error}`);
|
|
87
|
-
}
|
|
88
|
-
return false;
|
|
89
|
-
}
|
|
90
|
-
// Set FFmpeg and FFprobe paths with intelligent fallback strategy
|
|
91
|
-
// Priority: 1. System binaries (most reliable in Docker)
|
|
92
|
-
// 2. npm packages with permission fix
|
|
93
|
-
// 3. Graceful degradation
|
|
55
|
+
// Set FFmpeg and FFprobe paths with validation
|
|
94
56
|
try {
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
//
|
|
98
|
-
if (
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
}
|
|
103
|
-
if (isCommandAvailable('ffprobe')) {
|
|
104
|
-
ffprobePath = 'ffprobe';
|
|
105
|
-
console.log('[VideoComposer] ✅ Using system ffprobe');
|
|
106
|
-
debugLog('[VideoComposer] Using system ffprobe');
|
|
107
|
-
}
|
|
108
|
-
// STRATEGY 2: Try npm-installed binaries if system binaries not available
|
|
109
|
-
if (!ffmpegPath || !ffprobePath) {
|
|
57
|
+
const ffmpegPath = ffmpeg_1.default.path;
|
|
58
|
+
const ffprobePath = ffprobeInstaller.path;
|
|
59
|
+
// Validate that binaries actually exist (critical for n8n environment)
|
|
60
|
+
if (!(0, fs_1.existsSync)(ffmpegPath)) {
|
|
61
|
+
console.error(`[VideoComposer] FFmpeg binary not found at: ${ffmpegPath}`);
|
|
62
|
+
debugLog(`[VideoComposer] FFmpeg binary missing: ${ffmpegPath}`);
|
|
63
|
+
// Try system ffmpeg as fallback
|
|
110
64
|
try {
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
const ffprobeInstaller = require('@ffprobe-installer/ffprobe');
|
|
115
|
-
if (!ffmpegPath && ffmpegInstaller.path) {
|
|
116
|
-
if ((0, fs_1.existsSync)(ffmpegInstaller.path)) {
|
|
117
|
-
// Try to fix permissions
|
|
118
|
-
fixBinaryPermissions(ffmpegInstaller.path);
|
|
119
|
-
ffmpegPath = ffmpegInstaller.path;
|
|
120
|
-
console.log(`[VideoComposer] ✅ Using npm ffmpeg: ${ffmpegPath}`);
|
|
121
|
-
debugLog(`[VideoComposer] Using npm ffmpeg: ${ffmpegPath}`);
|
|
122
|
-
}
|
|
123
|
-
}
|
|
124
|
-
if (!ffprobePath && ffprobeInstaller.path) {
|
|
125
|
-
if ((0, fs_1.existsSync)(ffprobeInstaller.path)) {
|
|
126
|
-
// Try to fix permissions
|
|
127
|
-
fixBinaryPermissions(ffprobeInstaller.path);
|
|
128
|
-
ffprobePath = ffprobeInstaller.path;
|
|
129
|
-
console.log(`[VideoComposer] ✅ Using npm ffprobe: ${ffprobePath}`);
|
|
130
|
-
debugLog(`[VideoComposer] Using npm ffprobe: ${ffprobePath}`);
|
|
131
|
-
}
|
|
132
|
-
}
|
|
65
|
+
fluent_ffmpeg_1.default.setFfmpegPath('ffmpeg');
|
|
66
|
+
console.warn('[VideoComposer] Using system ffmpeg as fallback');
|
|
67
|
+
debugLog('[VideoComposer] Using system ffmpeg as fallback');
|
|
133
68
|
}
|
|
134
|
-
catch (
|
|
135
|
-
|
|
136
|
-
debugLog(`[VideoComposer] npm packages unavailable: ${npmError}`);
|
|
69
|
+
catch (systemError) {
|
|
70
|
+
throw new Error(`FFmpeg binary not found at ${ffmpegPath} and system ffmpeg unavailable`);
|
|
137
71
|
}
|
|
138
72
|
}
|
|
139
|
-
// Set the paths in fluent-ffmpeg
|
|
140
|
-
if (ffmpegPath) {
|
|
141
|
-
fluent_ffmpeg_1.default.setFfmpegPath(ffmpegPath);
|
|
142
|
-
}
|
|
143
73
|
else {
|
|
144
|
-
|
|
145
|
-
|
|
74
|
+
fluent_ffmpeg_1.default.setFfmpegPath(ffmpegPath);
|
|
75
|
+
console.log(`[VideoComposer] ✅ FFmpeg verified: ${ffmpegPath}`);
|
|
76
|
+
debugLog(`[VideoComposer] FFmpeg path set and verified: ${ffmpegPath}`);
|
|
146
77
|
}
|
|
147
|
-
if (ffprobePath) {
|
|
148
|
-
|
|
78
|
+
if (!(0, fs_1.existsSync)(ffprobePath)) {
|
|
79
|
+
console.error(`[VideoComposer] FFprobe binary not found at: ${ffprobePath}`);
|
|
80
|
+
debugLog(`[VideoComposer] FFprobe binary missing: ${ffprobePath}`);
|
|
81
|
+
// Try system ffprobe as fallback
|
|
82
|
+
try {
|
|
83
|
+
fluent_ffmpeg_1.default.setFfprobePath('ffprobe');
|
|
84
|
+
console.warn('[VideoComposer] Using system ffprobe as fallback');
|
|
85
|
+
debugLog('[VideoComposer] Using system ffprobe as fallback');
|
|
86
|
+
}
|
|
87
|
+
catch (systemError) {
|
|
88
|
+
console.warn('[VideoComposer] System ffprobe also unavailable, metadata detection will be limited');
|
|
89
|
+
debugLog('[VideoComposer] System ffprobe unavailable, will use fallback metadata');
|
|
90
|
+
// Don't throw - we'll handle this gracefully in getVideoMetadata
|
|
91
|
+
}
|
|
149
92
|
}
|
|
150
93
|
else {
|
|
151
|
-
|
|
152
|
-
|
|
94
|
+
fluent_ffmpeg_1.default.setFfprobePath(ffprobePath);
|
|
95
|
+
console.log(`[VideoComposer] ✅ FFprobe verified: ${ffprobePath}`);
|
|
96
|
+
debugLog(`[VideoComposer] FFprobe path set and verified: ${ffprobePath}`);
|
|
153
97
|
}
|
|
154
|
-
// Log final configuration
|
|
155
|
-
console.log('[VideoComposer] 🎬 FFmpeg configuration complete');
|
|
156
|
-
debugLog(`[VideoComposer] Final config - FFmpeg: ${ffmpegPath || 'none'}, FFprobe: ${ffprobePath || 'none'}`);
|
|
157
98
|
}
|
|
158
99
|
catch (error) {
|
|
159
|
-
console.error('[VideoComposer]
|
|
100
|
+
console.error('[VideoComposer] CRITICAL: Failed to initialize FFmpeg/FFprobe:', error);
|
|
160
101
|
debugLog(`[VideoComposer] Initialization error: ${error}`);
|
|
102
|
+
// Final fallback: try system binaries
|
|
103
|
+
try {
|
|
104
|
+
fluent_ffmpeg_1.default.setFfmpegPath('ffmpeg');
|
|
105
|
+
fluent_ffmpeg_1.default.setFfprobePath('ffprobe');
|
|
106
|
+
console.warn('[VideoComposer] Using system ffmpeg/ffprobe binaries as last resort');
|
|
107
|
+
debugLog('[VideoComposer] Using system binaries as last resort');
|
|
108
|
+
}
|
|
109
|
+
catch (systemError) {
|
|
110
|
+
console.error('[VideoComposer] No FFmpeg/FFprobe available - operations will be limited');
|
|
111
|
+
debugLog('[VideoComposer] No FFmpeg available - critical error');
|
|
112
|
+
}
|
|
161
113
|
}
|
|
162
114
|
/**
|
|
163
115
|
* VideoComposer Service
|
|
@@ -184,8 +136,7 @@ class VideoComposer {
|
|
|
184
136
|
// __dirname is dist/nodes/SbRender/services, go up 4 levels to package root
|
|
185
137
|
const fontsDir = (0, path_1.join)((0, path_1.dirname)((0, path_1.dirname)((0, path_1.dirname)((0, path_1.dirname)(__dirname)))), 'fonts');
|
|
186
138
|
const escapedFontsDir = fontsDir.replace(/\\/g, '/').replace(/:/g, '\\:');
|
|
187
|
-
|
|
188
|
-
videoFilters.push(`subtitles=${escapedPath}:fontsdir=${escapedFontsDir}`);
|
|
139
|
+
videoFilters.push(`ass=${escapedPath}:fontsdir=${escapedFontsDir}`);
|
|
189
140
|
}
|
|
190
141
|
// Apply video filters if any
|
|
191
142
|
if (videoFilters.length > 0) {
|
|
@@ -255,83 +206,79 @@ class VideoComposer {
|
|
|
255
206
|
* Compose with complex audio mixing
|
|
256
207
|
*/
|
|
257
208
|
async composeWithAudioMix(videoPath, bgmPath, narrationPath, subtitlePath, audioFilterChain, outputPath, config) {
|
|
258
|
-
//
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
}
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
debugLog(`[ComposeAudioMix] Video metadata: ${JSON.stringify(videoMetadata)}`);
|
|
209
|
+
// Get video duration with better error handling for n8n
|
|
210
|
+
let videoMetadata;
|
|
211
|
+
try {
|
|
212
|
+
videoMetadata = await this.getVideoMetadata(videoPath);
|
|
213
|
+
console.log(`[ComposeAudioMix] Video metadata: duration=${videoMetadata.duration}s, hasAudio=${videoMetadata.hasAudio}`);
|
|
214
|
+
debugLog(`[ComposeAudioMix] Video metadata: ${JSON.stringify(videoMetadata)}`);
|
|
215
|
+
}
|
|
216
|
+
catch (error) {
|
|
217
|
+
console.warn('[ComposeAudioMix] Failed to get video metadata, using fallback duration detection');
|
|
218
|
+
debugLog(`[ComposeAudioMix] Metadata detection failed: ${error}`);
|
|
219
|
+
// Fallback: Use ffprobe directly on video to get duration
|
|
220
|
+
try {
|
|
221
|
+
const fallbackDuration = await this.getFallbackDuration(videoPath);
|
|
222
|
+
videoMetadata = {
|
|
223
|
+
duration: fallbackDuration,
|
|
224
|
+
width: 1920,
|
|
225
|
+
height: 1080,
|
|
226
|
+
hasAudio: true, // Assume audio exists in n8n to preserve it
|
|
227
|
+
videoCodec: 'unknown'
|
|
228
|
+
};
|
|
229
|
+
console.log(`[ComposeAudioMix] Using fallback duration: ${fallbackDuration}s`);
|
|
230
|
+
}
|
|
231
|
+
catch (fallbackError) {
|
|
232
|
+
console.warn('[ComposeAudioMix] Fallback duration detection also failed, using 30s default');
|
|
233
|
+
videoMetadata = {
|
|
234
|
+
duration: 30, // Conservative default for multiple merged videos
|
|
235
|
+
width: 1920,
|
|
236
|
+
height: 1080,
|
|
237
|
+
hasAudio: true,
|
|
238
|
+
videoCodec: 'unknown'
|
|
239
|
+
};
|
|
240
|
+
}
|
|
241
|
+
}
|
|
292
242
|
const videoDuration = videoMetadata.duration;
|
|
293
|
-
//
|
|
294
|
-
let
|
|
295
|
-
if (
|
|
243
|
+
// Get BGM duration if exists
|
|
244
|
+
let bgmDuration = 0;
|
|
245
|
+
if (bgmPath) {
|
|
296
246
|
try {
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
247
|
+
bgmDuration = await this.getAudioDuration(bgmPath);
|
|
248
|
+
console.log(`[ComposeAudioMix] BGM duration: ${bgmDuration}s, video duration: ${videoDuration}s`);
|
|
249
|
+
debugLog(`[ComposeAudioMix] BGM duration: ${bgmDuration}s`);
|
|
250
|
+
}
|
|
251
|
+
catch (error) {
|
|
252
|
+
console.warn('Failed to get BGM duration:', error);
|
|
253
|
+
bgmDuration = 180; // Default 3 minutes
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
// Get narration duration if exists
|
|
257
|
+
let narrationDuration = 0;
|
|
258
|
+
if (narrationPath) {
|
|
259
|
+
try {
|
|
260
|
+
const narrationMetadata = await this.getAudioDuration(narrationPath);
|
|
261
|
+
narrationDuration = narrationMetadata;
|
|
262
|
+
console.log(`[ComposeAudioMix] Narration duration: ${narrationDuration}s`);
|
|
302
263
|
}
|
|
303
264
|
catch (error) {
|
|
304
|
-
console.warn('Failed to get
|
|
265
|
+
console.warn('Failed to get narration duration:', error);
|
|
305
266
|
}
|
|
306
267
|
}
|
|
307
|
-
// Calculate
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
const safeNarrationDuration = isNaN(narrationDuration) || narrationDuration < 0 ? 0 : narrationDuration;
|
|
311
|
-
const maxDuration = Math.max(safeVideoDuration, safeNarrationDuration) + 10;
|
|
312
|
-
console.log(`[ComposeAudioMix] Max duration for BGM: ${maxDuration}s (video: ${safeVideoDuration}s, narration: ${safeNarrationDuration}s)`);
|
|
268
|
+
// Calculate effective duration (max of video and narration) to ensure BGM covers the whole duration
|
|
269
|
+
const effectiveDuration = Math.max(videoDuration, narrationDuration);
|
|
270
|
+
console.log(`[ComposeAudioMix] Effective duration for BGM: ${effectiveDuration}s (Video: ${videoDuration}s, Narration: ${narrationDuration}s)`);
|
|
313
271
|
return new Promise((resolve, reject) => {
|
|
314
272
|
try {
|
|
315
273
|
const command = (0, fluent_ffmpeg_1.default)(videoPath);
|
|
316
|
-
// Add BGM input with
|
|
274
|
+
// Add BGM input with simple approach
|
|
317
275
|
if (bgmPath) {
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
let requiredLoops = 10; // Default minimum
|
|
322
|
-
if (safeBgmDuration > 0 && maxDuration > 0) {
|
|
323
|
-
requiredLoops = Math.ceil((maxDuration / safeBgmDuration) * 1.1); // 10% buffer
|
|
324
|
-
requiredLoops = Math.max(requiredLoops, 1); // At least 1 loop
|
|
325
|
-
requiredLoops = Math.min(requiredLoops, CONFIG.MAX_BGM_LOOPS); // Cap to prevent memory issues
|
|
326
|
-
}
|
|
327
|
-
// Ensure maxDuration is valid for -t option
|
|
328
|
-
const safeTrimDuration = isNaN(maxDuration) || maxDuration <= 0 ? CONFIG.DEFAULT_DURATION + 10 : maxDuration;
|
|
329
|
-
console.log(`[ComposeAudioMix] Adding BGM input: duration=${safeBgmDuration}s, maxNeeded=${safeTrimDuration}s, loops=${requiredLoops}`);
|
|
330
|
-
debugLog(`[ComposeAudioMix] BGM strategy: dynamic loop calculation`);
|
|
331
|
-
// Use calculated loops to ensure BGM covers entire duration
|
|
276
|
+
console.log(`[ComposeAudioMix] Adding BGM input for ${effectiveDuration}s video`);
|
|
277
|
+
debugLog(`[ComposeAudioMix] BGM strategy: simple input mapping`);
|
|
278
|
+
// Use simple input approach without complex looping
|
|
332
279
|
command.input(bgmPath).inputOptions([
|
|
333
|
-
'-stream_loop',
|
|
334
|
-
'-t',
|
|
280
|
+
'-stream_loop', '-1', // Infinite loop to cover any duration
|
|
281
|
+
'-t', (effectiveDuration + 10).toString() // Buffer time based on effective duration
|
|
335
282
|
]);
|
|
336
283
|
}
|
|
337
284
|
// Add narration input
|
|
@@ -360,14 +307,6 @@ class VideoComposer {
|
|
|
360
307
|
finalAudioFilterChain = audioMixer.getAudioFilterChain(audioConfig, videoMetadata.hasAudio);
|
|
361
308
|
console.log(`[ComposeAudioMix] Generated filter chain: "${finalAudioFilterChain}"`);
|
|
362
309
|
debugLog(`[ComposeAudioMix] Generated filter chain: ${finalAudioFilterChain}`);
|
|
363
|
-
// If Half Frame Rate is enabled, pad audio to match doubled video duration
|
|
364
|
-
if (config.halfFrameRate && finalAudioFilterChain && finalAudioFilterChain.includes('[mixed]')) {
|
|
365
|
-
const paddedVideoDuration = videoDuration * 2;
|
|
366
|
-
// Add apad filter to extend audio with silence to match video duration
|
|
367
|
-
finalAudioFilterChain = finalAudioFilterChain.replace('[mixed]', `,apad=whole_dur=${paddedVideoDuration}[mixed]`);
|
|
368
|
-
console.log(`[ComposeAudioMix] Padding audio to ${paddedVideoDuration}s for Half Frame Rate`);
|
|
369
|
-
debugLog(`[ComposeAudioMix] Audio padding filter added: whole_dur=${paddedVideoDuration}`);
|
|
370
|
-
}
|
|
371
310
|
}
|
|
372
311
|
// Apply complex audio filter with fallback to simple approach
|
|
373
312
|
if (finalAudioFilterChain && finalAudioFilterChain.trim() !== '') {
|
|
@@ -424,33 +363,15 @@ class VideoComposer {
|
|
|
424
363
|
}
|
|
425
364
|
// Video filters
|
|
426
365
|
const videoFilters = [];
|
|
427
|
-
//
|
|
428
|
-
let targetVideoDuration = videoDuration;
|
|
366
|
+
// Half frame rate if enabled (doubles duration)
|
|
429
367
|
if (config.halfFrameRate) {
|
|
430
|
-
//
|
|
368
|
+
// Slow down video by doubling PTS and maintaining consistent frame timing
|
|
431
369
|
videoFilters.push('setpts=2.0*PTS');
|
|
432
|
-
targetVideoDuration = videoDuration * 2;
|
|
433
|
-
console.log(`[ComposeAudioMix] Half frame rate: ${videoDuration}s → ${targetVideoDuration}s (setpts=2.0*PTS)`);
|
|
434
|
-
debugLog(`[ComposeAudioMix] Video PTS doubled for half frame rate`);
|
|
435
|
-
}
|
|
436
|
-
else if (config.syncToAudio && narrationDuration > 0) {
|
|
437
|
-
// Sync to audio: stretch/compress video to match narration duration using setpts
|
|
438
|
-
// setpts multiplier: >1 = slower/longer, <1 = faster/shorter
|
|
439
|
-
const ptsFactor = narrationDuration / videoDuration;
|
|
440
|
-
videoFilters.push(`setpts=${ptsFactor.toFixed(4)}*PTS`);
|
|
441
|
-
targetVideoDuration = narrationDuration;
|
|
442
|
-
console.log(`[ComposeAudioMix] Sync to audio: ${videoDuration}s → ${targetVideoDuration}s (setpts=${ptsFactor.toFixed(4)}*PTS)`);
|
|
443
|
-
debugLog(`[ComposeAudioMix] Video PTS adjustment: ${ptsFactor.toFixed(4)}x for audio sync`);
|
|
444
370
|
}
|
|
445
|
-
// If narration is longer than
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
// Validate freezeDuration to prevent NaN errors
|
|
450
|
-
if (!isNaN(freezeDuration) && freezeDuration > 0) {
|
|
451
|
-
videoFilters.push(`tpad=stop_mode=clone:stop_duration=${freezeDuration.toFixed(3)}`);
|
|
452
|
-
console.log(`[ComposeAudioMix] Extending video with freeze frame: +${freezeDuration.toFixed(3)}s`);
|
|
453
|
-
}
|
|
371
|
+
// If narration is longer than video AND sync enabled, freeze last frame
|
|
372
|
+
if (config.syncToAudio && narrationDuration > videoDuration) {
|
|
373
|
+
const freezeDuration = narrationDuration - videoDuration;
|
|
374
|
+
videoFilters.push(`tpad=stop_mode=clone:stop_duration=${freezeDuration}`);
|
|
454
375
|
}
|
|
455
376
|
// Add subtitle overlay if present
|
|
456
377
|
if (subtitlePath) {
|
|
@@ -458,8 +379,7 @@ class VideoComposer {
|
|
|
458
379
|
// __dirname is dist/nodes/SbRender/services, go up 4 levels to package root
|
|
459
380
|
const fontsDir = (0, path_1.join)((0, path_1.dirname)((0, path_1.dirname)((0, path_1.dirname)((0, path_1.dirname)(__dirname)))), 'fonts');
|
|
460
381
|
const escapedFontsDir = fontsDir.replace(/\\/g, '/').replace(/:/g, '\\:');
|
|
461
|
-
|
|
462
|
-
videoFilters.push(`subtitles=${escapedPath}:fontsdir=${escapedFontsDir}`);
|
|
382
|
+
videoFilters.push(`ass=${escapedPath}:fontsdir=${escapedFontsDir}`);
|
|
463
383
|
}
|
|
464
384
|
if (videoFilters.length > 0) {
|
|
465
385
|
command.videoFilters(videoFilters);
|
|
@@ -471,8 +391,10 @@ class VideoComposer {
|
|
|
471
391
|
'-preset medium',
|
|
472
392
|
'-movflags +faststart',
|
|
473
393
|
];
|
|
474
|
-
//
|
|
475
|
-
|
|
394
|
+
// Add explicit frame rate for half frame rate mode to ensure proper playback
|
|
395
|
+
if (config.halfFrameRate) {
|
|
396
|
+
outputOptions.push('-r 24');
|
|
397
|
+
}
|
|
476
398
|
// Map video and mixed audio with safe fallback
|
|
477
399
|
if (finalAudioFilterChain && finalAudioFilterChain.includes('[mixed]')) {
|
|
478
400
|
// Complex filter was successfully applied
|
|
@@ -488,17 +410,12 @@ class VideoComposer {
|
|
|
488
410
|
}
|
|
489
411
|
else if (narrationPath && !bgmPath) {
|
|
490
412
|
// Narration only - map narration audio and video
|
|
491
|
-
|
|
492
|
-
outputOptions.unshift(
|
|
493
|
-
}
|
|
494
|
-
else if (bgmPath && narrationPath) {
|
|
495
|
-
// Both BGM and narration - Input 0: video, Input 1: BGM, Input 2: narration
|
|
496
|
-
// Use amix filter for proper mixing, but fallback maps BGM (input 1)
|
|
497
|
-
outputOptions.unshift('-map 1:a', '-map 0:v');
|
|
413
|
+
const narrationIndex = videoMetadata.hasAudio ? 1 : 1;
|
|
414
|
+
outputOptions.unshift(`-map ${narrationIndex}:a`, '-map 0:v');
|
|
498
415
|
}
|
|
499
416
|
else {
|
|
500
|
-
//
|
|
501
|
-
outputOptions.unshift('-map
|
|
417
|
+
// Both BGM and narration - use first audio input (BGM)
|
|
418
|
+
outputOptions.unshift('-map 1:a', '-map 0:v');
|
|
502
419
|
}
|
|
503
420
|
}
|
|
504
421
|
else {
|
|
@@ -580,41 +497,6 @@ class VideoComposer {
|
|
|
580
497
|
});
|
|
581
498
|
});
|
|
582
499
|
}
|
|
583
|
-
/**
|
|
584
|
-
* Get video's audio track duration
|
|
585
|
-
* This is useful when video has audio that might be longer than video duration
|
|
586
|
-
*/
|
|
587
|
-
async getVideoAudioDuration(videoPath) {
|
|
588
|
-
return new Promise((resolve, reject) => {
|
|
589
|
-
fluent_ffmpeg_1.default.ffprobe(videoPath, (error, metadata) => {
|
|
590
|
-
var _a, _b;
|
|
591
|
-
if (error) {
|
|
592
|
-
reject(new Error(`Failed to get video audio duration: ${error.message}`));
|
|
593
|
-
return;
|
|
594
|
-
}
|
|
595
|
-
// Find audio stream and get its duration
|
|
596
|
-
const audioStream = (_a = metadata.streams) === null || _a === void 0 ? void 0 : _a.find((stream) => stream.codec_type === 'audio');
|
|
597
|
-
if (audioStream && audioStream.duration !== undefined) {
|
|
598
|
-
const duration = typeof audioStream.duration === 'string'
|
|
599
|
-
? parseFloat(audioStream.duration)
|
|
600
|
-
: audioStream.duration;
|
|
601
|
-
if (!isNaN(duration) && duration > 0) {
|
|
602
|
-
resolve(duration);
|
|
603
|
-
return;
|
|
604
|
-
}
|
|
605
|
-
}
|
|
606
|
-
// Fallback to format duration with validation
|
|
607
|
-
const formatDuration = (_b = metadata.format) === null || _b === void 0 ? void 0 : _b.duration;
|
|
608
|
-
if (formatDuration && !isNaN(formatDuration) && formatDuration > 0) {
|
|
609
|
-
resolve(formatDuration);
|
|
610
|
-
}
|
|
611
|
-
else {
|
|
612
|
-
console.warn('[VideoAudioDuration] Unable to determine audio duration, returning 0');
|
|
613
|
-
resolve(0);
|
|
614
|
-
}
|
|
615
|
-
});
|
|
616
|
-
});
|
|
617
|
-
}
|
|
618
500
|
/**
|
|
619
501
|
* Get video metadata (duration, resolution, codec)
|
|
620
502
|
*/
|
|
@@ -624,8 +506,16 @@ class VideoComposer {
|
|
|
624
506
|
console.log(logMsg);
|
|
625
507
|
// Also write to file for n8n debugging
|
|
626
508
|
debugLog(`${logMsg}`);
|
|
627
|
-
//
|
|
628
|
-
//
|
|
509
|
+
// Try to use ffprobe
|
|
510
|
+
// Ensure path is set in case n8n environment is different
|
|
511
|
+
try {
|
|
512
|
+
fluent_ffmpeg_1.default.setFfprobePath(ffprobeInstaller.path);
|
|
513
|
+
debugLog(`[Metadata] FFprobe path reconfirmed: ${ffprobeInstaller.path}`);
|
|
514
|
+
}
|
|
515
|
+
catch (e) {
|
|
516
|
+
console.warn('[Metadata] Could not set ffprobe path:', e);
|
|
517
|
+
debugLog(`[Metadata] FFprobe path setting failed: ${e}`);
|
|
518
|
+
}
|
|
629
519
|
fluent_ffmpeg_1.default.ffprobe(videoPath, (error, metadata) => {
|
|
630
520
|
var _a;
|
|
631
521
|
if (error) {
|
|
@@ -652,22 +542,8 @@ class VideoComposer {
|
|
|
652
542
|
}
|
|
653
543
|
const videoStream = metadata.streams.find((s) => s.codec_type === 'video');
|
|
654
544
|
const audioStream = metadata.streams.find((s) => s.codec_type === 'audio');
|
|
655
|
-
// Extract frame rate from video stream
|
|
656
|
-
let fps;
|
|
657
|
-
if (videoStream) {
|
|
658
|
-
// r_frame_rate is a fraction like "24/1" or "30000/1001"
|
|
659
|
-
const frameRateStr = videoStream.r_frame_rate || videoStream.avg_frame_rate;
|
|
660
|
-
if (frameRateStr) {
|
|
661
|
-
const [num, den] = frameRateStr.split('/').map(Number);
|
|
662
|
-
if (den && den > 0) {
|
|
663
|
-
fps = num / den;
|
|
664
|
-
console.log(`[Metadata] Detected frame rate: ${fps.toFixed(2)}fps (${frameRateStr})`);
|
|
665
|
-
debugLog(`[Metadata] Frame rate: ${fps}fps from ${frameRateStr}`);
|
|
666
|
-
}
|
|
667
|
-
}
|
|
668
|
-
}
|
|
669
545
|
const streamInfo = {
|
|
670
|
-
videoStream: videoStream ? `${videoStream.codec_name} ${videoStream.width}x${videoStream.height}
|
|
546
|
+
videoStream: videoStream ? `${videoStream.codec_name} ${videoStream.width}x${videoStream.height}` : 'none',
|
|
671
547
|
audioStream: audioStream ? `${audioStream.codec_name} channels=${audioStream.channels}` : 'none'
|
|
672
548
|
};
|
|
673
549
|
console.log(`[Metadata] Streams found:`, streamInfo);
|
|
@@ -689,7 +565,6 @@ class VideoComposer {
|
|
|
689
565
|
hasAudio: hasValidAudio,
|
|
690
566
|
videoCodec: 'unknown',
|
|
691
567
|
audioCodec: audioStream === null || audioStream === void 0 ? void 0 : audioStream.codec_name,
|
|
692
|
-
fps: undefined,
|
|
693
568
|
});
|
|
694
569
|
return;
|
|
695
570
|
}
|
|
@@ -700,7 +575,6 @@ class VideoComposer {
|
|
|
700
575
|
hasAudio: hasValidAudio,
|
|
701
576
|
videoCodec: videoStream.codec_name || 'unknown',
|
|
702
577
|
audioCodec: audioStream === null || audioStream === void 0 ? void 0 : audioStream.codec_name,
|
|
703
|
-
fps: fps,
|
|
704
578
|
};
|
|
705
579
|
console.log(`[Metadata] ✅ Result:`, result);
|
|
706
580
|
resolve(result);
|
|
@@ -779,7 +653,7 @@ class VideoComposer {
|
|
|
779
653
|
const hasMixedAudio = !allHaveAudio && hasAudio.some(has => has);
|
|
780
654
|
if (allHaveAudio || hasMixedAudio) {
|
|
781
655
|
// All videos have audio OR mixed audio - normalize video and ensure audio for all
|
|
782
|
-
const scaleFilters = videoPaths.map((_, index) => `[${index}:v]scale=1920:1080:force_original_aspect_ratio=
|
|
656
|
+
const scaleFilters = videoPaths.map((_, index) => `[${index}:v]scale=1920:1080:force_original_aspect_ratio=decrease,pad=1920:1080:(ow-iw)/2:(oh-ih)/2,setsar=1,fps=24[v${index}]`).join(';');
|
|
783
657
|
// Prepare audio streams
|
|
784
658
|
const audioStreams = [];
|
|
785
659
|
let audioFilters = '';
|
|
@@ -792,11 +666,9 @@ class VideoComposer {
|
|
|
792
666
|
// Generate silent audio for this video using anullsrc
|
|
793
667
|
// We use the video duration to trim the silence
|
|
794
668
|
const duration = videoMetadataList[index].duration;
|
|
795
|
-
// Validate duration to prevent NaN errors in FFmpeg
|
|
796
|
-
const safeDuration = isNaN(duration) || duration <= 0 ? CONFIG.DEFAULT_DURATION : duration;
|
|
797
669
|
// anullsrc generates infinite silence, we trim it to video duration
|
|
798
670
|
// We use a unique label for this silence stream
|
|
799
|
-
audioFilters += `anullsrc=r=44100:cl=stereo,atrim=duration=${
|
|
671
|
+
audioFilters += `anullsrc=r=44100:cl=stereo,atrim=duration=${duration}[silence${index}];`;
|
|
800
672
|
audioStreams.push(`[silence${index}]`);
|
|
801
673
|
}
|
|
802
674
|
});
|
|
@@ -808,7 +680,7 @@ class VideoComposer {
|
|
|
808
680
|
}
|
|
809
681
|
else {
|
|
810
682
|
// No videos have audio - normalize and concat video only
|
|
811
|
-
const scaleFilters = videoPaths.map((_, index) => `[${index}:v]scale=1920:1080:force_original_aspect_ratio=
|
|
683
|
+
const scaleFilters = videoPaths.map((_, index) => `[${index}:v]scale=1920:1080:force_original_aspect_ratio=decrease,pad=1920:1080:(ow-iw)/2:(oh-ih)/2,setsar=1,fps=24[v${index}]`).join(';');
|
|
812
684
|
const videoStreams = videoPaths.map((_, index) => `[v${index}]`).join('');
|
|
813
685
|
filterString = `${scaleFilters};${videoStreams}concat=n=${videoPaths.length}:v=1:a=0[outv]`;
|
|
814
686
|
}
|
|
@@ -885,19 +757,16 @@ class VideoComposer {
|
|
|
885
757
|
const command = (0, fluent_ffmpeg_1.default)();
|
|
886
758
|
// Add all images as inputs with loop and duration
|
|
887
759
|
imagePaths.forEach((imagePath, index) => {
|
|
888
|
-
// Validate duration to prevent NaN errors in FFmpeg
|
|
889
|
-
const duration = durations[index];
|
|
890
|
-
const safeDuration = isNaN(duration) || duration <= 0 ? CONFIG.DEFAULT_DURATION : duration;
|
|
891
760
|
command
|
|
892
761
|
.input(imagePath)
|
|
893
762
|
.inputOptions([
|
|
894
763
|
'-loop 1',
|
|
895
|
-
`-t ${
|
|
764
|
+
`-t ${durations[index]}`,
|
|
896
765
|
]);
|
|
897
766
|
});
|
|
898
767
|
// Build filter to scale all images to 1920x1080 and concat
|
|
899
|
-
// Each image is scaled to
|
|
900
|
-
const scaleFilters = imagePaths.map((_, index) => `[${index}:v]scale=1920:1080:force_original_aspect_ratio=
|
|
768
|
+
// Each image is scaled to fit within 1920x1080 with padding (black bars) if needed
|
|
769
|
+
const scaleFilters = imagePaths.map((_, index) => `[${index}:v]scale=1920:1080:force_original_aspect_ratio=decrease,pad=1920:1080:(ow-iw)/2:(oh-ih)/2,setsar=1,fps=24[v${index}]`).join(';');
|
|
901
770
|
const concatInputs = imagePaths.map((_, index) => `[v${index}]`).join('');
|
|
902
771
|
const filterString = `${scaleFilters};${concatInputs}concat=n=${imagePaths.length}:v=1:a=0[outv]`;
|
|
903
772
|
console.log(`FFmpeg image to video filter: scale and concat`);
|