const express = require('express'); const path = require('path'); const fs = require('fs'); const { v4: uuidv4 } = require('uuid'); const cron = require('node-cron'); const app = express(); const PORT = process.env.PORT || 7860; const API_KEY = process.env.REMOTION_API_KEY || ''; const BUNDLE_DIR = path.join(__dirname, 'bundle'); const STORAGE_DIR = path.join(__dirname, 'storage', 'videos'); const MAX_QUEUE = 5; const MAX_DURATION_SEC = 60; // Ensure storage dir exists if (!fs.existsSync(STORAGE_DIR)) fs.mkdirSync(STORAGE_DIR, { recursive: true }); app.use(express.json({ limit: '10mb' })); app.use('/storage', express.static(path.join(__dirname, 'storage'))); app.use(express.static(path.join(__dirname, 'public'))); // --- In-memory job store (persisted to disk) --- const JOBS_FILE = path.join(__dirname, 'storage', 'jobs.json'); let jobs = []; let isRendering = false; function loadJobs() { try { if (fs.existsSync(JOBS_FILE)) { jobs = JSON.parse(fs.readFileSync(JOBS_FILE, 'utf8')); // Reset any stuck "rendering" jobs on restart jobs.forEach(j => { if (j.status === 'rendering') j.status = 'queued'; }); } } catch (e) { jobs = []; } } function saveJobs() { try { fs.writeFileSync(JOBS_FILE, JSON.stringify(jobs, null, 2)); } catch (e) { /* */ } } loadJobs(); // --- Template Registry --- const TEMPLATES = { 'text-explainer': { id: 'TextExplainer', name: 'Text Explainer', description: 'Title cards + bullet points with smooth transitions. Great for educational content.', ratio: '16:9', width: 1280, height: 720, fps: 30, defaultDuration: 30, requiredProps: ['title', 'hook', 'bullets'], optionalProps: ['cta', 'ctaUrl', 'brandColor'], }, 'product-promo': { id: 'ProductPromo', name: 'Product Promo', description: 'Sales offer showcase with price animation + benefits. Perfect for digital products.', ratio: '16:9', width: 1280, height: 720, fps: 30, defaultDuration: 10, requiredProps: ['productName', 'price', 'benefits'], optionalProps: ['cta', 'ctaUrl', 'brandColor'], }, 'social-short': { id: 'SocialShort', name: 'Social Short', description: 'Vertical format for TikTok/Reels. Hook-first, fast-paced.', ratio: '9:16', width: 720, height: 1280, fps: 30, defaultDuration: 15, requiredProps: ['hook', 'points'], optionalProps: ['cta', 'brandColor'], }, 'seo-summary': { id: 'SeoSummary', name: 'SEO Summary', description: 'Article summary with key takeaways. Repurpose blog content into video.', ratio: '16:9', width: 1280, height: 720, fps: 30, defaultDuration: 20, requiredProps: ['articleTitle', 'keyPoints'], optionalProps: ['source', 'cta', 'brandColor'], }, }; // --- Auth Middleware --- function authCheck(req, res, next) { if (!API_KEY) return next(); // No key set = open access const token = (req.headers.authorization || '').replace('Bearer ', ''); if (token === API_KEY) return next(); res.status(401).json({ error: 'Unauthorized' }); } // --- API Routes --- app.get('/api/health', (req, res) => { res.json({ status: 'ok', templates: Object.keys(TEMPLATES).length, jobs: { total: jobs.length, rendering: jobs.filter(j => j.status === 'rendering').length, queued: jobs.filter(j => j.status === 'queued').length }, bundleReady: fs.existsSync(BUNDLE_DIR), }); }); app.get('/api/templates', (req, res) => { const list = Object.entries(TEMPLATES).map(([key, t]) => ({ key, name: t.name, description: t.description, ratio: t.ratio, defaultDuration: t.defaultDuration, requiredProps: t.requiredProps, optionalProps: t.optionalProps, })); res.json({ templates: list }); }); app.post('/api/render', authCheck, (req, res) => { const { template, inputProps, duration } = req.body; if (!template || !TEMPLATES[template]) { return res.status(400).json({ error: `Invalid template. Available: ${Object.keys(TEMPLATES).join(', ')}` }); } const tmpl = TEMPLATES[template]; // Validate required props for (const prop of tmpl.requiredProps) { if (!inputProps || inputProps[prop] === undefined) { return res.status(400).json({ error: `Missing required prop: ${prop}` }); } } // Check queue limit const pendingCount = jobs.filter(j => j.status === 'queued' || j.status === 'rendering').length; if (pendingCount >= MAX_QUEUE) { return res.status(429).json({ error: `Queue full (${MAX_QUEUE} max). Try again later.` }); } const durationSec = Math.min(duration || tmpl.defaultDuration, MAX_DURATION_SEC); const durationInFrames = durationSec * tmpl.fps; const job = { id: `vid_${Date.now()}_${uuidv4().slice(0, 8)}`, template, compositionId: tmpl.id, inputProps: inputProps || {}, durationInFrames, width: tmpl.width, height: tmpl.height, fps: tmpl.fps, status: 'queued', progress: 0, outputFile: null, downloadUrl: null, error: null, created: new Date().toISOString(), completed: null, }; jobs.push(job); saveJobs(); // Trigger render queue processing processQueue(); res.json({ jobId: job.id, status: job.status, estimatedTime: `${Math.round(durationSec * 2)}s`, }); }); app.get('/api/jobs', (req, res) => { const limit = parseInt(req.query.limit) || 20; const recent = [...jobs].reverse().slice(0, limit).map(sanitizeJob); res.json({ jobs: recent }); }); app.get('/api/jobs/:id', (req, res) => { const job = jobs.find(j => j.id === req.params.id); if (!job) return res.status(404).json({ error: 'Job not found' }); res.json(sanitizeJob(job)); }); app.get('/api/download/:id', (req, res) => { const job = jobs.find(j => j.id === req.params.id); if (!job || job.status !== 'complete' || !job.outputFile) { return res.status(404).json({ error: 'Video not available' }); } const filePath = path.join(STORAGE_DIR, job.outputFile); if (!fs.existsSync(filePath)) { return res.status(404).json({ error: 'Video file expired or deleted' }); } res.download(filePath, `${job.id}.mp4`); }); app.delete('/api/jobs/:id', authCheck, (req, res) => { const idx = jobs.findIndex(j => j.id === req.params.id); if (idx === -1) return res.status(404).json({ error: 'Job not found' }); const job = jobs[idx]; // Delete video file if exists if (job.outputFile) { const filePath = path.join(STORAGE_DIR, job.outputFile); try { if (fs.existsSync(filePath)) fs.unlinkSync(filePath); } catch (e) { /* */ } } jobs.splice(idx, 1); saveJobs(); res.json({ success: true }); }); // Serve web UI app.get('/', (req, res) => { res.sendFile(path.join(__dirname, 'public', 'index.html')); }); function sanitizeJob(job) { return { id: job.id, template: job.template, status: job.status, progress: job.progress, downloadUrl: job.status === 'complete' ? `/api/download/${job.id}` : null, error: job.error, created: job.created, completed: job.completed, inputProps: job.inputProps, }; } // --- Render Queue Processor --- async function processQueue() { if (isRendering) return; const next = jobs.find(j => j.status === 'queued'); if (!next) return; isRendering = true; next.status = 'rendering'; next.progress = 5; saveJobs(); console.log(`[Renderer] Starting: ${next.id} (${next.template})`); try { // Lazy-load Remotion renderer (heavy module) const { renderMedia, selectComposition } = require('@remotion/renderer'); const bundleLocation = BUNDLE_DIR; if (!fs.existsSync(bundleLocation)) { throw new Error('Bundle not found. Run: npx remotion bundle src/index.ts --out-dir bundle'); } next.progress = 10; saveJobs(); const composition = await selectComposition({ serveUrl: bundleLocation, id: next.compositionId, inputProps: next.inputProps, }); // Override duration composition.durationInFrames = next.durationInFrames; composition.width = next.width; composition.height = next.height; composition.fps = next.fps; next.progress = 20; saveJobs(); const outputFile = `${next.id}.mp4`; const outputPath = path.join(STORAGE_DIR, outputFile); await renderMedia({ composition, serveUrl: bundleLocation, codec: 'h264', outputLocation: outputPath, inputProps: next.inputProps, chromiumOptions: { executablePath: process.env.CHROME_PATH || process.env.PUPPETEER_EXECUTABLE_PATH || undefined, args: ['--no-sandbox', '--disable-setuid-sandbox', '--disable-dev-shm-usage'], }, concurrency: 1, imageFormat: 'jpeg', jpegQuality: 80, timeoutInMilliseconds: 180000, onProgress: ({ progress }) => { next.progress = Math.round(20 + progress * 75); // Don't save on every frame update — too expensive if (next.progress % 10 === 0) saveJobs(); }, }); next.status = 'complete'; next.progress = 100; next.outputFile = outputFile; next.downloadUrl = `/api/download/${next.id}`; next.completed = new Date().toISOString(); console.log(`[Renderer] Complete: ${next.id} → ${outputFile}`); } catch (err) { console.error(`[Renderer] Failed: ${next.id}`, err.message); next.status = 'failed'; next.error = err.message; } saveJobs(); isRendering = false; // Process next in queue setImmediate(processQueue); } // --- Cleanup Cron: delete videos older than 24h --- cron.schedule('0 * * * *', () => { const cutoff = Date.now() - 24 * 60 * 60 * 1000; let cleaned = 0; try { const files = fs.readdirSync(STORAGE_DIR); for (const file of files) { if (!file.endsWith('.mp4')) continue; const filePath = path.join(STORAGE_DIR, file); const stat = fs.statSync(filePath); if (stat.mtimeMs < cutoff) { fs.unlinkSync(filePath); cleaned++; } } // Also clean job entries for expired videos jobs = jobs.filter(j => { if (j.status === 'complete' && j.outputFile) { const exists = fs.existsSync(path.join(STORAGE_DIR, j.outputFile)); if (!exists) { j.status = 'expired'; return true; } } return true; }); if (cleaned > 0) { console.log(`[Cleanup] Removed ${cleaned} expired video(s)`); saveJobs(); } } catch (e) { /* */ } }); // --- Boot --- app.listen(PORT, '0.0.0.0', () => { console.log(`[Remotion Studio] Running on port ${PORT}`); console.log(`[Remotion Studio] Bundle: ${fs.existsSync(BUNDLE_DIR) ? 'READY' : 'NOT FOUND — run npm run build'}`); console.log(`[Remotion Studio] Templates: ${Object.keys(TEMPLATES).join(', ')}`); console.log(`[Remotion Studio] Auth: ${API_KEY ? 'ENABLED' : 'DISABLED (open access)'}`); });