import express from 'express' import path from 'node:path' import fs from 'node:fs/promises' import os from 'node:os' import { randomBytes, randomUUID, timingSafeEqual } from 'node:crypto' import { spawn } from 'node:child_process' import { fileURLToPath } from 'node:url' import puppeteer from 'puppeteer-core' const __filename = fileURLToPath(import.meta.url) const __dirname = path.dirname(__filename) const HOST = process.env.HOST || '127.0.0.1' const PORT = readIntEnv('PORT', 6000, 1, 65535) const CHROME_PATH = process.env.PUPPETEER_EXECUTABLE_PATH || '/usr/bin/chromium' const RENDER_HTML = path.join(__dirname, 'render.html') const RENDER_TOKEN = process.env.RENDER_TOKEN || '' const ALLOWED_ORIGINS = new Set( (process.env.RENDER_ALLOWED_ORIGINS || 'http://localhost:5200,http://127.0.0.1:5200,http://localhost:5173,http://127.0.0.1:5173,http://localhost:6000,http://127.0.0.1:6000') .split(',') .map((origin) => origin.trim()) .filter(Boolean), ) const BODY_LIMIT_BYTES = readSizeEnv('RENDER_MAX_BODY', 15 * 1024 * 1024) const MAX_ACTIVE_JOBS = readIntEnv('MAX_ACTIVE_JOBS', 1, 1, 16) const JOB_TIMEOUT_MS = readIntEnv('RENDER_JOB_TIMEOUT_MS', 2 * 60 * 1000, 1000, 60 * 60 * 1000) const JOB_TTL_MS = readIntEnv('RENDER_JOB_TTL_MS', 10 * 60 * 1000, 1000, 24 * 60 * 60 * 1000) const RATE_LIMIT_WINDOW_MS = readIntEnv('RENDER_RATE_WINDOW_MS', 60 * 1000, 1000, 60 * 60 * 1000) const RATE_LIMIT_MAX = readIntEnv('RENDER_RATE_MAX', 8, 1, 1000) const MAX_CANVAS_WIDTH = readIntEnv('RENDER_MAX_CANVAS_WIDTH', 4096, 1, 16384) const MAX_CANVAS_HEIGHT = readIntEnv('RENDER_MAX_CANVAS_HEIGHT', 4096, 1, 16384) const MAX_FPS = readIntEnv('RENDER_MAX_FPS', 60, 1, 240) const MAX_DURATION = readIntEnv('RENDER_MAX_DURATION', 60, 1, 60 * 60) const MAX_FRAMES = readIntEnv('RENDER_MAX_FRAMES', 3600, 1, 250000) const MAX_LAYERS = readIntEnv('RENDER_MAX_LAYERS', 500, 1, 100000) const MAX_KEYFRAMES = readIntEnv('RENDER_MAX_KEYFRAMES', 5000, 1, 1000000) const MAX_ASSETS = readIntEnv('RENDER_MAX_ASSETS', 500, 1, 100000) const app = express() app.disable('x-powered-by') app.use(originGuard) app.use(rejectLargeBodies) app.use(express.json({ limit: BODY_LIMIT_BYTES })) /** @type {Map} */ const jobs = new Map() const rateBuckets = new Map() app.get('/health', (_req, res) => res.json({ ok: true })) app.post('/render', requireRenderToken, rateLimitRender, (req, res) => { if (activeJobCount() >= MAX_ACTIVE_JOBS) { return res.status(503).json({ error: 'render server is busy' }) } const { scene, options } = req.body || {} const validation = validateRenderRequest(scene, options || {}) if (!validation.ok) { return res.status(400).json({ error: validation.error }) } const job = { id: randomUUID(), accessToken: randomToken(), progress: 0, done: false, error: null, filePath: null, workDir: null, subscribers: new Set(), abort: new AbortController(), cleanupTimer: null, } jobs.set(job.id, job) res.json({ jobId: job.id, accessToken: job.accessToken }) runJob(job, validation.scene, validation.options).catch((err) => { job.error = err?.message || String(err) job.done = true notify(job) scheduleCleanup(job, 0) }) }) app.get('/render/:id/events', requireJobToken, (req, res) => { const job = jobs.get(req.params.id) if (!job) return res.status(404).end() res.writeHead(200, { 'Content-Type': 'text/event-stream', 'Cache-Control': 'no-cache', Connection: 'keep-alive', }) const send = () => { const payload = { progress: job.progress, done: job.done, error: job.error, url: job.done && !job.error ? `/render/${job.id}/file?token=${encodeURIComponent(job.accessToken)}` : null, } res.write(`data: ${JSON.stringify(payload)}\n\n`) } job.subscribers.add(send) send() const keepalive = setInterval(() => res.write(': keepalive\n\n'), 15000) req.on('close', () => { job.subscribers.delete(send) clearInterval(keepalive) }) }) app.get('/render/:id/file', requireJobToken, (req, res) => { const job = jobs.get(req.params.id) if (!job || !job.filePath || job.error) return res.status(404).end() res.download(job.filePath, 'animation.mp4') }) app.delete('/render/:id', requireJobToken, (req, res) => { const job = jobs.get(req.params.id) if (!job) return res.status(404).end() job.abort.abort() if (job.done) scheduleCleanup(job, 0) res.json({ ok: true }) }) app.use((err, _req, res, _next) => { if (err?.type === 'entity.too.large') { return res.status(413).json({ error: `request body exceeds ${BODY_LIMIT_BYTES} bytes` }) } if (err instanceof SyntaxError && 'body' in err) { return res.status(400).json({ error: 'invalid JSON body' }) } console.error(err) return res.status(500).json({ error: 'internal server error' }) }) function originGuard(req, res, next) { const origin = req.get('Origin') if (origin) { if (!ALLOWED_ORIGINS.has(origin)) { return res.status(403).json({ error: 'origin is not allowed' }) } res.setHeader('Access-Control-Allow-Origin', origin) res.setHeader('Vary', 'Origin') res.setHeader('Access-Control-Allow-Headers', 'Content-Type, Authorization, X-RUWIO-Render-Token') res.setHeader('Access-Control-Allow-Methods', 'GET,POST,DELETE,OPTIONS') } if (req.method === 'OPTIONS') return res.status(204).end() next() } function rejectLargeBodies(req, res, next) { const len = Number(req.get('Content-Length') || 0) if (Number.isFinite(len) && len > BODY_LIMIT_BYTES) { return res.status(413).json({ error: `request body exceeds ${BODY_LIMIT_BYTES} bytes` }) } next() } function requireRenderToken(req, res, next) { if (!RENDER_TOKEN) { return res.status(503).json({ error: 'render server requires RENDER_TOKEN to be configured' }) } const token = bearerToken(req) || req.get('X-RUWIO-Render-Token') || '' if (!secureEqual(token, RENDER_TOKEN)) return res.status(401).json({ error: 'invalid render token' }) next() } function requireJobToken(req, res, next) { const job = jobs.get(req.params.id) if (!job) return res.status(404).end() const token = bearerToken(req) || req.get('X-RUWIO-Render-Token') || String(req.query.token || '') if (!secureEqual(token, job.accessToken)) return res.status(401).json({ error: 'invalid job token' }) next() } function rateLimitRender(req, res, next) { const ip = req.ip || req.socket.remoteAddress || 'unknown' const now = Date.now() const bucket = rateBuckets.get(ip) if (!bucket || now - bucket.startedAt > RATE_LIMIT_WINDOW_MS) { rateBuckets.set(ip, { startedAt: now, count: 1 }) return next() } bucket.count += 1 if (bucket.count > RATE_LIMIT_MAX) return res.status(429).json({ error: 'too many render requests' }) next() } function bearerToken(req) { const auth = req.get('Authorization') || '' return auth.toLowerCase().startsWith('bearer ') ? auth.slice(7).trim() : '' } function secureEqual(a, b) { if (!a || !b) return false const left = Buffer.from(String(a)) const right = Buffer.from(String(b)) return left.length === right.length && timingSafeEqual(left, right) } function randomToken() { return randomBytes(32).toString('base64url') } function activeJobCount() { let count = 0 for (const job of jobs.values()) { if (!job.done) count += 1 } return count } function notify(job) { for (const sub of Array.from(job.subscribers)) { try { sub() } catch { /* noop */ } } } function effectiveEnd(scene) { let max = 0 for (const layer of scene.layers) { for (const kf of layer.keyframes) { if (kf.time > max) max = kf.time } } if (max <= 0) return scene.duration return Math.min(max, scene.duration) } function playbackRange(scene) { const autoEnd = effectiveEnd(scene) const start = Math.max(0, Math.min(scene.duration, scene.inTime == null ? 0 : scene.inTime)) const rawEnd = scene.outTime == null ? autoEnd : scene.outTime const end = Math.max(start, Math.min(scene.duration, rawEnd)) return { start, end } } async function runJob(job, scene, options) { const { start: rangeIn } = playbackRange(scene) const { fps, duration, totalFrames, crf, preset } = options const workDir = await fs.mkdtemp(path.join(os.tmpdir(), 'animatr-')) job.workDir = workDir const outPath = path.join(workDir, 'out.mp4') const timeout = setTimeout(() => job.abort.abort(), JOB_TIMEOUT_MS) const browser = await puppeteer.launch({ executablePath: CHROME_PATH, headless: 'new', args: chromeArgs(), defaultViewport: { width: scene.canvas.width, height: scene.canvas.height, deviceScaleFactor: 1, }, }) const ffmpeg = spawn('ffmpeg', [ '-y', '-f', 'image2pipe', '-framerate', String(fps), '-i', '-', '-c:v', 'libx264', '-pix_fmt', 'yuv420p', '-preset', preset, '-crf', crf, '-movflags', '+faststart', outPath, ]) let ffmpegErr = '' ffmpeg.stderr.on('data', (d) => { ffmpegErr += d.toString() }) const ffmpegDone = new Promise((resolve, reject) => { ffmpeg.on('exit', (code) => { if (code === 0) resolve() else reject(new Error(`ffmpeg exited with code ${code}: ${ffmpegErr.slice(-500)}`)) }) ffmpeg.on('error', reject) }) try { const page = await browser.newPage() await page.goto('file://' + RENDER_HTML, { waitUntil: 'domcontentloaded' }) await page.evaluate((s) => window.__animatrInit(s), scene) for (let i = 0; i < totalFrames; i++) { if (job.abort.signal.aborted) { try { ffmpeg.kill('SIGKILL') } catch { /* noop */ } throw new Error('aborted') } const t = rangeIn + Math.min(i / fps, duration) await page.evaluate((time) => window.__animatrSeek(time), t) const buf = await page.screenshot({ type: 'png', omitBackground: false, clip: { x: 0, y: 0, width: scene.canvas.width, height: scene.canvas.height }, }) if (!ffmpeg.stdin.write(buf)) { await new Promise((resolve) => ffmpeg.stdin.once('drain', resolve)) } job.progress = (i + 1) / totalFrames notify(job) } ffmpeg.stdin.end() await ffmpegDone job.filePath = outPath job.done = true notify(job) scheduleCleanup(job, JOB_TTL_MS) } finally { clearTimeout(timeout) if (!job.done) { try { ffmpeg.kill('SIGKILL') } catch { /* noop */ } } await browser.close().catch(() => {}) } } function scheduleCleanup(job, delayMs) { if (job.cleanupTimer) clearTimeout(job.cleanupTimer) job.cleanupTimer = setTimeout(() => cleanupJob(job.id).catch(() => {}), delayMs) } async function cleanupJob(jobId) { const job = jobs.get(jobId) if (!job) return jobs.delete(jobId) if (job.workDir) await fs.rm(job.workDir, { recursive: true, force: true }) } function validateRenderRequest(scene, options) { if (!scene || typeof scene !== 'object') return { ok: false, error: 'invalid scene' } if (!scene.canvas || typeof scene.canvas !== 'object') return { ok: false, error: 'invalid canvas' } const width = toBoundedInt(scene.canvas.width, 1, MAX_CANVAS_WIDTH) const height = toBoundedInt(scene.canvas.height, 1, MAX_CANVAS_HEIGHT) if (!width || !height) return { ok: false, error: `canvas must be between 1 and ${MAX_CANVAS_WIDTH}x${MAX_CANVAS_HEIGHT}` } if (!Array.isArray(scene.layers)) return { ok: false, error: 'scene layers must be an array' } if (scene.layers.length > MAX_LAYERS) return { ok: false, error: `scene has more than ${MAX_LAYERS} layers` } if (Array.isArray(scene.assets) && scene.assets.length > MAX_ASSETS) return { ok: false, error: `scene has more than ${MAX_ASSETS} assets` } let keyframes = 0 for (const layer of scene.layers) { if (!Array.isArray(layer?.keyframes)) return { ok: false, error: 'every layer must have keyframes' } keyframes += layer.keyframes.length if (keyframes > MAX_KEYFRAMES) return { ok: false, error: `scene has more than ${MAX_KEYFRAMES} keyframes` } for (const kf of layer.keyframes) { if (!Number.isFinite(Number(kf?.time))) return { ok: false, error: 'keyframe times must be finite numbers' } } } const duration = Number(scene.duration) if (!Number.isFinite(duration) || duration <= 0 || duration > MAX_DURATION) { return { ok: false, error: `duration must be between 0 and ${MAX_DURATION} seconds` } } const fps = toBoundedInt(options.fps || scene.fps || 60, 1, MAX_FPS) if (!fps) return { ok: false, error: `fps must be between 1 and ${MAX_FPS}` } const range = playbackRange({ ...scene, canvas: { ...scene.canvas, width, height }, duration }) const requestedDuration = Number(options.duration || Math.max(0, range.end - range.start)) if (!Number.isFinite(requestedDuration) || requestedDuration <= 0 || requestedDuration > MAX_DURATION) { return { ok: false, error: `render duration must be between 0 and ${MAX_DURATION} seconds` } } const totalFrames = Math.max(1, Math.round(requestedDuration * fps)) if (totalFrames > MAX_FRAMES) return { ok: false, error: `render has more than ${MAX_FRAMES} frames` } const crf = toBoundedInt(options.crf ?? 18, 0, 51) if (crf == null) return { ok: false, error: 'crf must be between 0 and 51' } const preset = String(options.preset ?? 'fast') const allowedPresets = new Set(['ultrafast', 'superfast', 'veryfast', 'faster', 'fast', 'medium', 'slow', 'slower', 'veryslow']) if (!allowedPresets.has(preset)) return { ok: false, error: 'invalid ffmpeg preset' } return { ok: true, scene: { ...scene, canvas: { ...scene.canvas, width, height }, duration }, options: { fps, duration: requestedDuration, totalFrames, crf: String(crf), preset }, } } function toBoundedInt(value, min, max) { const n = Number(value) if (!Number.isFinite(n)) return null const i = Math.round(n) if (i < min || i > max) return null return i } function chromeArgs() { const args = ['--disable-dev-shm-usage', '--hide-scrollbars'] if (process.env.CHROME_NO_SANDBOX === '1') { console.warn('WARNING: CHROME_NO_SANDBOX=1 disables Chromium sandboxing for render jobs.') args.push('--no-sandbox', '--disable-setuid-sandbox') } return args } function readIntEnv(name, fallback, min, max) { const raw = process.env[name] if (!raw) return fallback const n = Number.parseInt(raw, 10) if (!Number.isFinite(n) || n < min || n > max) { throw new Error(`${name} must be an integer between ${min} and ${max}`) } return n } function readSizeEnv(name, fallback) { const raw = process.env[name] if (!raw) return fallback const match = raw.trim().match(/^(\d+)(b|kb|mb)?$/i) if (!match) throw new Error(`${name} must be a byte size like 1048576 or 15mb`) const amount = Number.parseInt(match[1], 10) const unit = (match[2] || 'b').toLowerCase() const mult = unit === 'mb' ? 1024 * 1024 : unit === 'kb' ? 1024 : 1 return amount * mult } app.listen(PORT, HOST, () => { console.log(`animatr render server listening on ${HOST}:${PORT}`) console.log(`chromium: ${CHROME_PATH}`) if (!RENDER_TOKEN) console.warn('WARNING: RENDER_TOKEN is not set; POST /render is disabled.') })