'use strict'; const express = require('express'); const multer = require('multer'); const path = require('path'); const fs = require('fs'); const { exec } = require('child_process'); const app = express(); const PORT = 7860; // ── Directories ─────────────────────────────────────────────────────────────── const TEMP_DIR = '/tmp/reel_uploads'; const OUTPUT_DIR = '/tmp/reel_output'; [TEMP_DIR, OUTPUT_DIR].forEach(d => fs.mkdirSync(d, { recursive: true })); // ── Serve uploaded clips and output via HTTP (Remotion needs HTTP URLs) ─────── app.use('/clips', express.static(TEMP_DIR)); app.use('/output', express.static(OUTPUT_DIR)); // ── Health check ────────────────────────────────────────────────────────────── app.get('/', (req, res) => res.json({ status: 'ok', service: 'remotion-render', version: '2.0' })); // ── Multer config ───────────────────────────────────────────────────────────── const storage = multer.diskStorage({ destination: (_req, _file, cb) => cb(null, TEMP_DIR), filename: (_req, file, cb) => { const ext = guessExt(file.fieldname, file.originalname); cb(null, `${Date.now()}_${file.fieldname}${ext}`); }, }); const upload = multer({ storage, limits: { fileSize: 500 * 1024 * 1024 } }); const CLIP_FIELDS = Array.from({ length: 10 }, (_, i) => ({ name: `clip_${i}`, maxCount: 1 })); const uploadFields = upload.fields([ ...CLIP_FIELDS, { name: 'voice', maxCount: 1 }, { name: 'subtitles', maxCount: 1 }, { name: 'backgroundImage', maxCount: 1 }, { name: 'backgroundVideo', maxCount: 1 }, ]); function guessExt(fieldname, originalname) { const orig = path.extname(originalname || ''); if (orig) return orig; if (fieldname.startsWith('clip_') || fieldname === 'backgroundVideo') return '.mp4'; if (fieldname === 'voice') return '.mp3'; if (fieldname === 'subtitles') return '.srt'; if (fieldname === 'backgroundImage') return '.jpg'; return ''; } // ── Remotion bundle (lazy, cached after first render) ───────────────────────── let _bundleLocation = null; async function getBundle() { if (_bundleLocation && fs.existsSync(_bundleLocation)) return _bundleLocation; console.log('[Bundle] Building Remotion bundle...'); const { bundle } = require('@remotion/bundler'); _bundleLocation = await bundle({ entryPoint: path.join(__dirname, 'src', 'index.jsx'), }); console.log('[Bundle] ✅ Bundle ready:', _bundleLocation); return _bundleLocation; } // ── Render endpoint ─────────────────────────────────────────────────────────── app.post('/render', uploadFields, async (req, res) => { const jobId = Date.now(); const jobDir = path.join(OUTPUT_DIR, String(jobId)); const uploaded = []; // file paths to clean up when done fs.mkdirSync(jobDir, { recursive: true }); console.log(`\n[Job ${jobId}] ═══════════════ START RENDER ═══════════════`); console.log(`[Job ${jobId}] template=${req.body.template || 'viral'} hasMusic=${req.body.hasMusic}`); try { // ── STEP 1: Collect clips (file upload OR remote URLs) ──────────────── console.log(`[Job ${jobId}] STEP 1: Collecting clips...`); const clipPaths = []; const urlCount = parseInt(req.body?.clipUrlCount) || 0; if (urlCount > 0) { // URL mode: download clips from Pexels URLs (no upload bandwidth needed) console.log(`[Job ${jobId}] STEP 1: URL mode — downloading ${urlCount} clips from Pexels...`); for (let i = 0; i < urlCount; i++) { const clipUrl = req.body?.[`clipUrl_${i}`]; if (!clipUrl) continue; const destPath = path.join(TEMP_DIR, `${Date.now()}_clip_${i}.mp4`); try { await downloadUrl(clipUrl, destPath); clipPaths.push(destPath); uploaded.push(destPath); console.log(`[Job ${jobId}] clip_${i}: downloaded (${fs.statSync(destPath).size}b) from Pexels`); } catch (dlErr) { console.warn(`[Job ${jobId}] clip_${i}: download failed — ${dlErr.message}`); } } } else { // File mode: clips uploaded directly (localhost Remotion) for (let i = 0; i < 10; i++) { const field = req.files?.[`clip_${i}`]?.[0]; if (!field) break; clipPaths.push(field.path); uploaded.push(field.path); console.log(`[Job ${jobId}] clip_${i}: ${path.basename(field.path)} (${field.size}b)`); } } if (clipPaths.length === 0) throw new Error('No clips provided'); console.log(`[Job ${jobId}] STEP 1 ✅ ${clipPaths.length} clips`); // Convert local paths → HTTP URLs accessible to Remotion headless Chrome const clipUrls = clipPaths.map(p => `http://localhost:${PORT}/clips/${path.basename(p)}`); // ── STEP 2: Parse subtitles SRT ─────────────────────────────────────── console.log(`[Job ${jobId}] STEP 2: Parsing subtitles...`); let subtitles = []; const subFile = req.files?.subtitles?.[0]; if (subFile) { uploaded.push(subFile.path); subtitles = parseSRT(fs.readFileSync(subFile.path, 'utf-8')); console.log(`[Job ${jobId}] STEP 2 ✅ ${subtitles.length} subtitle entries`); } else { console.log(`[Job ${jobId}] STEP 2: No subtitles`); } // ── STEP 3: Resolve background asset ───────────────────────────────── console.log(`[Job ${jobId}] STEP 3: Resolving background...`); let backgroundImage = null; let backgroundVideo = null; const bgImg = req.files?.backgroundImage?.[0]; const bgVid = req.files?.backgroundVideo?.[0]; if (bgImg) { uploaded.push(bgImg.path); backgroundImage = `http://localhost:${PORT}/clips/${path.basename(bgImg.path)}`; console.log(`[Job ${jobId}] STEP 3 ✅ backgroundImage: ${path.basename(bgImg.path)}`); } else if (bgVid) { uploaded.push(bgVid.path); backgroundVideo = `http://localhost:${PORT}/clips/${path.basename(bgVid.path)}`; console.log(`[Job ${jobId}] STEP 3 ✅ backgroundVideo: ${path.basename(bgVid.path)}`); } else { console.log(`[Job ${jobId}] STEP 3: No user background, using clips`); } // ── STEP 4: Remotion render (visual only — no audio) ────────────────── console.log(`[Job ${jobId}] STEP 4: Rendering Remotion (visual)...`); const template = req.body.template || 'viral'; const remotionOut = path.join(jobDir, 'remotion.mp4'); const inputProps = { clips: clipUrls, subtitles, template, backgroundImage, backgroundVideo }; const bundleLoc = await getBundle(); const { selectComposition, renderMedia } = require('@remotion/renderer'); const composition = await selectComposition({ serveUrl: bundleLoc, id: 'ReelsComposition', inputProps, }); await renderMedia({ composition, serveUrl: bundleLoc, codec: 'h264', outputLocation: remotionOut, inputProps, timeoutInMilliseconds: 120_000, concurrency: 1, }); if (!fs.existsSync(remotionOut)) throw new Error('Remotion output not created'); console.log(`[Job ${jobId}] STEP 4 ✅ Remotion done: ${fs.statSync(remotionOut).size}b`); let finalVideo = remotionOut; // ── STEP 5: Adding voice ─────────────────────────────────────────────── const voiceFile = req.files?.voice?.[0]; if (voiceFile) { uploaded.push(voiceFile.path); console.log(`[Job ${jobId}] STEP 5: Adding voice (${voiceFile.size}b)...`); const withVoice = path.join(jobDir, 'with_voice.mp4'); const ok = await ffmpegRun( `"${ffmpegBin()}" -i "${finalVideo}" -i "${voiceFile.path}" ` + `-c:v copy -c:a aac -b:a 128k -map 0:v:0 -map 1:a:0 -shortest "${withVoice}" -y` ); if (ok && fs.existsSync(withVoice)) { finalVideo = withVoice; console.log(`[Job ${jobId}] STEP 5 ✅ Voice added`); } else { console.warn(`[Job ${jobId}] STEP 5 ⚠️ Voice merge failed, continuing`); } } else { console.log(`[Job ${jobId}] STEP 5: No voice file, skipping`); } // ── STEP 6: Adding background music ─────────────────────────────────── const hasMusic = req.body.hasMusic === 'true'; if (hasMusic) { console.log(`[Job ${jobId}] STEP 6: Generating & mixing music...`); const musicPath = path.join(jobDir, 'music.mp3'); const duration = await getVideoDuration(finalVideo); await generateMusic(musicPath, duration); if (fs.existsSync(musicPath)) { const withMusic = path.join(jobDir, 'with_music.mp4'); const hasAudio = await videoHasAudio(finalVideo); const mixCmd = hasAudio ? `"${ffmpegBin()}" -i "${finalVideo}" -i "${musicPath}" ` + `-filter_complex "[1:a]volume=0.15[music];[0:a]volume=1.0[voice];[voice][music]amix=inputs=2:duration=first:normalize=0[audio]" ` + `-map 0:v:0 -map "[audio]" -c:v copy -c:a aac -b:a 128k -shortest "${withMusic}" -y` : `"${ffmpegBin()}" -i "${finalVideo}" -i "${musicPath}" ` + `-filter_complex "[1:a]volume=0.30,afade=t=in:st=0:d=1[audio]" ` + `-map 0:v:0 -map "[audio]" -c:v copy -c:a aac -b:a 128k -shortest "${withMusic}" -y`; const ok = await ffmpegRun(mixCmd); if (ok && fs.existsSync(withMusic)) { finalVideo = withMusic; console.log(`[Job ${jobId}] STEP 6 ✅ Music added (hasAudio=${hasAudio})`); } else { console.warn(`[Job ${jobId}] STEP 6 ⚠️ Music mix failed, continuing`); } } } else { console.log(`[Job ${jobId}] STEP 6: hasMusic=false, skipping`); } // ── STEP 7: Finalize output ──────────────────────────────────────────── const outputPath = path.join(jobDir, 'final.mp4'); if (finalVideo !== outputPath) fs.copyFileSync(finalVideo, outputPath); const size = fs.statSync(outputPath).size; console.log(`[Job ${jobId}] STEP 7 ✅ Final: ${size}b`); console.log(`[Job ${jobId}] ═══════════════ DONE ═══════════════\n`); // Schedule job dir cleanup in 30 minutes setTimeout(() => { try { fs.rmSync(jobDir, { recursive: true, force: true }); } catch(_e) {} }, 30 * 60 * 1000); return res.json({ success: true, videoUrl: `http://localhost:${PORT}/output/${jobId}/final.mp4`, audioMixed: true, size, }); } catch (err) { console.error(`[Job ${jobId}] ❌ Fatal error:`, err.message); return res.status(500).json({ success: false, error: err.message }); } finally { // Clean up uploaded source files for (const f of uploaded) { try { if (fs.existsSync(f)) fs.unlinkSync(f); } catch(_e) {} } } }); // ── Helpers ─────────────────────────────────────────────────────────────────── function downloadUrl(url, destPath) { return new Promise((resolve, reject) => { const https = require('https'); const http = require('http'); const transport = url.startsWith('https') ? https : http; const file = require('fs').createWriteStream(destPath); const req = transport.get(url, { timeout: 30000 }, (res) => { if (res.statusCode >= 300 && res.statusCode < 400 && res.headers.location) { file.close(); resolve(downloadUrl(res.headers.location, destPath)); return; } if (res.statusCode !== 200) { file.close(); return reject(new Error(`HTTP ${res.statusCode}`)); } res.pipe(file); file.on('finish', () => { file.close(); resolve(destPath); }); file.on('error', reject); }); req.on('error', reject); req.on('timeout', () => { req.destroy(); reject(new Error('Timeout')); }); }); } function parseSRT(content) { const blocks = content.trim().split(/\n\s*\n/); return blocks.map(block => { const lines = block.trim().split('\n'); if (lines.length < 3) return null; const m = lines[1].match(/(\d{2}:\d{2}:\d{2}[,\.]\d{3})\s*-->\s*(\d{2}:\d{2}:\d{2}[,\.]\d{3})/); if (!m) return null; return { start: parseSRTTime(m[1]), end: parseSRTTime(m[2]), text: lines.slice(2).join(' ').trim(), }; }).filter(Boolean); } function parseSRTTime(s) { const [hms, ms = '000'] = s.replace(',', '.').split('.'); const [h, m, sec] = hms.split(':').map(Number); return h * 3600 + m * 60 + sec + parseInt(ms) / 1000; } function ffmpegBin() { try { return require('ffmpeg-static'); } catch(_e) { return 'ffmpeg'; } } function ffmpegRun(cmd) { return new Promise(resolve => { exec(cmd, { maxBuffer: 200 * 1024 * 1024, timeout: 180_000 }, (err, _stdout, stderr) => { if (err) console.warn('[ffmpegRun] Error:', (stderr || err.message).slice(-300)); resolve(!err); }); }); } function getVideoDuration(videoPath) { return new Promise(resolve => { exec(`"${ffmpegBin()}" -i "${videoPath}" 2>&1`, { timeout: 10_000 }, (_err, stdout, stderr) => { const out = (stdout || '') + (stderr || ''); const m = out.match(/Duration: (\d+):(\d+):(\d+)/); resolve(m ? parseInt(m[1]) * 3600 + parseInt(m[2]) * 60 + parseInt(m[3]) || 20 : 20); }); }); } function videoHasAudio(videoPath) { return new Promise(resolve => { exec(`"${ffmpegBin()}" -i "${videoPath}" 2>&1`, { timeout: 5_000 }, (_err, stdout, stderr) => { resolve((stdout + (stderr || '')).includes('Audio:')); }); }); } function generateMusic(outputPath, duration) { // C major chord (C4 + E4 + G4) with pink noise undertone for ambient feel const cmd = `"${ffmpegBin()}" ` + `-f lavfi -i "sine=frequency=261.63:duration=${duration}" ` + `-f lavfi -i "sine=frequency=329.63:duration=${duration}" ` + `-f lavfi -i "sine=frequency=392.00:duration=${duration}" ` + `-f lavfi -i "aevalsrc=random(0)*0.05:d=${duration}:s=44100" ` + `-filter_complex "[0:a]volume=0.12[c];[1:a]volume=0.10[e];[2:a]volume=0.08[g];[3:a]volume=0.06[n];` + `[c][e][g][n]amix=inputs=4:normalize=0,` + `aecho=0.6:0.5:600:0.3,` + `lowpass=f=800,` + `afade=t=in:st=0:d=2,afade=t=out:st=${Math.max(duration - 2, 0)}:d=2[out]" ` + `-map "[out]" -c:a libmp3lame -b:a 128k -ar 44100 "${outputPath}" -y`; return ffmpegRun(cmd); } // ── Start ───────────────────────────────────────────────────────────────────── app.listen(PORT, () => { console.log(`[Remotion Server] ✅ Running on port ${PORT}`); // Warm up the Remotion bundle on startup (runs in background) getBundle().catch(err => console.warn('[Bundle] Warmup failed:', err.message)); });