const express = require('express'); const cors = require('cors'); const puppeteer = require('puppeteer'); const ffmpeg = require('fluent-ffmpeg'); const ffmpegStatic = require('ffmpeg-static'); const fs = require('fs'); const path = require('path'); const os = require('os'); const { randomUUID } = require('crypto'); const { execSync } = require('child_process'); function hasGpu() { try { execSync('nvidia-smi', { timeout: 3000, stdio: 'ignore' }); return true; } catch (e) { return false; } } const SERVER_HAS_GPU = hasGpu(); ffmpeg.setFfmpegPath(ffmpegStatic); const app = express(); app.use(express.json({ limit: '50mb' })); const requireAuth = (req, res, next) => { const apiKey = process.env.STUDIO_API_KEY; if (!apiKey) return next(); const authHeader = req.headers.authorization; if (!authHeader || authHeader !== `Bearer ${apiKey}`) { console.warn(`[Security] Blocked unauthorized request from IP: ${req.ip}`); return res.status(401).json({ error: 'Unauthorized: Invalid or missing API Key' }); } next(); }; const TIME_SHIM = ` window.__VTIME_INSTALLED__ = true; var vtime = 0; window.__nativeSetTimeout = window.setTimeout; window.__nativeClearTimeout = window.clearTimeout; const __originalGetContext = HTMLCanvasElement.prototype.getContext; HTMLCanvasElement.prototype.getContext = function(type, options) { if (type === 'webgl' || type === 'webgl2' || type === 'experimental-webgl') { options = options || {}; options.preserveDrawingBuffer = true; } if (type === '2d') { options = options || {}; options.willReadFrequently = true; } return __originalGetContext.call(this, type, options); }; window.__nativeSetInterval = window.setInterval; window.__nativeClearInterval = window.clearInterval; window.__nativeDate = window.Date; window.__nativePerformance = window.performance; var _origDate = Date; window.Date = function() { if (arguments.length === 0) return new _origDate(vtime); return new _origDate(...arguments); }; window.Date.now = function() { return vtime; }; window.Date.parse = _origDate.parse; window.Date.UTC = _origDate.UTC; if (window.performance) { window.performance.now = function() { return vtime; }; } var rafCallbacks = []; var rafId = 0; window.__origRaf = window.requestAnimationFrame; window.__origCancelRaf = window.cancelAnimationFrame; window.requestAnimationFrame = function(cb){ rafId++; rafCallbacks.push({id: rafId, cb: cb}); return rafId; }; window.cancelAnimationFrame = function(id){ rafCallbacks = rafCallbacks.filter(function(x){ return x.id !== id; }); }; var timeoutCallbacks = []; var timeoutId = 0; window.setTimeout = function(cb, delay){ timeoutId++; timeoutCallbacks.push({id: timeoutId, cb: typeof cb === 'function' ? cb : function(){}, triggerTime: vtime + (delay || 0)}); return timeoutId; }; window.clearTimeout = function(id){ timeoutCallbacks = timeoutCallbacks.filter(function(x){ return x.id !== id; }); }; var intervalCallbacks = []; var intervalId = 0; window.setInterval = function(cb, delay){ intervalId++; intervalCallbacks.push({id: intervalId, cb: typeof cb === 'function' ? cb : function(){}, interval: delay || 0, nextTime: vtime + (delay || 0)}); return intervalId; }; window.clearInterval = function(id){ intervalCallbacks = intervalCallbacks.filter(function(x){ return x.id !== id; }); }; window.__advanceVTime = function(target){ var STEP = 8; while (vtime < target) { vtime = Math.min(vtime + STEP, target); var pendingTimeouts = timeoutCallbacks; timeoutCallbacks = []; for (var k = 0; k < pendingTimeouts.length; k++) { if (pendingTimeouts[k].triggerTime <= vtime) { try { pendingTimeouts[k].cb(); } catch(e){} } else { timeoutCallbacks.push(pendingTimeouts[k]); } } for (var l = 0; l < intervalCallbacks.length; l++) { if (intervalCallbacks[l].nextTime <= vtime) { try { intervalCallbacks[l].cb(); } catch(e){} intervalCallbacks[l].nextTime = vtime + intervalCallbacks[l].interval; } } var cbs = rafCallbacks; rafCallbacks = []; for (var j = 0; j < cbs.length; j++) { try { cbs[j].cb(vtime); } catch(e){} } if (document.getAnimations) { document.getAnimations().forEach(function(a){ try { a.pause(); // CRITICAL: Freeze CSS animations so they don't bleed during page.screenshot() delay if (a.__vstartTime === undefined) a.__vstartTime = vtime; a.currentTime = vtime - a.__vstartTime; } catch(e){} }); } try { if (window.gsap && window.gsap.globalTimeline) { window.gsap.globalTimeline.pause(); window.gsap.globalTimeline.seek(vtime / 1000); } if (window.__animeInstances) { window.__animeInstances.forEach(function(a){ try { a.pause(); a.seek(vtime); } catch(e){} }); } } catch(e){} } }; `; function injectShim(html) { let processedHtml = html; const script = ``; if (/]*>/i.test(processedHtml)) return processedHtml.replace(/]*>/i, (m) => m + script); return script + processedHtml; } // ---------------------------------------------------------------------------- // ASYNCHRONOUS JOB QUEUE SYSTEM // ---------------------------------------------------------------------------- const jobs = new Map(); // Store all job states const queue = []; // Queue of jobIds waiting to be processed let isProcessing = false; async function processQueue() { if (isProcessing || queue.length === 0) return; isProcessing = true; while (queue.length > 0) { const jobId = queue.shift(); const job = jobs.get(jobId); if (!job || job.isCancelled) continue; // Update positions for everyone left in the queue queue.forEach((id, index) => { const qJob = jobs.get(id); if (qJob) qJob.position = index + 1; }); job.status = 'processing'; job.position = 0; try { console.log(`[Job ${jobId}] Starting render process...`); await executeRenderJob(jobId, job.reqBody); job.status = 'done'; } catch (err) { console.error(`[Job ${jobId}] Failed:`, err); job.status = 'error'; job.error = err.message; if (job.tempDir) { try { fs.rmSync(job.tempDir, { recursive: true, force: true }); } catch (e) {} } } } isProcessing = false; } async function executeRenderJob(jobId, reqBody) { const { html, fps = 60, duration = 5, width = 1920, height = 1080, zoom = 1, panX, panY, exportScale = 1 } = reqBody; const tempDir = path.join(os.tmpdir(), `reelify_${jobId}`); fs.mkdirSync(tempDir, { recursive: true }); const totalFrames = Math.round(fps * duration); const frameIntervalMs = 1000 / fps; const pX = panX ?? (width / 2); const pY = panY ?? (height / 2); const finalScale = Number((zoom * exportScale).toFixed(3)); const jobState = jobs.get(jobId); jobState.tempDir = tempDir; let totalFramesRendered = 0; async function renderFrameChunk(workerId, startFrame, endFrame) { let browser; try { const puppeteerArgs = [ '--no-sandbox', '--disable-setuid-sandbox', '--disable-dev-shm-usage', '--disable-web-security', '--disable-features=IsolateOrigins,site-per-process', '--js-flags="--max-old-space-size=4096"' ]; if (!SERVER_HAS_GPU) puppeteerArgs.push('--disable-gpu'); browser = await puppeteer.launch({ executablePath: process.env.PUPPETEER_EXECUTABLE_PATH || undefined, headless: 'new', protocolTimeout: 3600000, args: puppeteerArgs }); const page = await browser.newPage(); await page.setViewport({ width, height, deviceScaleFactor: finalScale }); const processedHtml = injectShim(html); try { await page.setContent(processedHtml, { waitUntil: 'networkidle0', timeout: 30000 }); } catch (e) { console.warn(`[Job ${jobId}] Timeout waiting for networkidle0. Proceeding.`); } const clipWidth = Math.round(width / zoom); const clipHeight = Math.round(height / zoom); const clipX = Math.round(pX - (clipWidth / 2)); const clipY = Math.round(pY - (clipHeight / 2)); // SHARDING FIX: We ALWAYS loop from 0 to endFrame! // This guarantees physics engines evaluate perfectly in order. for (let i = 0; i <= endFrame; i++) { if (jobState.isCancelled) throw new Error('Job cancelled'); const timeMs = i * frameIntervalMs; await page.evaluate((t) => { if (typeof window.__renderFrame === 'function') { return window.__renderFrame(t); } else if (typeof window.__advanceVTime === 'function') { window.__advanceVTime(t); } }, timeMs); // If we haven't reached our chunk, fast-forward if (i < startFrame) { await new Promise(r => setTimeout(r, 5)); continue; } await new Promise(r => setTimeout(r, 60)); const framePath = path.join(tempDir, `frame_${String(i).padStart(5, '0')}.png`); await page.evaluate(async (hasGpu) => { if (hasGpu) return; const canvases = Array.from(document.querySelectorAll('canvas')); await Promise.all(canvases.map((c, index) => { return new Promise((resolve) => { if (c.width === 0 || c.height === 0) return resolve(); const dataUrl = c.toDataURL('image/png'); const imgId = 'puppeteer-canvas-composite-' + index; let img = document.getElementById(imgId); const computedStyle = window.getComputedStyle(c); if (!img) { img = document.createElement('img'); img.id = imgId; img.style.position = 'fixed'; img.style.pointerEvents = 'none'; img.style.zIndex = computedStyle.zIndex || '0'; c.parentNode.insertBefore(img, c.nextSibling); } img.onload = resolve; img.onerror = resolve; img.src = dataUrl; const rect = c.getBoundingClientRect(); img.style.left = rect.left + 'px'; img.style.top = rect.top + 'px'; img.style.width = rect.width + 'px'; img.style.height = rect.height + 'px'; img.style.transform = computedStyle.transform !== 'none' ? computedStyle.transform : ''; img.style.opacity = computedStyle.opacity; c.style.visibility = 'hidden'; }); })); }, SERVER_HAS_GPU); await page.screenshot({ path: framePath, type: 'png', timeout: 0, clip: { x: clipX, y: clipY, width: clipWidth, height: clipHeight } }); if (i % 2 === 0) await new Promise(r => setTimeout(r, 100)); totalFramesRendered++; jobState.progress = { current: totalFramesRendered, total: totalFrames }; if (totalFramesRendered % 50 === 0) { console.log(`[Job ${jobId}] Progress: ${totalFramesRendered}/${totalFrames} frames...`); } } await browser.close(); } catch (err) { if (browser) await browser.close(); throw err; } } try { console.log(`[Job ${jobId}] Extracting frames across 4 multi-core workers...`); const NUM_WORKERS = 4; const workerPromises = []; const framesPerWorker = Math.ceil(totalFrames / NUM_WORKERS); for (let w = 0; w < NUM_WORKERS; w++) { const startFrame = w * framesPerWorker; const endFrame = Math.min((w + 1) * framesPerWorker - 1, totalFrames - 1); if (startFrame <= totalFrames - 1) { workerPromises.push(renderFrameChunk(w, startFrame, endFrame)); } } // Run all 4 browser workers simultaneously! await Promise.all(workerPromises); const outputPath = path.join(tempDir, 'output.mp4'); jobState.outputPath = outputPath; const isHighRes = (width * exportScale) > 1920; const crf = isHighRes ? '23' : '18'; await new Promise((resolve, reject) => { if (jobState.isCancelled) return reject(new Error('Job cancelled')); console.log(`[Job ${jobId}] Encoding MP4...`); jobState.ffmpegCommand = ffmpeg() .input(path.join(tempDir, 'frame_%05d.png')) .inputOptions([`-framerate ${fps}`]) .outputOptions([ '-c:v libx264', '-pix_fmt yuv420p', '-preset medium', `-crf ${crf}`, '-profile:v high', '-level 5.2', '-vf', 'scale=trunc(iw/2)*2:trunc(ih/2)*2' ]) .output(outputPath) .on('end', resolve) .on('error', (err) => reject(new Error('FFmpeg encoding failed: ' + err.message))); jobState.ffmpegCommand.run(); }); } catch (err) { throw err; } } // ---------------------------------------------------------------------------- // ENDPOINTS // ---------------------------------------------------------------------------- app.post('/api/render-async', requireAuth, (req, res) => { if (!req.body.html) return res.status(400).json({ error: 'Missing HTML' }); const jobId = req.body.jobId || randomUUID(); jobs.set(jobId, { createdAt: Date.now(), id: jobId, reqBody: req.body, status: 'queued', position: queue.length + 1, isCancelled: false, progress: { current: 0, total: 0 } }); queue.push(jobId); res.json({ jobId, status: 'queued', position: queue.length }); // Kick off the queue without blocking the response processQueue().catch(console.error); }); app.get('/api/status/:jobId', requireAuth, (req, res) => { const job = jobs.get(req.params.jobId); if (!job) return res.status(404).json({ error: 'Job not found' }); res.json({ status: job.status, position: job.position, progress: job.progress, error: job.error }); }); app.get('/api/download/:jobId', (req, res) => { // Removed requireAuth for easy browser download const job = jobs.get(req.params.jobId); if (!job || job.status !== 'done' || !job.outputPath) { return res.status(404).json({ error: 'File not ready or not found' }); } console.log(`[Job ${job.id}] Sending it...`); res.download(job.outputPath, 'render.mp4', () => { // Cleanup after download jobs.delete(job.id); if (job.tempDir) { try { fs.rmSync(job.tempDir, { recursive: true, force: true }); } catch (e) {} } }); }); app.post('/api/cancel/:jobId', requireAuth, (req, res) => { const job = jobs.get(req.params.jobId); if (job) { job.isCancelled = true; if (job.ffmpegCommand) try { job.ffmpegCommand.kill('SIGKILL'); } catch(e){} const qIndex = queue.indexOf(job.id); if (qIndex !== -1) queue.splice(qIndex, 1); // Remove from queue if not started // Cleanup immediately if (job.tempDir) { try { fs.rmSync(job.tempDir, { recursive: true, force: true }); } catch (e) {} } jobs.delete(job.id); } res.json({ success: true }); }); // Keep original synchronous endpoint for backwards compatibility (local testing) app.post('/api/render', requireAuth, async (req, res) => { // Mock async flow for the sync endpoint const jobId = req.body.jobId || randomUUID(); jobs.set(jobId, { id: jobId, reqBody: req.body, status: 'processing', isCancelled: false }); try { await executeRenderJob(jobId, req.body); const job = jobs.get(jobId); console.log(`[Job ${job.id}] Sending it...`); res.download(job.outputPath, 'render.mp4', () => { jobs.delete(jobId); if (job.tempDir) try { fs.rmSync(job.tempDir, { recursive: true, force: true }); } catch (e) {} }); } catch (err) { res.status(500).json({ error: err.message }); } }); const PORT = process.env.PORT || 7860; const server = app.listen(PORT, () => { console.log(`Studio Backend listening on port ${PORT}`); }); server.setTimeout(0); // Reaper: Clean up orphaned jobs from memory and disk after 45 minutes setInterval(() => { const now = Date.now(); for (const [id, job] of jobs.entries()) { if (now - job.createdAt > 45 * 60 * 1000) { console.log(`[Reaper] Reaping stale job ${id}`); if (job.ffmpegCommand) try { job.ffmpegCommand.kill('SIGKILL'); } catch(e){} if (job.tempDir) try { fs.rmSync(job.tempDir, { recursive: true, force: true }); } catch(e){} jobs.delete(id); } } }, 5 * 60 * 1000);