Spaces:
Sleeping
Sleeping
| ; | |
| 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)); | |
| }); | |