init
This commit is contained in:
commit
c6863f2bfc
34 files changed
+6909
No files matched your search
@@ -0,0 +1,4 @@
|
||||
node_modules
|
||||
npm-debug.log
|
||||
.env
|
||||
*.mp4
|
||||
@@ -0,0 +1,20 @@
|
||||
FROM node:20-slim
|
||||
|
||||
RUN apt-get update && apt-get install -y \
|
||||
chromium \
|
||||
ffmpeg \
|
||||
fonts-liberation \
|
||||
fonts-noto-color-emoji \
|
||||
--no-install-recommends \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
ENV PUPPETEER_EXECUTABLE_PATH=/usr/bin/chromium
|
||||
ENV PORT=6000
|
||||
|
||||
WORKDIR /app
|
||||
COPY package.json ./
|
||||
RUN npm install --omit=dev
|
||||
COPY . .
|
||||
|
||||
EXPOSE 6000
|
||||
CMD ["node", "server.js"]
|
||||
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"name": "animatr-server",
|
||||
"version": "0.0.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"main": "server.js",
|
||||
"scripts": {
|
||||
"start": "node server.js",
|
||||
"dev": "node server.js"
|
||||
},
|
||||
"dependencies": {
|
||||
"cors": "^2.8.5",
|
||||
"express": "^4.21.2",
|
||||
"puppeteer-core": "^23.11.1"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,144 @@
|
||||
<!doctype html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<style>
|
||||
html,body { margin:0; padding:0; overflow:hidden; background:transparent; }
|
||||
canvas { display:block; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<canvas id="c"></canvas>
|
||||
<script>
|
||||
(function(){
|
||||
function easing(name,t){
|
||||
if(name==='easeIn') return t*t;
|
||||
if(name==='easeOut') return 1-(1-t)*(1-t);
|
||||
if(name==='easeInOut') return t<0.5 ? 2*t*t : 1-Math.pow(-2*t+2,2)/2;
|
||||
return t;
|
||||
}
|
||||
function merge(b,p){ var o={}; for(var k in b) o[k]=b[k]; if(p) for(var k2 in p) o[k2]=p[k2]; return o; }
|
||||
function lerp(a,b,t){ return a+(b-a)*t; }
|
||||
function parseHex(h){
|
||||
if(!h) return null;
|
||||
var s=String(h).trim().replace('#','');
|
||||
if(s.length===3) s=s[0]+s[0]+s[1]+s[1]+s[2]+s[2];
|
||||
if(s.length<6) return null;
|
||||
var n=parseInt(s.slice(0,6),16);
|
||||
if(isNaN(n)) return null;
|
||||
return [(n>>16)&255,(n>>8)&255,n&255];
|
||||
}
|
||||
function formatHex(r,g,b){
|
||||
function c(v){ v=Math.round(v); if(v<0)v=0; if(v>255)v=255; var s=v.toString(16); return s.length<2?'0'+s:s; }
|
||||
return '#'+c(r)+c(g)+c(b);
|
||||
}
|
||||
function lerpColor(a,b,t){
|
||||
var pa=parseHex(a), pb=parseHex(b);
|
||||
if(!pa||!pb) return t<0.5?a:b;
|
||||
return formatHex(lerp(pa[0],pb[0],t), lerp(pa[1],pb[1],t), lerp(pa[2],pb[2],t));
|
||||
}
|
||||
function lerpProps(a,b,t){
|
||||
var o={
|
||||
x:lerp(a.x,b.x,t),
|
||||
y:lerp(a.y,b.y,t),
|
||||
scaleX:lerp(a.scaleX,b.scaleX,t),
|
||||
scaleY:lerp(a.scaleY,b.scaleY,t),
|
||||
rotation:lerp(a.rotation,b.rotation,t),
|
||||
opacity:lerp(a.opacity,b.opacity,t)
|
||||
};
|
||||
if(a.fill && b.fill) o.fill=lerpColor(a.fill,b.fill,t);
|
||||
else if(b.fill) o.fill=b.fill;
|
||||
else if(a.fill) o.fill=a.fill;
|
||||
return o;
|
||||
}
|
||||
function sample(layer,time){
|
||||
var kfs=layer.keyframes.slice().sort(function(a,b){return a.time-b.time;});
|
||||
if(!kfs.length) return merge(layer.base);
|
||||
if(time<=kfs[0].time) return merge(layer.base,kfs[0].props);
|
||||
var last=kfs[kfs.length-1];
|
||||
if(time>=last.time) return merge(layer.base,last.props);
|
||||
for(var i=0;i<kfs.length-1;i++){
|
||||
var a=kfs[i], b=kfs[i+1];
|
||||
if(time>=a.time && time<=b.time){
|
||||
var raw=(time-a.time)/(b.time-a.time);
|
||||
var t=easing(b.easing,raw);
|
||||
return lerpProps(merge(layer.base,a.props), merge(layer.base,b.props), t);
|
||||
}
|
||||
}
|
||||
return merge(layer.base);
|
||||
}
|
||||
function loadImages(assets){
|
||||
var imgs={};
|
||||
var list=assets.filter(function(a){return a.kind==='image';});
|
||||
return Promise.all(list.map(function(a){
|
||||
return new Promise(function(res){
|
||||
var img=new Image();
|
||||
img.onload=function(){ imgs[a.id]=img; res(); };
|
||||
img.onerror=function(){ res(); };
|
||||
img.src=a.src;
|
||||
});
|
||||
})).then(function(){ return imgs; });
|
||||
}
|
||||
var canvas=document.getElementById('c');
|
||||
var ctx=canvas.getContext('2d');
|
||||
var scene=null, images={};
|
||||
function drawFrame(time){
|
||||
if(!scene) return;
|
||||
var lw=scene.canvas.width, lh=scene.canvas.height;
|
||||
ctx.save();
|
||||
ctx.beginPath(); ctx.rect(0,0,lw,lh); ctx.clip();
|
||||
ctx.fillStyle=scene.canvas.background;
|
||||
ctx.fillRect(0,0,lw,lh);
|
||||
var am={}; for(var i=0;i<scene.assets.length;i++) am[scene.assets[i].id]=scene.assets[i];
|
||||
for(var j=0;j<scene.layers.length;j++){
|
||||
var layer=scene.layers[j]; if(!layer.visible) continue;
|
||||
var asset=am[layer.assetId]; if(!asset) continue;
|
||||
var p=sample(layer,time);
|
||||
ctx.save();
|
||||
ctx.globalAlpha = ctx.globalAlpha * p.opacity;
|
||||
ctx.translate(p.x,p.y);
|
||||
ctx.rotate(p.rotation*Math.PI/180);
|
||||
ctx.scale(p.scaleX,p.scaleY);
|
||||
var fill = p.fill || asset.fill;
|
||||
if(asset.kind==='rect'){
|
||||
var rOffX=layer.offsetX!=null?layer.offsetX:asset.width/2;
|
||||
var rOffY=layer.offsetY!=null?layer.offsetY:asset.height/2;
|
||||
ctx.fillStyle=fill;
|
||||
ctx.fillRect(-rOffX,-rOffY,asset.width,asset.height);
|
||||
} else if(asset.kind==='circle'){
|
||||
ctx.fillStyle=fill;
|
||||
ctx.beginPath(); ctx.arc(0,0,asset.radius,0,Math.PI*2); ctx.fill();
|
||||
} else if(asset.kind==='text'){
|
||||
ctx.fillStyle=fill;
|
||||
ctx.font=asset.fontSize+'px sans-serif';
|
||||
ctx.textBaseline='top';
|
||||
ctx.fillText(asset.text,0,0);
|
||||
} else if(asset.kind==='image'){
|
||||
var img=images[asset.id];
|
||||
if(img){
|
||||
var iOffX=layer.offsetX!=null?layer.offsetX:asset.width/2;
|
||||
var iOffY=layer.offsetY!=null?layer.offsetY:asset.height/2;
|
||||
ctx.drawImage(img,-iOffX,-iOffY,asset.width,asset.height);
|
||||
}
|
||||
}
|
||||
ctx.restore();
|
||||
}
|
||||
ctx.restore();
|
||||
}
|
||||
window.__animatrInit=function(s){
|
||||
scene=s;
|
||||
canvas.width=s.canvas.width;
|
||||
canvas.height=s.canvas.height;
|
||||
document.body.style.width=s.canvas.width+'px';
|
||||
document.body.style.height=s.canvas.height+'px';
|
||||
return loadImages(s.assets).then(function(imgs){
|
||||
images=imgs;
|
||||
drawFrame(0);
|
||||
return true;
|
||||
});
|
||||
};
|
||||
window.__animatrSeek=function(time){ drawFrame(time); };
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,198 @@
|
||||
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}`)
|
||||
})
|
||||
Reference in new issue
Block a user