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