Update server.js
Browse files
server.js
CHANGED
|
@@ -4,15 +4,12 @@ import express from 'express';
|
|
| 4 |
import cors from 'cors';
|
| 5 |
import { bundle } from '@remotion/bundler';
|
| 6 |
import { renderMedia, selectComposition } from '@remotion/renderer';
|
| 7 |
-
import { readFile,
|
| 8 |
import { tmpdir } from 'os';
|
| 9 |
import { join, dirname } from 'path';
|
| 10 |
-
import { randomUUID } from 'crypto';
|
| 11 |
import { fileURLToPath } from 'url';
|
| 12 |
-
import
|
| 13 |
-
import { promisify } from 'util';
|
| 14 |
|
| 15 |
-
const exec = promisify(execCallback);
|
| 16 |
const __filename = fileURLToPath(import.meta.url);
|
| 17 |
const __dirname = dirname(__filename);
|
| 18 |
|
|
@@ -24,7 +21,7 @@ app.use(express.json({ limit: '50mb' }));
|
|
| 24 |
|
| 25 |
// Health check
|
| 26 |
app.get('/', (req, res) => {
|
| 27 |
-
res.json({ status: 'ok', service: 'FacelessFlowAI Video Renderer' });
|
| 28 |
});
|
| 29 |
|
| 30 |
// Render endpoint
|
|
@@ -55,206 +52,67 @@ app.post('/render', async (req, res) => {
|
|
| 55 |
try {
|
| 56 |
console.log(`[Render] Starting background render for project ${projectId}`);
|
| 57 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 58 |
const bundleLocation = await bundle({
|
| 59 |
entryPoint: join(__dirname, 'remotion', 'index.tsx'),
|
| 60 |
webpackOverride: (config) => config,
|
| 61 |
});
|
| 62 |
|
| 63 |
-
//
|
| 64 |
-
const
|
| 65 |
-
|
| 66 |
-
|
| 67 |
-
|
| 68 |
-
|
| 69 |
-
const downloadAsset = async (url, destPath, retries = 3) => {
|
| 70 |
-
for (let i = 0; i < retries; i++) {
|
| 71 |
-
try {
|
| 72 |
-
const response = await fetch(url);
|
| 73 |
-
if (!response.ok) throw new Error(`Failed to fetch ${url}: ${response.statusText}`);
|
| 74 |
-
if (!response.body) throw new Error(`No body for ${url}`);
|
| 75 |
-
|
| 76 |
-
await pipeline(response.body, createWriteStream(destPath));
|
| 77 |
-
return; // Success
|
| 78 |
-
} catch (err) {
|
| 79 |
-
console.warn(`[Download] Attempt ${i + 1} failed for ${url}: ${err.message}`);
|
| 80 |
-
if (i === retries - 1) throw err; // Throw on last failure
|
| 81 |
-
await new Promise(r => setTimeout(r, 2000 * (i + 1))); // Backoff
|
| 82 |
-
}
|
| 83 |
-
}
|
| 84 |
-
};
|
| 85 |
-
|
| 86 |
-
// process.cwd() is disk-backed (prevents RAM OOM on HF Spaces)
|
| 87 |
-
const tempDir = join(process.cwd(), `temp_assets_${projectId}`);
|
| 88 |
-
try { await rm(tempDir, { recursive: true, force: true }); } catch { }
|
| 89 |
-
await mkdir(tempDir, { recursive: true });
|
| 90 |
-
|
| 91 |
-
console.log(`[Render] Pre-downloading assets to ${tempDir} (Disk-backed)...`);
|
| 92 |
-
|
| 93 |
-
// Helper: Batch Processor for Concurrency Control
|
| 94 |
-
const batchProcessArgs = async (items, batchSize, fn) => {
|
| 95 |
-
const results = [];
|
| 96 |
-
for (let i = 0; i < items.length; i += batchSize) {
|
| 97 |
-
const batch = items.slice(i, i + batchSize);
|
| 98 |
-
const batchResults = await Promise.all(batch.map((item, batchLocalIndex) => {
|
| 99 |
-
const globalIndex = i + batchLocalIndex;
|
| 100 |
-
return fn(item, globalIndex);
|
| 101 |
-
}));
|
| 102 |
-
results.push(...batchResults);
|
| 103 |
-
}
|
| 104 |
-
return results;
|
| 105 |
-
};
|
| 106 |
-
|
| 107 |
-
// Calculate total downloaded size using fs.stat
|
| 108 |
-
let totalBytes = 0;
|
| 109 |
-
const trackSize = async (file) => {
|
| 110 |
-
try {
|
| 111 |
-
const stats = await fs.promises.stat(file);
|
| 112 |
-
totalBytes += stats.size;
|
| 113 |
-
} catch { }
|
| 114 |
-
};
|
| 115 |
-
|
| 116 |
-
// Iterate scenes and download assets (Concurrency Limit: 5)
|
| 117 |
-
const localScenes = await batchProcessArgs(scenes, 5, async (scene, idx) => {
|
| 118 |
-
const newScene = { ...scene };
|
| 119 |
-
|
| 120 |
-
if (scene.audio_url) {
|
| 121 |
-
const audioExt = scene.audio_url.split('.').pop().split('?')[0] || 'mp3';
|
| 122 |
-
const localAudioPath = join(tempDir, `audio_${idx}.${audioExt}`);
|
| 123 |
-
try {
|
| 124 |
-
await downloadAsset(scene.audio_url, localAudioPath);
|
| 125 |
-
await trackSize(localAudioPath);
|
| 126 |
-
newScene.audio_url = `file://${localAudioPath}`;
|
| 127 |
-
} catch (e) {
|
| 128 |
-
console.error(`[Download] Failed audio for scene ${idx}: ${e.message}`);
|
| 129 |
-
}
|
| 130 |
-
}
|
| 131 |
-
|
| 132 |
-
if (scene.image_url) {
|
| 133 |
-
const isVideo = scene.media_type === 'video' || scene.image_url.includes('.mp4');
|
| 134 |
-
const ext = isVideo ? 'mp4' : 'jpg';
|
| 135 |
-
const localMediaPath = join(tempDir, `visual_${idx}.${ext}`);
|
| 136 |
-
try {
|
| 137 |
-
await downloadAsset(scene.image_url, localMediaPath);
|
| 138 |
-
await trackSize(localMediaPath);
|
| 139 |
-
newScene.image_url = `file://${localMediaPath}`;
|
| 140 |
-
} catch (e) {
|
| 141 |
-
console.error(`[Download] Failed visual for scene ${idx}: ${e.message}`);
|
| 142 |
-
}
|
| 143 |
-
}
|
| 144 |
-
return newScene;
|
| 145 |
});
|
| 146 |
|
| 147 |
-
|
| 148 |
-
|
| 149 |
-
|
| 150 |
-
|
| 151 |
-
|
| 152 |
-
|
| 153 |
-
|
| 154 |
-
|
| 155 |
-
|
| 156 |
-
|
| 157 |
-
|
| 158 |
-
|
| 159 |
-
|
| 160 |
-
|
| 161 |
-
|
| 162 |
-
|
| 163 |
-
|
| 164 |
-
|
| 165 |
-
|
| 166 |
-
|
| 167 |
-
|
| 168 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 169 |
});
|
| 170 |
|
| 171 |
-
|
| 172 |
-
// SEGMENTED BATCH RENDERING
|
| 173 |
-
// --------------------------------------
|
| 174 |
-
// CRASH FIX: Reduce batch size to 10 to prevent OOM
|
| 175 |
-
const BATCH_SIZE = 10;
|
| 176 |
-
const totalBatches = Math.ceil(httpScenes.length / BATCH_SIZE);
|
| 177 |
-
const partFiles = [];
|
| 178 |
-
|
| 179 |
-
const os = await import('os');
|
| 180 |
-
const cpuCount = os.cpus().length;
|
| 181 |
-
console.log(`[Render] Splitting ${httpScenes.length} scenes into ${totalBatches} parts. CPU: ${cpuCount}`);
|
| 182 |
-
|
| 183 |
-
for (let i = 0; i < totalBatches; i++) {
|
| 184 |
-
const startIdx = i * BATCH_SIZE;
|
| 185 |
-
const endIdx = Math.min((i + 1) * BATCH_SIZE, httpScenes.length);
|
| 186 |
-
const batchScenes = httpScenes.slice(startIdx, endIdx);
|
| 187 |
-
|
| 188 |
-
console.log(`[Render] Processing Part ${i + 1}/${totalBatches} (Scenes ${startIdx}-${endIdx - 1})...`);
|
| 189 |
-
|
| 190 |
-
const composition = await selectComposition({
|
| 191 |
-
serveUrl: bundleLocation,
|
| 192 |
-
id: 'Main',
|
| 193 |
-
inputProps: { scenes: batchScenes, settings },
|
| 194 |
-
chromiumOptions: { executablePath: process.env.CHROME_BIN },
|
| 195 |
-
});
|
| 196 |
-
|
| 197 |
-
const partPath = join(tempDir, `part_${i}.mp4`);
|
| 198 |
-
partFiles.push(partPath);
|
| 199 |
|
| 200 |
-
|
| 201 |
|
| 202 |
-
|
| 203 |
-
composition,
|
| 204 |
-
serveUrl: bundleLocation,
|
| 205 |
-
codec: 'h264',
|
| 206 |
-
pixelFormat: 'yuv420p',
|
| 207 |
-
outputLocation: partPath,
|
| 208 |
-
imageFormat: 'jpeg',
|
| 209 |
-
jpegQuality: 80,
|
| 210 |
-
inputProps: { scenes: batchScenes, settings },
|
| 211 |
-
chromiumOptions: {
|
| 212 |
-
executablePath: process.env.CHROME_BIN || '/usr/bin/google-chrome-stable',
|
| 213 |
-
enableMultiProcessRendering: true, // SPEED: Enable since we have 32GB RAM
|
| 214 |
-
args: [
|
| 215 |
-
'--no-sandbox',
|
| 216 |
-
'--disable-dev-shm-usage',
|
| 217 |
-
'--disable-gpu', // CPU Mode
|
| 218 |
-
'--mute-audio',
|
| 219 |
-
]
|
| 220 |
-
},
|
| 221 |
-
concurrency: 2, // SPEED: 2x Parallel Frames
|
| 222 |
-
disallowParallelEncoding: false, // SPEED: Allow parallel encoding
|
| 223 |
-
onProgress: ({ progress }) => {
|
| 224 |
-
const percent = Math.round(progress * 100);
|
| 225 |
-
if (percent !== lastLoggedPercent && percent % 10 === 0) {
|
| 226 |
-
console.log(`[Render] Part ${i + 1} Progress: ${percent}%`);
|
| 227 |
-
lastLoggedPercent = percent;
|
| 228 |
-
}
|
| 229 |
-
},
|
| 230 |
-
});
|
| 231 |
-
|
| 232 |
-
console.log(`[Render] Part ${i + 1} Done.`);
|
| 233 |
-
// Clean composition object to help GC?
|
| 234 |
-
}
|
| 235 |
-
|
| 236 |
-
// --------------------------------------
|
| 237 |
-
// STITCHING
|
| 238 |
-
// --------------------------------------
|
| 239 |
-
console.log(`[Render] All parts rendered. Stitching ${partFiles.length} files...`);
|
| 240 |
-
|
| 241 |
-
const concatListPath = join(tempDir, 'concat_list.txt');
|
| 242 |
-
const concatContent = partFiles.map(p => `file '${p}'`).join('\n');
|
| 243 |
-
await writeFile(concatListPath, concatContent);
|
| 244 |
-
|
| 245 |
-
const finalOutputPath = join(tempDir, 'output_final.mp4');
|
| 246 |
-
const ffmpegCmd = `ffmpeg -f concat -safe 0 -i "${concatListPath}" -c copy "${finalOutputPath}"`;
|
| 247 |
-
|
| 248 |
-
await exec(ffmpegCmd);
|
| 249 |
-
console.log(`[Render] Stitching complete.`);
|
| 250 |
-
|
| 251 |
-
// --------------------------------------
|
| 252 |
-
// UPLOAD
|
| 253 |
-
// --------------------------------------
|
| 254 |
-
console.log(`[Render] Uploading to Supabase...`);
|
| 255 |
-
const videoBuffer = await readFile(finalOutputPath);
|
| 256 |
const fileName = `${projectId}/video-${Date.now()}.mp4`;
|
| 257 |
-
|
| 258 |
const { data: uploadData, error: uploadError } = await supabase
|
| 259 |
.storage
|
| 260 |
.from('projects')
|
|
@@ -265,6 +123,7 @@ app.post('/render', async (req, res) => {
|
|
| 265 |
|
| 266 |
if (uploadError) throw new Error(`Upload failed: ${uploadError.message}`);
|
| 267 |
|
|
|
|
| 268 |
const { data: { publicUrl } } = supabase
|
| 269 |
.storage
|
| 270 |
.from('projects')
|
|
@@ -275,21 +134,26 @@ app.post('/render', async (req, res) => {
|
|
| 275 |
// Update Project in DB
|
| 276 |
const { error: dbError } = await supabase
|
| 277 |
.from('projects')
|
| 278 |
-
.update({
|
|
|
|
|
|
|
|
|
|
| 279 |
.eq('id', projectId);
|
| 280 |
|
| 281 |
if (dbError) throw new Error(`DB Update failed: ${dbError.message}`);
|
| 282 |
|
| 283 |
-
console.log(`[Render] Project ${projectId}
|
| 284 |
|
| 285 |
// Cleanup
|
| 286 |
-
await rm(
|
| 287 |
|
| 288 |
} catch (error) {
|
| 289 |
console.error(`[Render] Background Error for ${projectId}:`, error);
|
| 290 |
await supabase
|
| 291 |
.from('projects')
|
| 292 |
-
.update({
|
|
|
|
|
|
|
| 293 |
.eq('id', projectId);
|
| 294 |
}
|
| 295 |
})();
|
|
|
|
| 4 |
import cors from 'cors';
|
| 5 |
import { bundle } from '@remotion/bundler';
|
| 6 |
import { renderMedia, selectComposition } from '@remotion/renderer';
|
| 7 |
+
import { readFile, rm } from 'fs/promises';
|
| 8 |
import { tmpdir } from 'os';
|
| 9 |
import { join, dirname } from 'path';
|
|
|
|
| 10 |
import { fileURLToPath } from 'url';
|
| 11 |
+
import os from 'os';
|
|
|
|
| 12 |
|
|
|
|
| 13 |
const __filename = fileURLToPath(import.meta.url);
|
| 14 |
const __dirname = dirname(__filename);
|
| 15 |
|
|
|
|
| 21 |
|
| 22 |
// Health check
|
| 23 |
app.get('/', (req, res) => {
|
| 24 |
+
res.json({ status: 'ok', service: 'FacelessFlowAI Video Renderer (Simple)' });
|
| 25 |
});
|
| 26 |
|
| 27 |
// Render endpoint
|
|
|
|
| 52 |
try {
|
| 53 |
console.log(`[Render] Starting background render for project ${projectId}`);
|
| 54 |
|
| 55 |
+
// Available resources
|
| 56 |
+
const cpuCount = os.cpus().length;
|
| 57 |
+
const freeMem = (os.freemem() / 1024 / 1024).toFixed(0);
|
| 58 |
+
const totalMem = (os.totalmem() / 1024 / 1024).toFixed(0);
|
| 59 |
+
console.log(`[Resources] vCPUs: ${cpuCount} | RAM: ${freeMem}/${totalMem} MB Free`);
|
| 60 |
+
|
| 61 |
const bundleLocation = await bundle({
|
| 62 |
entryPoint: join(__dirname, 'remotion', 'index.tsx'),
|
| 63 |
webpackOverride: (config) => config,
|
| 64 |
});
|
| 65 |
|
| 66 |
+
// Clean Composition Params
|
| 67 |
+
const composition = await selectComposition({
|
| 68 |
+
serveUrl: bundleLocation,
|
| 69 |
+
id: 'Main',
|
| 70 |
+
inputProps: { scenes, settings }, // Direct Cloud URLs
|
| 71 |
+
chromiumOptions: { executablePath: process.env.CHROME_BIN },
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 72 |
});
|
| 73 |
|
| 74 |
+
const outputLocation = join(tmpdir(), `out-${projectId}.mp4`);
|
| 75 |
+
|
| 76 |
+
let lastLoggedPercent = -1;
|
| 77 |
+
|
| 78 |
+
// Simple Render
|
| 79 |
+
await renderMedia({
|
| 80 |
+
composition,
|
| 81 |
+
serveUrl: bundleLocation,
|
| 82 |
+
codec: 'h264',
|
| 83 |
+
pixelFormat: 'yuv420p',
|
| 84 |
+
outputLocation: outputLocation,
|
| 85 |
+
imageFormat: 'jpeg',
|
| 86 |
+
jpegQuality: 80,
|
| 87 |
+
inputProps: { scenes, settings },
|
| 88 |
+
chromiumOptions: {
|
| 89 |
+
executablePath: process.env.CHROME_BIN || '/usr/bin/google-chrome-stable',
|
| 90 |
+
enableMultiProcessRendering: true, // Requested: Enable Multi-process
|
| 91 |
+
args: [
|
| 92 |
+
'--no-sandbox',
|
| 93 |
+
'--disable-dev-shm-usage',
|
| 94 |
+
'--disable-gpu',
|
| 95 |
+
'--mute-audio',
|
| 96 |
+
]
|
| 97 |
+
},
|
| 98 |
+
// Use all available cores (or set specific number)
|
| 99 |
+
concurrency: 4,
|
| 100 |
+
disallowParallelEncoding: false,
|
| 101 |
+
onProgress: ({ progress }) => {
|
| 102 |
+
const percent = Math.round(progress * 100);
|
| 103 |
+
if (percent !== lastLoggedPercent && percent % 5 === 0) {
|
| 104 |
+
console.log(`[Render] Progress: ${percent}%`);
|
| 105 |
+
lastLoggedPercent = percent;
|
| 106 |
+
}
|
| 107 |
+
},
|
| 108 |
});
|
| 109 |
|
| 110 |
+
console.log(`[Render] Render complete. Uploading to Supabase...`);
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 111 |
|
| 112 |
+
const videoBuffer = await readFile(outputLocation);
|
| 113 |
|
| 114 |
+
// Upload to Supabase Storage
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 115 |
const fileName = `${projectId}/video-${Date.now()}.mp4`;
|
|
|
|
| 116 |
const { data: uploadData, error: uploadError } = await supabase
|
| 117 |
.storage
|
| 118 |
.from('projects')
|
|
|
|
| 123 |
|
| 124 |
if (uploadError) throw new Error(`Upload failed: ${uploadError.message}`);
|
| 125 |
|
| 126 |
+
// Get Public URL
|
| 127 |
const { data: { publicUrl } } = supabase
|
| 128 |
.storage
|
| 129 |
.from('projects')
|
|
|
|
| 134 |
// Update Project in DB
|
| 135 |
const { error: dbError } = await supabase
|
| 136 |
.from('projects')
|
| 137 |
+
.update({
|
| 138 |
+
status: 'done',
|
| 139 |
+
video_url: publicUrl
|
| 140 |
+
})
|
| 141 |
.eq('id', projectId);
|
| 142 |
|
| 143 |
if (dbError) throw new Error(`DB Update failed: ${dbError.message}`);
|
| 144 |
|
| 145 |
+
console.log(`[Render] Project ${projectId} updated successfully`);
|
| 146 |
|
| 147 |
// Cleanup
|
| 148 |
+
await rm(outputLocation, { force: true });
|
| 149 |
|
| 150 |
} catch (error) {
|
| 151 |
console.error(`[Render] Background Error for ${projectId}:`, error);
|
| 152 |
await supabase
|
| 153 |
.from('projects')
|
| 154 |
+
.update({
|
| 155 |
+
status: 'error',
|
| 156 |
+
})
|
| 157 |
.eq('id', projectId);
|
| 158 |
}
|
| 159 |
})();
|