n8n-nodes-sb-render 1.6.2 → 1.6.4
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 +63 -0
- package/dist/nodes/SbRender/services/ParallaxEngine.d.ts +3 -0
- package/dist/nodes/SbRender/services/ParallaxEngine.d.ts.map +1 -1
- package/dist/nodes/SbRender/services/ParallaxEngine.js +157 -263
- package/dist/nodes/SbRender/services/ParallaxEngine.js.map +1 -1
- package/dist/nodes/SbRender/services/VideoComposer.d.ts.map +1 -1
- package/dist/nodes/SbRender/services/VideoComposer.js +117 -81
- package/dist/nodes/SbRender/services/VideoComposer.js.map +1 -1
- package/package.json +1 -1
- package/scripts/__pycache__/depth_parallax.cpython-312.pyc +0 -0
- package/scripts/depth_parallax.py +373 -0
- package/scripts/restart-n8n.sh +14 -0
package/README.md
CHANGED
|
@@ -10,6 +10,8 @@ This is an n8n community node for video rendering with customizable subtitles, b
|
|
|
10
10
|
- 🎙️ Narration audio with timing control
|
|
11
11
|
- 📝 Customizable subtitles with extensive styling options
|
|
12
12
|
- 🎨 Multiple output formats and quality presets
|
|
13
|
+
- 🖼️ AI-powered parallax effects from static images (Depth Anything V2)
|
|
14
|
+
- 🔀 Video merging with transitions and audio sync
|
|
13
15
|
|
|
14
16
|
Inspired by [n8n-nodes-mediafx](https://github.com/dandacompany/n8n-nodes-mediafx).
|
|
15
17
|
|
|
@@ -18,6 +20,7 @@ Inspired by [n8n-nodes-mediafx](https://github.com/dandacompany/n8n-nodes-mediaf
|
|
|
18
20
|
- [Installation](#installation)
|
|
19
21
|
- [Prerequisites](#prerequisites)
|
|
20
22
|
- [Operations](#operations)
|
|
23
|
+
- [Parallax Effect](#parallax-effect)
|
|
21
24
|
- [Configuration](#configuration)
|
|
22
25
|
- [Examples](#examples)
|
|
23
26
|
- [Development](#development)
|
|
@@ -99,6 +102,66 @@ Compose a video with optional background music, narration, and subtitles.
|
|
|
99
102
|
- ✅ Quality presets (Low, Medium, High, Custom)
|
|
100
103
|
- ✅ Smart audio merging (handles mixed audio/silent video inputs)
|
|
101
104
|
|
|
105
|
+
### Image → Parallax
|
|
106
|
+
|
|
107
|
+
Create 2.5D parallax video effects from static images using AI depth estimation.
|
|
108
|
+
|
|
109
|
+
**Features:**
|
|
110
|
+
- ✅ AI-powered depth estimation (Depth Anything V2)
|
|
111
|
+
- ✅ True layer separation with inpainting
|
|
112
|
+
- ✅ Direction + Zoom combination effects
|
|
113
|
+
- ✅ Intensity control (subtle, normal, dramatic)
|
|
114
|
+
- ✅ Falls back to Ken Burns effect if AI not available
|
|
115
|
+
|
|
116
|
+
## Parallax Effect
|
|
117
|
+
|
|
118
|
+
The parallax feature uses **Depth Anything V2** AI model to create professional 2.5D parallax effects from static images.
|
|
119
|
+
|
|
120
|
+
### How It Works
|
|
121
|
+
|
|
122
|
+
1. **Depth Estimation**: AI model analyzes the image to create a depth map
|
|
123
|
+
2. **Layer Separation**: Foreground is extracted based on depth (closer objects)
|
|
124
|
+
3. **Inpainting**: Background is filled where foreground was removed
|
|
125
|
+
4. **Animation**: Layers move at different speeds creating parallax illusion
|
|
126
|
+
|
|
127
|
+
### Parameters
|
|
128
|
+
|
|
129
|
+
| Parameter | Type | Options | Description |
|
|
130
|
+
|-----------|------|---------|-------------|
|
|
131
|
+
| **direction** | String | `left`, `right`, `up`, `down` | Pan direction |
|
|
132
|
+
| **zoom** | String | `none`, `in`, `out` | Zoom effect (combinable with direction) |
|
|
133
|
+
| **intensity** | String | `subtle`, `normal`, `dramatic` | Movement intensity |
|
|
134
|
+
| **duration** | Number | seconds | Video duration |
|
|
135
|
+
|
|
136
|
+
### Combined Effects
|
|
137
|
+
|
|
138
|
+
You can combine zoom with direction for richer effects:
|
|
139
|
+
|
|
140
|
+
```json
|
|
141
|
+
{
|
|
142
|
+
"direction": "left",
|
|
143
|
+
"zoom": "in",
|
|
144
|
+
"intensity": "normal",
|
|
145
|
+
"duration": 5
|
|
146
|
+
}
|
|
147
|
+
```
|
|
148
|
+
|
|
149
|
+
**Effect Combinations:**
|
|
150
|
+
- `left` + `zoomIn`: Pan left while zooming in
|
|
151
|
+
- `right` + `zoomOut`: Pan right while zooming out
|
|
152
|
+
- `up` + `zoomIn`: Pan up with zoom in
|
|
153
|
+
- `zoomIn` only: Pure zoom in effect (set direction to empty)
|
|
154
|
+
|
|
155
|
+
### Requirements for AI Parallax
|
|
156
|
+
|
|
157
|
+
For AI-powered depth parallax, the following Python packages are required on the server:
|
|
158
|
+
|
|
159
|
+
```bash
|
|
160
|
+
pip3 install torch transformers pillow opencv-python-headless
|
|
161
|
+
```
|
|
162
|
+
|
|
163
|
+
If not available, the node automatically falls back to Ken Burns (zoompan) effect.
|
|
164
|
+
|
|
102
165
|
## Configuration
|
|
103
166
|
|
|
104
167
|
### Video Input
|
|
@@ -1,9 +1,11 @@
|
|
|
1
1
|
export type ParallaxDirection = 'left' | 'right' | 'up' | 'down' | 'zoomIn' | 'zoomOut';
|
|
2
2
|
export type ParallaxIntensity = 'subtle' | 'normal' | 'dramatic';
|
|
3
|
+
export type ParallaxZoom = 'none' | 'in' | 'out';
|
|
3
4
|
export interface ParallaxConfig {
|
|
4
5
|
direction: ParallaxDirection;
|
|
5
6
|
intensity: ParallaxIntensity;
|
|
6
7
|
layerCount?: number;
|
|
8
|
+
zoom?: ParallaxZoom;
|
|
7
9
|
}
|
|
8
10
|
export declare class ParallaxEngine {
|
|
9
11
|
private sharpAvailable;
|
|
@@ -19,6 +21,7 @@ export declare class ParallaxEngine {
|
|
|
19
21
|
/**
|
|
20
22
|
* Generate parallax video from a single image
|
|
21
23
|
* Main entry point for creating parallax effect videos
|
|
24
|
+
* Uses AI depth estimation for true parallax, falls back to Ken Burns
|
|
22
25
|
*/
|
|
23
26
|
generateParallaxVideo(imagePath: string, outputPath: string, config: ParallaxConfig, duration: number, fps?: number, _videoCodec?: string, _quality?: string, _customCRF?: number): Promise<Buffer>;
|
|
24
27
|
/**
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"ParallaxEngine.d.ts","sourceRoot":"","sources":["../../../../nodes/SbRender/services/ParallaxEngine.ts"],"names":[],"mappings":"
|
|
1
|
+
{"version":3,"file":"ParallaxEngine.d.ts","sourceRoot":"","sources":["../../../../nodes/SbRender/services/ParallaxEngine.ts"],"names":[],"mappings":"AAoIA,MAAM,MAAM,iBAAiB,GAAG,MAAM,GAAG,OAAO,GAAG,IAAI,GAAG,MAAM,GAAG,QAAQ,GAAG,SAAS,CAAC;AACxF,MAAM,MAAM,iBAAiB,GAAG,QAAQ,GAAG,QAAQ,GAAG,UAAU,CAAC;AACjE,MAAM,MAAM,YAAY,GAAG,MAAM,GAAG,IAAI,GAAG,KAAK,CAAC;AAEjD,MAAM,WAAW,cAAc;IAC7B,SAAS,EAAE,iBAAiB,CAAC;IAC7B,SAAS,EAAE,iBAAiB,CAAC;IAC7B,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB,IAAI,CAAC,EAAE,YAAY,CAAC;CACrB;AA8KD,qBAAa,cAAc;IACzB,OAAO,CAAC,cAAc,CAAS;IAC/B,OAAO,CAAC,WAAW,CAAS;IAE5B;;OAEG;IACG,UAAU,IAAI,OAAO,CAAC,IAAI,CAAC;IASjC;;OAEG;IACH,wBAAwB,IAAI,OAAO;IAInC;;;;OAIG;IACG,qBAAqB,CACzB,SAAS,EAAE,MAAM,EACjB,UAAU,EAAE,MAAM,EAClB,MAAM,EAAE,cAAc,EACtB,QAAQ,EAAE,MAAM,EAChB,GAAG,SAAK,EACR,WAAW,SAAY,EACvB,QAAQ,SAAS,EACjB,UAAU,CAAC,EAAE,MAAM,GAClB,OAAO,CAAC,MAAM,CAAC;IAyElB;;OAEG;IACG,sBAAsB,CAC1B,SAAS,EAAE,MAAM,EACjB,SAAS,EAAE,MAAM,EACjB,MAAM,EAAE,cAAc,EACtB,QAAQ,EAAE,MAAM,EAChB,GAAG,SAAK,GACP,OAAO,CAAC,MAAM,EAAE,CAAC;CAKrB;AAGD,eAAO,MAAM,cAAc,gBAAuB,CAAC"}
|
|
@@ -45,304 +45,181 @@ const fs = __importStar(require("fs"));
|
|
|
45
45
|
const path = __importStar(require("path"));
|
|
46
46
|
const ffmpeg_wrapper_1 = require("../utils/ffmpeg-wrapper");
|
|
47
47
|
const child_process_1 = require("child_process");
|
|
48
|
-
//
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
48
|
+
// Path to the depth parallax Python script
|
|
49
|
+
// In compiled code, __dirname is dist/nodes/SbRender/services/
|
|
50
|
+
// Script is at project_root/scripts/depth_parallax.py
|
|
51
|
+
const DEPTH_SCRIPT_PATH = path.join(__dirname, '../../../../scripts/depth_parallax.py');
|
|
52
|
+
// Python executable path - prefer mounted venv, fallback to system python3
|
|
53
|
+
const PYTHON_PATHS = ['/opt/venv/bin/python3', 'python3'];
|
|
54
|
+
let pythonPath = null;
|
|
55
|
+
// Check if Python depth estimation is available
|
|
56
|
+
let pythonDepthAvailable = null;
|
|
57
|
+
function findPythonPath() {
|
|
58
|
+
for (const pyPath of PYTHON_PATHS) {
|
|
59
|
+
try {
|
|
60
|
+
(0, child_process_1.execSync)(`${pyPath} -c "import torch; import transformers"`, { stdio: 'pipe' });
|
|
61
|
+
console.log(`[ParallaxEngine] Found working Python at: ${pyPath}`);
|
|
62
|
+
return pyPath;
|
|
63
|
+
}
|
|
64
|
+
catch {
|
|
65
|
+
// Try next path
|
|
66
|
+
}
|
|
61
67
|
}
|
|
68
|
+
return null;
|
|
62
69
|
}
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
case 'normal':
|
|
71
|
-
return { fgSpeed: 0.08, bgSpeed: 0.025, scaleRange: 0.04 };
|
|
72
|
-
case 'dramatic':
|
|
73
|
-
return { fgSpeed: 0.15, bgSpeed: 0.04, scaleRange: 0.08 };
|
|
74
|
-
default:
|
|
75
|
-
return { fgSpeed: 0.08, bgSpeed: 0.025, scaleRange: 0.04 };
|
|
70
|
+
async function checkPythonDepth() {
|
|
71
|
+
if (pythonDepthAvailable !== null)
|
|
72
|
+
return pythonDepthAvailable;
|
|
73
|
+
pythonPath = findPythonPath();
|
|
74
|
+
if (pythonPath && fs.existsSync(DEPTH_SCRIPT_PATH)) {
|
|
75
|
+
pythonDepthAvailable = true;
|
|
76
|
+
console.log('[ParallaxEngine] Python depth estimation available');
|
|
76
77
|
}
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
*/
|
|
82
|
-
function createGradientMask(width, height, type, layerIndex, totalLayers) {
|
|
83
|
-
// Create RGBA buffer
|
|
84
|
-
const buffer = Buffer.alloc(width * height * 4);
|
|
85
|
-
// Calculate layer boundaries
|
|
86
|
-
const layerHeight = height / totalLayers;
|
|
87
|
-
const layerStart = (totalLayers - 1 - layerIndex) * layerHeight; // Reverse: top layers are background
|
|
88
|
-
const layerEnd = layerStart + layerHeight;
|
|
89
|
-
// Feather zone for smooth transitions
|
|
90
|
-
const featherSize = layerHeight * 0.3;
|
|
91
|
-
for (let y = 0; y < height; y++) {
|
|
92
|
-
let alpha = 0;
|
|
93
|
-
if (y >= layerStart && y < layerEnd) {
|
|
94
|
-
// Inside layer
|
|
95
|
-
alpha = 255;
|
|
96
|
-
// Apply feathering at edges
|
|
97
|
-
if (y < layerStart + featherSize) {
|
|
98
|
-
alpha = Math.floor(((y - layerStart) / featherSize) * 255);
|
|
99
|
-
}
|
|
100
|
-
else if (y > layerEnd - featherSize) {
|
|
101
|
-
alpha = Math.floor(((layerEnd - y) / featherSize) * 255);
|
|
102
|
-
}
|
|
103
|
-
}
|
|
104
|
-
else if (y < layerStart && y > layerStart - featherSize) {
|
|
105
|
-
// Fade in from above
|
|
106
|
-
alpha = Math.floor(((layerStart - y) / featherSize) * 128);
|
|
107
|
-
}
|
|
108
|
-
else if (y >= layerEnd && y < layerEnd + featherSize) {
|
|
109
|
-
// Fade out below
|
|
110
|
-
alpha = Math.floor(((y - layerEnd) / featherSize) * 128);
|
|
111
|
-
}
|
|
112
|
-
// For foreground (bottom), extend fully to bottom
|
|
113
|
-
if (type === 'foreground' && layerIndex === 0 && y >= layerStart) {
|
|
114
|
-
alpha = 255;
|
|
78
|
+
else {
|
|
79
|
+
pythonDepthAvailable = false;
|
|
80
|
+
if (!pythonPath) {
|
|
81
|
+
console.log('[ParallaxEngine] Python depth estimation not available (missing torch/transformers)');
|
|
115
82
|
}
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
alpha = 255;
|
|
119
|
-
}
|
|
120
|
-
for (let x = 0; x < width; x++) {
|
|
121
|
-
const idx = (y * width + x) * 4;
|
|
122
|
-
buffer[idx] = 255; // R
|
|
123
|
-
buffer[idx + 1] = 255; // G
|
|
124
|
-
buffer[idx + 2] = 255; // B
|
|
125
|
-
buffer[idx + 3] = alpha; // A
|
|
83
|
+
else {
|
|
84
|
+
console.log('[ParallaxEngine] Depth script not found:', DEPTH_SCRIPT_PATH);
|
|
126
85
|
}
|
|
127
86
|
}
|
|
128
|
-
return
|
|
87
|
+
return pythonDepthAvailable;
|
|
129
88
|
}
|
|
130
89
|
/**
|
|
131
|
-
* Generate
|
|
90
|
+
* Generate parallax using AI depth estimation (Python + Depth Anything)
|
|
91
|
+
* Creates true layer separation based on depth map
|
|
132
92
|
*/
|
|
133
|
-
async function
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
const type = i === 0 ? 'foreground' : i === numLayers - 1 ? 'background' : 'middle';
|
|
159
|
-
layerMasks.push(createGradientMask(imgWidth, imgHeight, type, i, numLayers));
|
|
160
|
-
}
|
|
161
|
-
// Generate each frame
|
|
162
|
-
console.log(`[ParallaxEngine] Generating ${totalFrames} frames...`);
|
|
163
|
-
for (let frameNum = 0; frameNum < totalFrames; frameNum++) {
|
|
164
|
-
// Progress from -1 to 1 (centered animation)
|
|
165
|
-
const progress = ((frameNum / (totalFrames - 1)) - 0.5) * 2;
|
|
166
|
-
// Start with a base canvas (slightly larger for movement headroom)
|
|
167
|
-
const canvasWidth = Math.floor(imgWidth * 1.2);
|
|
168
|
-
const canvasHeight = Math.floor(imgHeight * 1.2);
|
|
169
|
-
// Create composites array for all layers
|
|
170
|
-
const composites = [];
|
|
171
|
-
// Process each layer (background to foreground)
|
|
172
|
-
for (let layerIdx = numLayers - 1; layerIdx >= 0; layerIdx--) {
|
|
173
|
-
// Calculate movement for this layer
|
|
174
|
-
// Background layers (higher index) move less, foreground layers move more
|
|
175
|
-
const depthFactor = layerIdx / (numLayers - 1); // 0 = foreground, 1 = background
|
|
176
|
-
const layerSpeed = params.bgSpeed + (params.fgSpeed - params.bgSpeed) * (1 - depthFactor);
|
|
177
|
-
let offsetX = 0;
|
|
178
|
-
let offsetY = 0;
|
|
179
|
-
let scale = 1.0;
|
|
180
|
-
switch (config.direction) {
|
|
181
|
-
case 'left':
|
|
182
|
-
offsetX = Math.round(progress * layerSpeed * imgWidth);
|
|
183
|
-
break;
|
|
184
|
-
case 'right':
|
|
185
|
-
offsetX = Math.round(-progress * layerSpeed * imgWidth);
|
|
186
|
-
break;
|
|
187
|
-
case 'up':
|
|
188
|
-
offsetY = Math.round(progress * layerSpeed * imgHeight);
|
|
189
|
-
break;
|
|
190
|
-
case 'down':
|
|
191
|
-
offsetY = Math.round(-progress * layerSpeed * imgHeight);
|
|
192
|
-
break;
|
|
193
|
-
case 'zoomIn':
|
|
194
|
-
scale = 1 + (progress + 1) * params.scaleRange * (1 - depthFactor * 0.5);
|
|
195
|
-
break;
|
|
196
|
-
case 'zoomOut':
|
|
197
|
-
scale = 1 + (1 - progress) * params.scaleRange * (1 - depthFactor * 0.5);
|
|
198
|
-
break;
|
|
93
|
+
async function generateDepthParallax(imagePath, outputPath, config, duration, fps) {
|
|
94
|
+
return new Promise((resolve, reject) => {
|
|
95
|
+
const options = JSON.stringify({
|
|
96
|
+
direction: config.direction,
|
|
97
|
+
intensity: config.intensity,
|
|
98
|
+
duration,
|
|
99
|
+
fps,
|
|
100
|
+
layerCount: config.layerCount || 3,
|
|
101
|
+
zoom: config.zoom || 'none',
|
|
102
|
+
});
|
|
103
|
+
const pyExe = pythonPath || 'python3';
|
|
104
|
+
console.log(`[ParallaxEngine] Running depth estimation: ${pyExe} ${DEPTH_SCRIPT_PATH}`);
|
|
105
|
+
const proc = (0, child_process_1.spawn)(pyExe, [DEPTH_SCRIPT_PATH, imagePath, outputPath, options], {
|
|
106
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
107
|
+
});
|
|
108
|
+
let stdout = '';
|
|
109
|
+
let stderr = '';
|
|
110
|
+
proc.stdout.on('data', (data) => { stdout += data.toString(); });
|
|
111
|
+
proc.stderr.on('data', (data) => {
|
|
112
|
+
stderr += data.toString();
|
|
113
|
+
// Log progress messages
|
|
114
|
+
const lines = data.toString().split('\n');
|
|
115
|
+
for (const line of lines) {
|
|
116
|
+
if (line.trim()) {
|
|
117
|
+
console.log(`[DepthParallax] ${line.trim()}`);
|
|
199
118
|
}
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
119
|
+
}
|
|
120
|
+
});
|
|
121
|
+
proc.on('close', (code) => {
|
|
122
|
+
if (code !== 0) {
|
|
123
|
+
console.error('[ParallaxEngine] Depth parallax failed:', stderr);
|
|
124
|
+
reject(new Error(`Depth parallax failed with code ${code}: ${stderr}`));
|
|
125
|
+
return;
|
|
126
|
+
}
|
|
127
|
+
try {
|
|
128
|
+
const result = JSON.parse(stdout);
|
|
129
|
+
if (result.success) {
|
|
130
|
+
console.log('[ParallaxEngine] Depth parallax completed successfully');
|
|
131
|
+
resolve();
|
|
210
132
|
}
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
raw: { width: imgWidth, height: imgHeight, channels: 4 },
|
|
214
|
-
});
|
|
215
|
-
// Apply scale if needed
|
|
216
|
-
if (scale !== 1.0) {
|
|
217
|
-
const scaledWidth = Math.round(imgWidth * scale);
|
|
218
|
-
const scaledHeight = Math.round(imgHeight * scale);
|
|
219
|
-
layerImage = layerImage.resize(scaledWidth, scaledHeight, { fit: 'fill' });
|
|
220
|
-
// Extract center portion back to original size
|
|
221
|
-
const cropX = Math.floor((scaledWidth - imgWidth) / 2);
|
|
222
|
-
const cropY = Math.floor((scaledHeight - imgHeight) / 2);
|
|
223
|
-
layerImage = layerImage.extract({
|
|
224
|
-
left: Math.max(0, cropX),
|
|
225
|
-
top: Math.max(0, cropY),
|
|
226
|
-
width: Math.min(imgWidth, scaledWidth),
|
|
227
|
-
height: Math.min(imgHeight, scaledHeight),
|
|
228
|
-
});
|
|
133
|
+
else {
|
|
134
|
+
reject(new Error(result.error || 'Unknown error'));
|
|
229
135
|
}
|
|
230
|
-
const layerBuffer = await layerImage.ensureAlpha().raw().toBuffer();
|
|
231
|
-
// Calculate position on canvas
|
|
232
|
-
const baseX = Math.floor((canvasWidth - imgWidth) / 2);
|
|
233
|
-
const baseY = Math.floor((canvasHeight - imgHeight) / 2);
|
|
234
|
-
composites.push({
|
|
235
|
-
input: layerBuffer,
|
|
236
|
-
raw: { width: imgWidth, height: imgHeight, channels: 4 },
|
|
237
|
-
left: baseX + offsetX,
|
|
238
|
-
top: baseY + offsetY,
|
|
239
|
-
blend: 'over',
|
|
240
|
-
});
|
|
241
|
-
}
|
|
242
|
-
// Create the final frame by compositing all layers
|
|
243
|
-
let frame = sharp({
|
|
244
|
-
create: {
|
|
245
|
-
width: canvasWidth,
|
|
246
|
-
height: canvasHeight,
|
|
247
|
-
channels: 4,
|
|
248
|
-
background: { r: 0, g: 0, b: 0, alpha: 255 },
|
|
249
|
-
},
|
|
250
|
-
});
|
|
251
|
-
// Add each layer
|
|
252
|
-
for (const comp of composites) {
|
|
253
|
-
frame = sharp(await frame.png().toBuffer()).composite([{
|
|
254
|
-
input: comp.input,
|
|
255
|
-
raw: comp.raw,
|
|
256
|
-
left: comp.left,
|
|
257
|
-
top: comp.top,
|
|
258
|
-
blend: comp.blend,
|
|
259
|
-
}]);
|
|
260
136
|
}
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
if (frameNum % Math.max(1, Math.floor(totalFrames / 10)) === 0) {
|
|
270
|
-
console.log(`[ParallaxEngine] Frame ${frameNum + 1}/${totalFrames}`);
|
|
137
|
+
catch {
|
|
138
|
+
// If no JSON output, assume success if file exists
|
|
139
|
+
if (fs.existsSync(outputPath)) {
|
|
140
|
+
resolve();
|
|
141
|
+
}
|
|
142
|
+
else {
|
|
143
|
+
reject(new Error('Depth parallax failed: no output file'));
|
|
144
|
+
}
|
|
271
145
|
}
|
|
272
|
-
}
|
|
273
|
-
// Combine frames into video using FFmpeg
|
|
274
|
-
console.log('[ParallaxEngine] Combining frames into video...');
|
|
275
|
-
const inputPattern = path.join(tempDir, 'frame_%05d.png');
|
|
276
|
-
await new Promise((resolve, reject) => {
|
|
277
|
-
const command = (0, ffmpeg_wrapper_1.createCommand)();
|
|
278
|
-
command
|
|
279
|
-
.input(inputPattern)
|
|
280
|
-
.inputOptions([`-framerate ${fps}`])
|
|
281
|
-
.outputOptions([
|
|
282
|
-
'-c:v libx264',
|
|
283
|
-
'-crf 18',
|
|
284
|
-
'-preset medium',
|
|
285
|
-
'-pix_fmt yuv420p',
|
|
286
|
-
'-movflags +faststart',
|
|
287
|
-
])
|
|
288
|
-
.format('mp4')
|
|
289
|
-
.output(outputPath);
|
|
290
|
-
command.on('start', (cmd) => {
|
|
291
|
-
console.log(`[ParallaxEngine] FFmpeg: ${cmd}`);
|
|
292
|
-
});
|
|
293
|
-
command.on('error', (err) => reject(err));
|
|
294
|
-
command.on('end', () => resolve());
|
|
295
|
-
command.run();
|
|
296
146
|
});
|
|
297
|
-
|
|
147
|
+
proc.on('error', (err) => {
|
|
148
|
+
console.error('[ParallaxEngine] Failed to spawn Python process:', err);
|
|
149
|
+
reject(err);
|
|
150
|
+
});
|
|
151
|
+
});
|
|
152
|
+
}
|
|
153
|
+
// Try to load sharp (optional dependency)
|
|
154
|
+
let sharp = null;
|
|
155
|
+
async function loadSharp() {
|
|
156
|
+
if (sharp !== null)
|
|
157
|
+
return true;
|
|
158
|
+
try {
|
|
159
|
+
sharp = (await Promise.resolve().then(() => __importStar(require('sharp')))).default;
|
|
160
|
+
console.log('[ParallaxEngine] Sharp loaded successfully');
|
|
161
|
+
return true;
|
|
298
162
|
}
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
fs.rmSync(tempDir, { recursive: true, force: true });
|
|
303
|
-
}
|
|
163
|
+
catch (error) {
|
|
164
|
+
console.warn('[ParallaxEngine] Sharp not available:', error.message);
|
|
165
|
+
return false;
|
|
304
166
|
}
|
|
305
167
|
}
|
|
306
168
|
/**
|
|
307
|
-
*
|
|
308
|
-
*
|
|
169
|
+
* Ken Burns effect using zoompan with optimized settings
|
|
170
|
+
* Uses higher zoom and stronger movement for visible parallax
|
|
309
171
|
*/
|
|
310
172
|
async function generateSimpleKenBurns(imagePath, outputPath, config, duration, fps, width, height) {
|
|
311
|
-
const params = getIntensityParams(config.intensity);
|
|
312
173
|
const totalFrames = Math.ceil(duration * fps);
|
|
174
|
+
// Movement intensity - increased for more visible effect
|
|
175
|
+
const intensityMultiplier = config.intensity === 'subtle' ? 0.7 :
|
|
176
|
+
config.intensity === 'dramatic' ? 1.6 : 1.0;
|
|
177
|
+
// Higher base zoom for smoother subpixel movement
|
|
178
|
+
const baseZoom = 1.3;
|
|
179
|
+
// Stronger zoom change
|
|
180
|
+
const zoomDelta = 0.12 * intensityMultiplier;
|
|
181
|
+
// Stronger pan movement (percentage of available space)
|
|
182
|
+
const panSpeed = 0.15 * intensityMultiplier;
|
|
313
183
|
let zoomExpr;
|
|
314
184
|
let xExpr;
|
|
315
185
|
let yExpr;
|
|
316
|
-
|
|
317
|
-
|
|
186
|
+
// Smooth easing using sine function for natural movement
|
|
187
|
+
// on = frame number, goes from 0 to totalFrames
|
|
188
|
+
// Using smooth start/end with sine easing
|
|
189
|
+
const progress = `on/${totalFrames}`;
|
|
190
|
+
const easeInOut = `(1-cos(${progress}*PI))/2`;
|
|
318
191
|
switch (config.direction) {
|
|
319
192
|
case 'left':
|
|
320
|
-
|
|
321
|
-
|
|
193
|
+
// Pan from right to left
|
|
194
|
+
zoomExpr = `${baseZoom}+${zoomDelta}*${progress}`;
|
|
195
|
+
xExpr = `(iw-ow)/2*(1+${panSpeed})-${panSpeed}*(iw-ow)/2*${easeInOut}*2`;
|
|
322
196
|
yExpr = `(ih-oh)/2`;
|
|
323
197
|
break;
|
|
324
198
|
case 'right':
|
|
325
|
-
|
|
326
|
-
|
|
199
|
+
// Pan from left to right
|
|
200
|
+
zoomExpr = `${baseZoom}+${zoomDelta}*${progress}`;
|
|
201
|
+
xExpr = `(iw-ow)/2*(1-${panSpeed})+${panSpeed}*(iw-ow)/2*${easeInOut}*2`;
|
|
327
202
|
yExpr = `(ih-oh)/2`;
|
|
328
203
|
break;
|
|
329
204
|
case 'up':
|
|
330
|
-
|
|
205
|
+
// Pan from bottom to top
|
|
206
|
+
zoomExpr = `${baseZoom}+${zoomDelta}*${progress}`;
|
|
331
207
|
xExpr = `(iw-ow)/2`;
|
|
332
|
-
yExpr = `(ih-oh)/2-${
|
|
208
|
+
yExpr = `(ih-oh)/2*(1+${panSpeed})-${panSpeed}*(ih-oh)/2*${easeInOut}*2`;
|
|
333
209
|
break;
|
|
334
210
|
case 'down':
|
|
335
|
-
|
|
211
|
+
// Pan from top to bottom
|
|
212
|
+
zoomExpr = `${baseZoom}+${zoomDelta}*${progress}`;
|
|
336
213
|
xExpr = `(iw-ow)/2`;
|
|
337
|
-
yExpr = `(ih-oh)/2+${
|
|
214
|
+
yExpr = `(ih-oh)/2*(1-${panSpeed})+${panSpeed}*(ih-oh)/2*${easeInOut}*2`;
|
|
338
215
|
break;
|
|
339
216
|
case 'zoomIn':
|
|
340
|
-
zoomExpr = `${baseZoom}+${zoomDelta *
|
|
217
|
+
zoomExpr = `${baseZoom}+${zoomDelta * 2.5}*${easeInOut}`;
|
|
341
218
|
xExpr = `(iw-ow)/2`;
|
|
342
219
|
yExpr = `(ih-oh)/2`;
|
|
343
220
|
break;
|
|
344
221
|
case 'zoomOut':
|
|
345
|
-
zoomExpr = `${baseZoom + zoomDelta *
|
|
222
|
+
zoomExpr = `${baseZoom + zoomDelta * 2.5}-${zoomDelta * 2.5}*${easeInOut}`;
|
|
346
223
|
xExpr = `(iw-ow)/2`;
|
|
347
224
|
yExpr = `(ih-oh)/2`;
|
|
348
225
|
break;
|
|
@@ -351,12 +228,13 @@ async function generateSimpleKenBurns(imagePath, outputPath, config, duration, f
|
|
|
351
228
|
xExpr = `(iw-ow)/2`;
|
|
352
229
|
yExpr = `(ih-oh)/2`;
|
|
353
230
|
}
|
|
354
|
-
|
|
231
|
+
// zoompan filter with smooth interpolation
|
|
232
|
+
const filterChain = `zoompan=z='${zoomExpr}':x='${xExpr}':y='${yExpr}':d=${totalFrames}:s=${width}x${height}:fps=${fps}`;
|
|
355
233
|
return new Promise((resolve, reject) => {
|
|
356
234
|
const command = (0, ffmpeg_wrapper_1.createCommand)(imagePath);
|
|
357
235
|
command
|
|
358
236
|
.inputOptions(['-loop 1'])
|
|
359
|
-
.videoFilters([
|
|
237
|
+
.videoFilters([filterChain])
|
|
360
238
|
.outputOptions([
|
|
361
239
|
`-t ${duration}`,
|
|
362
240
|
'-pix_fmt yuv420p',
|
|
@@ -443,6 +321,7 @@ class ParallaxEngine {
|
|
|
443
321
|
/**
|
|
444
322
|
* Generate parallax video from a single image
|
|
445
323
|
* Main entry point for creating parallax effect videos
|
|
324
|
+
* Uses AI depth estimation for true parallax, falls back to Ken Burns
|
|
446
325
|
*/
|
|
447
326
|
async generateParallaxVideo(imagePath, outputPath, config, duration, fps = 24, _videoCodec = 'libx264', _quality = 'high', _customCRF) {
|
|
448
327
|
await this.initialize();
|
|
@@ -453,15 +332,17 @@ class ParallaxEngine {
|
|
|
453
332
|
const width = Math.floor(dimensions.width / 2) * 2; // Ensure even
|
|
454
333
|
const height = Math.floor(dimensions.height / 2) * 2;
|
|
455
334
|
console.log(`[ParallaxEngine] Output size: ${width}x${height}`);
|
|
335
|
+
// Check if Python depth estimation is available
|
|
336
|
+
const depthAvailable = await checkPythonDepth();
|
|
456
337
|
try {
|
|
457
|
-
if (
|
|
458
|
-
// Use true
|
|
459
|
-
console.log('[ParallaxEngine] Using
|
|
460
|
-
await
|
|
338
|
+
if (depthAvailable) {
|
|
339
|
+
// Use AI depth-based parallax for true layer separation
|
|
340
|
+
console.log('[ParallaxEngine] Using AI depth-based parallax (Depth Anything V2)');
|
|
341
|
+
await generateDepthParallax(imagePath, outputPath, config, duration, fps);
|
|
461
342
|
}
|
|
462
343
|
else {
|
|
463
|
-
//
|
|
464
|
-
console.log('[ParallaxEngine] Using Ken Burns
|
|
344
|
+
// Fall back to Ken Burns effect
|
|
345
|
+
console.log('[ParallaxEngine] Using Ken Burns effect (depth estimation not available)');
|
|
465
346
|
await generateSimpleKenBurns(imagePath, outputPath, config, duration, fps, width, height);
|
|
466
347
|
}
|
|
467
348
|
const buffer = fs.readFileSync(outputPath);
|
|
@@ -470,6 +351,19 @@ class ParallaxEngine {
|
|
|
470
351
|
}
|
|
471
352
|
catch (error) {
|
|
472
353
|
console.error('[ParallaxEngine] Error generating parallax:', error);
|
|
354
|
+
// If depth parallax failed, try Ken Burns as fallback
|
|
355
|
+
if (depthAvailable) {
|
|
356
|
+
console.log('[ParallaxEngine] Depth parallax failed, falling back to Ken Burns');
|
|
357
|
+
try {
|
|
358
|
+
await generateSimpleKenBurns(imagePath, outputPath, config, duration, fps, width, height);
|
|
359
|
+
const buffer = fs.readFileSync(outputPath);
|
|
360
|
+
return buffer;
|
|
361
|
+
}
|
|
362
|
+
catch (fallbackError) {
|
|
363
|
+
console.error('[ParallaxEngine] Ken Burns fallback also failed:', fallbackError);
|
|
364
|
+
throw fallbackError;
|
|
365
|
+
}
|
|
366
|
+
}
|
|
473
367
|
throw error;
|
|
474
368
|
}
|
|
475
369
|
}
|