199 lines
5.5 KiB
JavaScript
199 lines
5.5 KiB
JavaScript
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<string, Job>} */
|
|
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}`)
|
|
})
|