Spaces:
Sleeping
Sleeping
| /** | |
| * Lumina Studio β Video Render Engine [PRODUCTION v2.0] | |
| * ====================================================== | |
| * Bug Fixes Applied: | |
| * FIX-1 Scheduled MP4 cleanup β no more disk exhaustion | |
| * FIX-2 Puppeteer network interception β real SSRF protection (replaces bypassable regex) | |
| * FIX-3 Frame loop starts at i=0 β initial animation state is now captured | |
| * FIX-4 JPEG frames instead of PNG β ~40% faster capture, same visual quality | |
| * FIX-5 MAX_TOTAL_FRAMES hard limit β blocks runaway renders server-side | |
| * FIX-6 FFmpeg -start_number 0 β aligns with new i=0 loop start | |
| * FIX-7 parseParams throws on error β clean 400 response instead of 500 | |
| */ | |
| const express = require('express'); | |
| const fs = require('fs'); | |
| const path = require('path'); | |
| const util = require('util'); | |
| const { exec } = require('child_process'); | |
| const os = require('os'); | |
| const execPromise = util.promisify(exec); | |
| const app = express(); | |
| // βββ Config βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| const CONFIG = { | |
| PORT : process.env.PORT || 3000, | |
| POOL_SIZE : 3, | |
| MAX_DURATION : 30, | |
| MAX_FPS : 60, | |
| MAX_TOTAL_FRAMES : 1800, // FIX-5: 30s @ 60fps hard cap | |
| MIN_FREE_DISK_MB : 500, | |
| RATE_LIMIT : 10, // requests per minute per IP | |
| OUTPUT_DIR : path.join(__dirname, 'public'), | |
| TEMP_BASE_DIR : os.tmpdir(), | |
| VIDEO_MAX_AGE_MS : 2 * 60 * 60 * 1000, // FIX-1: expire videos after 2h | |
| CLEANUP_INTERVAL : 60 * 60 * 1000, // FIX-1: run cleanup every 1h | |
| }; | |
| // βββ Middleware ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| app.use(express.json({ limit: '10mb' })); | |
| app.use(express.static('public')); | |
| // Simple in-memory rate limiter (swap for redis-rate-limit in multi-instance prod) | |
| const rateLimitMap = new Map(); | |
| function rateLimit(req, res, next) { | |
| const ip = req.ip; | |
| const now = Date.now(); | |
| if (!rateLimitMap.has(ip)) rateLimitMap.set(ip, []); | |
| const hits = rateLimitMap.get(ip).filter(t => now - t < 60_000); | |
| hits.push(now); | |
| rateLimitMap.set(ip, hits); | |
| if (hits.length > CONFIG.RATE_LIMIT) | |
| return res.status(429).json({ message: 'Too many requests. Try again in a minute.' }); | |
| next(); | |
| } | |
| // βββ FIX-1: Scheduled Video Cleanup ββββββββββββββββββββββββββββββββββββββββββ | |
| // Removes MP4 files older than VIDEO_MAX_AGE_MS from the output directory. | |
| // Previously, output videos were never deleted, causing eventual disk exhaustion. | |
| function runCleanup() { | |
| const now = Date.now(); | |
| try { | |
| const files = fs.readdirSync(CONFIG.OUTPUT_DIR).filter(f => f.endsWith('.mp4')); | |
| let deleted = 0; | |
| for (const file of files) { | |
| const filePath = path.join(CONFIG.OUTPUT_DIR, file); | |
| try { | |
| const age = now - fs.statSync(filePath).mtimeMs; | |
| if (age > CONFIG.VIDEO_MAX_AGE_MS) { | |
| fs.rmSync(filePath, { force: true }); | |
| deleted++; | |
| } | |
| } catch (_) { /* file may be open / already deleted β skip */ } | |
| } | |
| if (deleted > 0) | |
| console.log(`ποΈ [CLEANUP] Removed ${deleted} expired video(s).`); | |
| } catch (err) { | |
| console.error('β οΈ [CLEANUP] Error during cleanup:', err.message); | |
| } | |
| } | |
| // Run once 5s after boot (cleans any videos from a previous crash) | |
| setTimeout(runCleanup, 5_000); | |
| // Then run on the configured interval | |
| setInterval(runCleanup, CONFIG.CLEANUP_INTERVAL); | |
| // βββ Browser Pool ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| const pool = { | |
| browsers : [], // idle browser instances | |
| active : 0, // browsers currently rendering | |
| queue : [], // callbacks waiting for a browser | |
| async acquire() { | |
| return new Promise(async (resolve, reject) => { | |
| const tryAcquire = async () => { | |
| if (this.browsers.length > 0) { | |
| this.active++; | |
| resolve(this.browsers.pop()); | |
| } else if (this.active < CONFIG.POOL_SIZE) { | |
| this.active++; | |
| try { | |
| // Dynamic import handles both ESM puppeteer and CommonJS environments | |
| const mod = await import('puppeteer'); | |
| const puppeteer = mod.default || mod; | |
| const browser = await puppeteer.launch({ | |
| headless : 'new', | |
| args : [ | |
| '--no-sandbox', | |
| '--disable-setuid-sandbox', | |
| '--disable-dev-shm-usage', | |
| '--disable-accelerated-2d-canvas', | |
| '--disable-gpu', | |
| '--js-flags=--max-old-space-size=512', | |
| ], | |
| }); | |
| resolve(browser); | |
| } catch (err) { | |
| this.active--; | |
| reject(err); | |
| } | |
| } else { | |
| // Pool is full β queue this request | |
| this.queue.push(tryAcquire); | |
| } | |
| }; | |
| tryAcquire(); | |
| }); | |
| }, | |
| release(browser) { | |
| this.active--; | |
| const healthy = browser && | |
| (typeof browser.isConnected === 'function' | |
| ? browser.isConnected() // Puppeteer v20+ | |
| : !browser._closed); // Puppeteer older | |
| if (healthy) { | |
| this.browsers.push(browser); | |
| } else if (browser) { | |
| browser.close().catch(() => {}); | |
| } | |
| if (this.queue.length > 0) this.queue.shift()(); | |
| }, | |
| async drainAll() { | |
| for (const b of this.browsers) { | |
| try { await b.close(); } catch (_) {} | |
| } | |
| this.browsers = []; | |
| }, | |
| }; | |
| // βββ Helpers βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| async function getFreeDiskMB(dir) { | |
| try { | |
| const isWin = process.platform === 'win32'; | |
| const cmd = isWin | |
| ? 'wmic logicaldisk get freespace' | |
| : `df -m "${dir}" | tail -1 | awk '{print $4}'`; | |
| const { stdout } = await execPromise(cmd); | |
| return parseInt(stdout.trim().split('\n').pop()) || 9999; | |
| } catch { | |
| return 9999; // cannot determine β assume enough space | |
| } | |
| } | |
| // FIX-5 + FIX-7: Validate all render params, throw a clean Error on failure. | |
| // Previously parseParams never threw β bad values silently slipped through. | |
| function parseParams(body) { | |
| const duration = Math.min(Math.max(parseInt(body.duration) || 3, 1), CONFIG.MAX_DURATION); | |
| const fps = [24, 30, 60].includes(parseInt(body.fps)) ? parseInt(body.fps) : 30; | |
| const totalFrames = duration * fps; | |
| if (totalFrames > CONFIG.MAX_TOTAL_FRAMES) { | |
| throw new Error( | |
| `Too many frames requested (${totalFrames}). ` + | |
| `Maximum is ${CONFIG.MAX_TOTAL_FRAMES} frames ` + | |
| `(e.g. ${Math.floor(CONFIG.MAX_TOTAL_FRAMES / fps)}s at ${fps}fps).` | |
| ); | |
| } | |
| const aspectMap = { | |
| '16:9' : { width: 1920, height: 1080 }, | |
| '9:16' : { width: 1080, height: 1920 }, | |
| '1:1' : { width: 1080, height: 1080 }, | |
| }; | |
| const dims = aspectMap[body.aspect] || { width: 1920, height: 1080 }; | |
| return { duration, fps, ...dims, totalFrames }; | |
| } | |
| // βββ SSE Progress Registry βββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| // Clients subscribe via GET /api/progress/:jobId before calling POST /api/render. | |
| const progressClients = new Map(); | |
| app.get('/api/progress/:jobId', (req, res) => { | |
| res.set({ | |
| 'Content-Type' : 'text/event-stream', | |
| 'Cache-Control' : 'no-cache', | |
| 'Connection' : 'keep-alive', | |
| 'Access-Control-Allow-Origin': '*', | |
| }); | |
| res.flushHeaders(); | |
| progressClients.set(req.params.jobId, res); | |
| req.on('close', () => progressClients.delete(req.params.jobId)); | |
| }); | |
| // βββ Main Render Endpoint ββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| app.post('/api/render', rateLimit, async (req, res) => { | |
| const { code } = req.body; | |
| if (!code || typeof code !== 'string' || code.trim().length < 10) | |
| return res.status(400).json({ message: 'No valid HTML code provided.' }); | |
| // FIX-7: parseParams can now throw a descriptive 400 error | |
| let params; | |
| try { | |
| params = parseParams(req.body); | |
| } catch (err) { | |
| return res.status(400).json({ message: err.message }); | |
| } | |
| const jobId = req.body.jobId || `job_${Date.now()}`; | |
| const videoId = `video_${Date.now()}_${Math.random().toString(36).slice(2, 7)}`; | |
| const framesDir = path.join(CONFIG.TEMP_BASE_DIR, `lumina_frames_${videoId}`); | |
| const outputPath = path.join(CONFIG.OUTPUT_DIR, `${videoId}.mp4`); | |
| // Helper: push SSE update to the subscribed client for this job | |
| const sse = progressClients.get(jobId); | |
| const progress = (step, message) => { | |
| if (sse && !sse.writableEnded) | |
| sse.write(`data: ${JSON.stringify({ step, total: params.totalFrames, message })}\n\n`); | |
| }; | |
| const freeMB = await getFreeDiskMB(CONFIG.OUTPUT_DIR); | |
| if (freeMB < CONFIG.MIN_FREE_DISK_MB) | |
| return res.status(507).json({ | |
| message: `Insufficient disk space: ${freeMB}MB free, need ${CONFIG.MIN_FREE_DISK_MB}MB.` | |
| }); | |
| console.log( | |
| `\nπ [${videoId}] Starting | ` + | |
| `${params.width}Γ${params.height} | ` + | |
| `${params.duration}s @ ${params.fps}fps | ` + | |
| `${params.totalFrames} frames` | |
| ); | |
| let browser = null; | |
| let page = null; | |
| try { | |
| fs.mkdirSync(framesDir, { recursive: true }); | |
| fs.mkdirSync(CONFIG.OUTPUT_DIR, { recursive: true }); | |
| // ββ Acquire browser ββββββββββββββββββββββββββββββββββββββββββββββββ | |
| progress(0, 'Acquiring render engine...'); | |
| browser = await pool.acquire(); | |
| console.log(`π [${videoId}] Browser acquired (pool active: ${pool.active}/${CONFIG.POOL_SIZE})`); | |
| page = await browser.newPage(); | |
| page.setDefaultTimeout((params.duration + 30) * 1000); | |
| await page.setViewport({ width: params.width, height: params.height }); | |
| // ββ FIX-2: Chromium-level network interception βββββββββββββββββββββ | |
| // Replaces the old regex-based sanitizeHtml() which could be bypassed | |
| // with unicode escapes or obfuscated fetch() calls. | |
| // This blocks ALL external requests at the network layer, making SSRF | |
| // impossible regardless of how the user code is written. | |
| await page.setRequestInterception(true); | |
| page.on('request', (interceptedReq) => { | |
| const url = interceptedReq.url(); | |
| const allowed = | |
| url.startsWith('data:') || | |
| url.startsWith('about:') || | |
| /fonts\.(googleapis|gstatic)\.com/.test(url); | |
| if (allowed) { | |
| interceptedReq.continue(); | |
| } else { | |
| console.warn(`π« [${videoId}] Blocked request: ${url.slice(0, 100)}`); | |
| interceptedReq.abort('blockedbyclient'); | |
| } | |
| }); | |
| // Use 'load' β 'networkidle0' is unreliable with interception active | |
| await page.setContent(code, { waitUntil: 'load', timeout: 15_000 }); | |
| // ββ FIX-3: Capture from frame 0 βββββββββββββββββββββββββββββββββββ | |
| // Original loop started at i=1, skipping the initial animation state | |
| // (progress=0). Now we capture frame 0 first, giving a complete video. | |
| progress(0, `Capturing ${params.totalFrames + 1} frames...`); | |
| const captureStart = Date.now(); | |
| for (let i = 0; i <= params.totalFrames; i++) { | |
| await page.evaluate((frame) => { | |
| if (typeof window.goToFrame === 'function') window.goToFrame(frame); | |
| }, i); | |
| const frameName = String(i).padStart(5, '0'); | |
| // ββ FIX-4: JPEG instead of PNG βββββββββββββββββββββββββββββββββ | |
| // JPEG at quality 95 is visually indistinguishable from PNG for | |
| // motion graphics, but captures ~40% faster due to smaller file I/O. | |
| await page.screenshot({ | |
| path : path.join(framesDir, `frame_${frameName}.jpg`), | |
| type : 'jpeg', | |
| quality: 95, | |
| }); | |
| if (i % 10 === 0 || i === params.totalFrames) { | |
| const pct = Math.round((i / params.totalFrames) * 100); | |
| progress(i, `Captured frame ${i}/${params.totalFrames} (${pct}%)`); | |
| if (i % 50 === 0) | |
| console.log(` πΈ [${videoId}] Frame ${i}/${params.totalFrames}`); | |
| } | |
| } | |
| await page.close(); | |
| page = null; | |
| pool.release(browser); | |
| browser = null; | |
| const captureSec = ((Date.now() - captureStart) / 1000).toFixed(1); | |
| console.log(`πΈ [${videoId}] Captured ${params.totalFrames + 1} frames in ${captureSec}s`); | |
| // ββ FFmpeg encode ββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| progress(params.totalFrames, 'Encoding MP4 with FFmpeg...'); | |
| console.log(`π₯ [${videoId}] FFmpeg encoding...`); | |
| const ffmpegCmd = [ | |
| 'ffmpeg -y', | |
| '-start_number 0', // FIX-6: match i=0 start | |
| `-framerate ${params.fps}`, | |
| `-i "${path.join(framesDir, 'frame_%05d.jpg')}"`, // FIX-4: .jpg | |
| '-c:v libx264', | |
| '-preset veryfast', | |
| '-crf 23', | |
| '-pix_fmt yuv420p', | |
| `-vf "scale=${params.width}:${params.height}"`, | |
| `"${outputPath}"`, | |
| ].join(' '); | |
| const { stderr } = await execPromise(ffmpegCmd); | |
| if (stderr && stderr.includes('Error')) { | |
| throw new Error('FFmpeg encoding error: ' + stderr.split('\n').slice(-3).join(' ')); | |
| } | |
| const stat = fs.statSync(outputPath); | |
| const sizeMB = (stat.size / 1024 / 1024).toFixed(2); | |
| console.log(`β [${videoId}] Render complete β ${sizeMB}MB`); | |
| progress(params.totalFrames, `Done! ${sizeMB}MB`); | |
| if (sse && !sse.writableEnded) sse.end(); | |
| res.json({ | |
| videoUrl : `/${videoId}.mp4`, | |
| meta : { | |
| duration : params.duration, | |
| fps : params.fps, | |
| frames : params.totalFrames, | |
| sizeMB, | |
| expiresInMs : CONFIG.VIDEO_MAX_AGE_MS, | |
| }, | |
| }); | |
| } catch (err) { | |
| console.error(`β [${videoId}] FAILED:`, err.message); | |
| progress(-1, `Error: ${err.message}`); | |
| if (sse && !sse.writableEnded) sse.end(); | |
| res.status(500).json({ message: 'Render failed: ' + err.message }); | |
| } finally { | |
| // Always clean up, even on error | |
| if (page) try { await page.close(); } catch (_) {} | |
| if (browser) try { pool.release(browser); } catch (_) {} | |
| if (fs.existsSync(framesDir)) { | |
| fs.rmSync(framesDir, { recursive: true, force: true }); | |
| console.log(`π§Ή [${videoId}] Temp frames deleted`); | |
| } | |
| } | |
| }); | |
| // βββ Health Check ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| app.get('/api/health', async (req, res) => { | |
| const freeMB = await getFreeDiskMB(CONFIG.OUTPUT_DIR); | |
| let videoCount = 0; | |
| try { | |
| videoCount = fs.readdirSync(CONFIG.OUTPUT_DIR).filter(f => f.endsWith('.mp4')).length; | |
| } catch (_) {} | |
| res.json({ | |
| status : 'ok', | |
| pool : { active: pool.active, idle: pool.browsers.length, max: CONFIG.POOL_SIZE }, | |
| disk : { freeMB, minRequired: CONFIG.MIN_FREE_DISK_MB }, | |
| videos : { stored: videoCount, maxAgeHours: CONFIG.VIDEO_MAX_AGE_MS / 3_600_000 }, | |
| limits : { maxFrames: CONFIG.MAX_TOTAL_FRAMES, maxDuration: CONFIG.MAX_DURATION, maxFps: CONFIG.MAX_FPS }, | |
| uptime : Math.floor(process.uptime()) + 's', | |
| }); | |
| }); | |
| // βββ Graceful Shutdown ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| async function shutdown(signal) { | |
| console.log(`\nβ‘ [SHUTDOWN] ${signal} β draining browser pool...`); | |
| await pool.drainAll(); | |
| process.exit(0); | |
| } | |
| process.on('SIGINT', () => shutdown('SIGINT')); | |
| process.on('SIGTERM', () => shutdown('SIGTERM')); | |
| process.on('uncaughtException', (err) => console.error('π₯ Uncaught Exception:', err)); | |
| process.on('unhandledRejection', (err) => console.error('π₯ Unhandled Rejection:', err)); | |
| // βββ Start ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| app.listen(CONFIG.PORT, () => { | |
| console.log(` | |
| ββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| β π¬ Lumina Studio Render Engine v2.0 β | |
| β π http://localhost:${CONFIG.PORT} β | |
| β π Browser pool: ${CONFIG.POOL_SIZE} instances β | |
| β π SSRF protection: network interception β | |
| β ποΈ Auto-cleanup: every ${CONFIG.CLEANUP_INTERVAL / 60_000}min (${CONFIG.VIDEO_MAX_AGE_MS / 3_600_000}h TTL) β | |
| β πΌοΈ Frame format: JPEG q95 (fast) β | |
| β π Max frames: ${CONFIG.MAX_TOTAL_FRAMES} (${CONFIG.MAX_DURATION}s @ ${CONFIG.MAX_FPS}fps) β | |
| ββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| `); | |
| }); |