Files
RUWIO/server/server.js
T
thandClaude Opus 4.8 518d43855b Add desktop/server MP4 export pipeline, hardening, and render tests (v1.3.0)
Desktop MP4 export renders frames from a hidden BrowserWindow via
capturePage, pipes PNGs to a bundled ffmpeg (libx264/yuv420p/+faststart),
and writes the file through an atomic temp-file swap. It shares scene
validation (shared/renderValidation.cjs) and the render.html renderer with
the standalone Puppeteer server, which is hardened in parallel.

Render worker & main (electron/):
- abortable render loop with an inactivity watchdog and cancel plumbing
- atomic temp-file + rename so a cancel/failure never truncates a
  pre-existing target (#7); stdin EPIPE / early-exit / backpressure-hang
  handled (#8)
- HiDPI frames normalized to canvas size (#10); odd dimensions padded to
  even (#13, also server); backgroundThrottling disabled (#12)
- reject canvases larger than the display (#11); single-render guard;
  isolated render session that blocks network requests (#5)

Tests:
- Electron harnesses (scripts/verify-*.cjs) for capture, export, cancel,
  timeout, assets, background, capture-fidelity, resolution, odd-dims,
  aggregated by verify-desktop-render.cjs
- server smoke + e2e and shared validation unit tests
- headless hook (src/devtools/headless.ts) to run the in-app suite under
  browser/electron hosts; pixel-diff util + capture fixtures (test/)
- dev_docs plans: integration, fixes (#7-#13), and the e2e/capture strategy

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 20:41:25 +02:00

364 lines
12 KiB
JavaScript

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'
import renderValidation from '../shared/renderValidation.cjs'
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 RENDER_LIMITS = {
maxCanvasWidth: MAX_CANVAS_WIDTH,
maxCanvasHeight: MAX_CANVAS_HEIGHT,
maxFps: MAX_FPS,
maxDuration: MAX_DURATION,
maxFrames: MAX_FRAMES,
maxLayers: MAX_LAYERS,
maxKeyframes: MAX_KEYFRAMES,
maxAssets: MAX_ASSETS,
}
const { playbackRange, validateRenderRequest: validateSharedRenderRequest } = renderValidation
const app = express()
app.disable('x-powered-by')
app.use(originGuard)
app.use(rejectLargeBodies)
app.use(express.json({ limit: BODY_LIMIT_BYTES }))
/** @type {Map<string, Job>} */
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 */ }
}
}
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',
// libx264 + yuv420p require even dimensions; pad an odd canvas up to the
// next even size so it encodes instead of crashing ffmpeg (finding #13).
'-vf', 'pad=ceil(iw/2)*2:ceil(ih/2)*2',
'-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) {
return validateSharedRenderRequest(scene, options, RENDER_LIMITS)
}
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.')
})