import express from 'express' import cors from 'cors' import path from 'node:path' import fs from 'node:fs/promises' import os from 'node:os' import { randomUUID } 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 PORT = Number(process.env.PORT || 6000) const CHROME_PATH = process.env.PUPPETEER_EXECUTABLE_PATH || '/usr/bin/chromium' const RENDER_HTML = path.join(__dirname, 'render.html') const app = express() app.use(cors()) app.use(express.json({ limit: '200mb' })) /** @type {Map} */ const jobs = new Map() app.get('/health', (_req, res) => res.json({ ok: true })) app.post('/render', (req, res) => { const { scene, options } = req.body || {} if (!scene || !scene.canvas || !Array.isArray(scene.layers)) { return res.status(400).json({ error: 'invalid scene' }) } const job = { id: randomUUID(), progress: 0, done: false, error: null, filePath: null, subscribers: new Set(), abort: new AbortController(), } jobs.set(job.id, job) res.json({ jobId: job.id }) runJob(job, scene, options || {}).catch((err) => { job.error = err?.message || String(err) job.done = true notify(job) }) }) app.get('/render/:id/events', (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` : 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', (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', (req, res) => { const job = jobs.get(req.params.id) if (!job) return res.status(404).end() job.abort.abort() res.json({ ok: true }) }) 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) } async function runJob(job, scene, options) { const fps = Number(options.fps || scene.fps || 60) const sceneEnd = effectiveEnd(scene) const duration = Number(options.duration || sceneEnd) const totalFrames = Math.max(1, Math.round(duration * fps)) const crf = String(options.crf ?? 18) const preset = String(options.preset ?? 'fast') const workDir = await fs.mkdtemp(path.join(os.tmpdir(), 'animatr-')) const outPath = path.join(workDir, 'out.mp4') const browser = await puppeteer.launch({ executablePath: CHROME_PATH, headless: 'new', args: [ '--no-sandbox', '--disable-setuid-sandbox', '--disable-dev-shm-usage', '--hide-scrollbars', ], 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 = 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) setTimeout(() => cleanupJob(job.id, workDir).catch(() => {}), 10 * 60 * 1000) } finally { await browser.close().catch(() => {}) } } async function cleanupJob(jobId, workDir) { jobs.delete(jobId) await fs.rm(workDir, { recursive: true, force: true }) } app.listen(PORT, () => { console.log(`animatr render server listening on :${PORT}`) console.log(`chromium: ${CHROME_PATH}`) })