remotion-render / server.js
Foxik163's picture
Add URL-mode clip download: HF Space fetches Pexels clips itself
1a1ee4c
'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));
});