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>
This commit is contained in:
thandClaude Opus 4.8 committed 2026-06-06 20:41:25 +02:00
1 parent 46f9f32aef
commit 518d43855b
41 files changed
+3907 -86

No files matched your search

+3 -1
View File
@@ -6,7 +6,9 @@
"main": "server.js",
"scripts": {
"start": "node server.js",
"dev": "node server.js"
"dev": "node server.js",
"test:smoke": "node --test server.smoke.test.cjs",
"test:e2e": "node --test render.e2e.test.cjs"
},
"dependencies": {
"express": "^4.21.2",
+310
View File
@@ -0,0 +1,310 @@
const assert = require('node:assert/strict')
const { spawn } = require('node:child_process')
const { mkdtemp, readFile, rm } = require('node:fs/promises')
const net = require('node:net')
const os = require('node:os')
const path = require('node:path')
const { test } = require('node:test')
const HOST = '127.0.0.1'
const TOKEN = 'server-e2e-token'
const CHROME = process.env.PUPPETEER_EXECUTABLE_PATH || '/usr/bin/chromium'
test('render server produces a playable MP4 for a tiny scene', async (t) => {
const ffmpeg = await commandPath('ffmpeg')
const ffprobe = await commandPath('ffprobe')
if (!ffmpeg || !ffprobe || !(await canAccess(CHROME))) {
t.skip('requires ffmpeg, ffprobe, and Chromium')
return
}
const port = await freePort()
const tmp = await mkdtemp(path.join(os.tmpdir(), 'ruwio-render-e2e-'))
const server = spawn(process.execPath, ['server.js'], {
cwd: __dirname,
env: {
...process.env,
HOST,
PORT: String(port),
RENDER_TOKEN: TOKEN,
RENDER_JOB_TTL_MS: '1000',
RENDER_JOB_TIMEOUT_MS: '30000',
PUPPETEER_EXECUTABLE_PATH: CHROME,
CHROME_NO_SANDBOX: '1',
},
stdio: ['ignore', 'pipe', 'pipe'],
})
let output = ''
server.stdout.on('data', (chunk) => {
output += chunk.toString()
})
server.stderr.on('data', (chunk) => {
output += chunk.toString()
})
t.after(async () => {
if (server.exitCode === null) {
server.kill('SIGTERM')
await waitForExit(server)
}
await rm(tmp, { recursive: true, force: true })
})
await waitForServer(server, port, () => output)
const base = `http://${HOST}:${port}`
const start = await fetch(`${base}/render`, {
method: 'POST',
headers: {
Authorization: `Bearer ${TOKEN}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({ scene: tinyScene(), options: { fps: 2, crf: 28, preset: 'ultrafast' } }),
})
const startText = await start.text()
assert.equal(start.status, 200, startText)
const { jobId, accessToken } = JSON.parse(startText)
assert.equal(typeof jobId, 'string')
assert.equal(typeof accessToken, 'string')
const done = await waitForDone(`${base}/render/${jobId}/events?token=${encodeURIComponent(accessToken)}`)
assert.equal(done.done, true)
assert.equal(done.error, null)
assert.equal(done.progress, 1)
assert.equal(done.url, `/render/${jobId}/file?token=${encodeURIComponent(accessToken)}`)
const file = await fetch(`${base}${done.url}`)
assert.equal(file.status, 200)
const outPath = path.join(tmp, 'tiny.mp4')
await require('node:fs/promises').writeFile(outPath, Buffer.from(await file.arrayBuffer()))
const metadata = await ffprobeJson(outPath)
const video = metadata.streams.find((stream) => stream.codec_type === 'video')
assert.ok(video, 'expected a video stream')
assert.equal(video.codec_name, 'h264')
assert.equal(video.width, 64)
assert.equal(video.height, 64)
})
test('render server pads odd canvas dimensions to an even MP4', async (t) => {
const ffmpeg = await commandPath('ffmpeg')
const ffprobe = await commandPath('ffprobe')
if (!ffmpeg || !ffprobe || !(await canAccess(CHROME))) {
t.skip('requires ffmpeg, ffprobe, and Chromium')
return
}
const port = await freePort()
const tmp = await mkdtemp(path.join(os.tmpdir(), 'ruwio-render-e2e-'))
const server = spawn(process.execPath, ['server.js'], {
cwd: __dirname,
env: {
...process.env,
HOST,
PORT: String(port),
RENDER_TOKEN: TOKEN,
RENDER_JOB_TTL_MS: '1000',
RENDER_JOB_TIMEOUT_MS: '30000',
PUPPETEER_EXECUTABLE_PATH: CHROME,
CHROME_NO_SANDBOX: '1',
},
stdio: ['ignore', 'pipe', 'pipe'],
})
let output = ''
server.stdout.on('data', (chunk) => {
output += chunk.toString()
})
server.stderr.on('data', (chunk) => {
output += chunk.toString()
})
t.after(async () => {
if (server.exitCode === null) {
server.kill('SIGTERM')
await waitForExit(server)
}
await rm(tmp, { recursive: true, force: true })
})
await waitForServer(server, port, () => output)
const base = `http://${HOST}:${port}`
const start = await fetch(`${base}/render`, {
method: 'POST',
headers: {
Authorization: `Bearer ${TOKEN}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({ scene: oddScene(), options: { fps: 2, crf: 28, preset: 'ultrafast' } }),
})
const startText = await start.text()
assert.equal(start.status, 200, startText)
const { jobId, accessToken } = JSON.parse(startText)
const done = await waitForDone(`${base}/render/${jobId}/events?token=${encodeURIComponent(accessToken)}`)
assert.equal(done.done, true)
assert.equal(done.error, null)
assert.equal(done.progress, 1)
const file = await fetch(`${base}${done.url}`)
assert.equal(file.status, 200)
const outPath = path.join(tmp, 'odd.mp4')
await require('node:fs/promises').writeFile(outPath, Buffer.from(await file.arrayBuffer()))
const metadata = await ffprobeJson(outPath)
const video = metadata.streams.find((stream) => stream.codec_type === 'video')
assert.ok(video, 'expected a video stream')
assert.equal(video.codec_name, 'h264')
assert.equal(video.width, 102)
assert.equal(video.height, 102)
})
function tinyScene() {
return {
canvas: { width: 64, height: 64, background: '#ffffff' },
duration: 1,
fps: 2,
assets: [
{ id: 'asset-rect', kind: 'rect', name: 'Box', width: 24, height: 24, fill: '#22cc88' },
],
layers: [
{
id: 'layer-rect',
name: 'Box',
assetId: 'asset-rect',
visible: true,
base: { x: 16, y: 32, scaleX: 1, scaleY: 1, rotation: 0, opacity: 1 },
keyframes: [
{ id: 'kf-0', time: 0, easing: 'linear', props: { x: 16 } },
{ id: 'kf-1', time: 0.5, easing: 'linear', props: { x: 48 } },
],
},
],
}
}
function oddScene() {
const s = tinyScene()
s.canvas.width = 101
s.canvas.height = 101
s.layers[0].base.x = 50
s.layers[0].base.y = 50
s.layers[0].keyframes = [
{ id: 'kf-0', time: 0, easing: 'linear', props: { x: 42 } },
{ id: 'kf-1', time: 0.5, easing: 'linear', props: { x: 58 } },
]
return s
}
async function waitForDone(url) {
const res = await fetch(url)
assert.equal(res.status, 200)
const reader = res.body.getReader()
const decoder = new TextDecoder()
let buffer = ''
const deadline = Date.now() + 30000
while (Date.now() < deadline) {
const { value, done } = await reader.read()
if (done) break
buffer += decoder.decode(value, { stream: true })
const messages = buffer.split('\n\n')
buffer = messages.pop() || ''
for (const message of messages) {
const line = message.split('\n').find((entry) => entry.startsWith('data: '))
if (!line) continue
const payload = JSON.parse(line.slice(6))
if (payload.done) return payload
}
}
throw new Error('render did not finish before timeout')
}
function ffprobeJson(file) {
return new Promise((resolve, reject) => {
const child = spawn('ffprobe', [
'-v', 'error',
'-show_streams',
'-of', 'json',
file,
])
let stdout = ''
let stderr = ''
child.stdout.on('data', (chunk) => {
stdout += chunk.toString()
})
child.stderr.on('data', (chunk) => {
stderr += chunk.toString()
})
child.on('error', reject)
child.on('exit', (code) => {
if (code === 0) resolve(JSON.parse(stdout))
else reject(new Error(`ffprobe exited with code ${code}: ${stderr}`))
})
})
}
function commandPath(command) {
return new Promise((resolve) => {
const probe = spawn(process.platform === 'win32' ? 'where' : 'which', [command])
probe.on('error', () => resolve(''))
probe.on('exit', (code) => resolve(code === 0 ? command : ''))
})
}
async function canAccess(file) {
try {
await readFile(file)
return true
} catch {
return false
}
}
function freePort() {
return new Promise((resolve, reject) => {
const server = net.createServer()
server.once('error', reject)
server.listen(0, HOST, () => {
const address = server.address()
server.close(() => {
if (!address || typeof address === 'string') reject(new Error('could not allocate test port'))
else resolve(address.port)
})
})
})
}
async function waitForServer(server, port, output) {
const deadline = Date.now() + 5000
while (Date.now() < deadline) {
if (server.exitCode !== null) {
throw new Error(`server exited early with code ${server.exitCode}\n${output()}`)
}
try {
const res = await fetch(`http://${HOST}:${port}/health`)
if (res.ok) return
} catch {
/* retry */
}
await delay(50)
}
throw new Error(`server did not start\n${output()}`)
}
function waitForExit(child) {
return new Promise((resolve) => {
child.once('exit', () => resolve())
setTimeout(() => {
if (child.exitCode === null) child.kill('SIGKILL')
resolve()
}, 1000).unref()
})
}
function delay(ms) {
return new Promise((resolve) => setTimeout(resolve, ms))
}
+14 -1
View File
@@ -386,7 +386,20 @@
return true;
});
};
window.__animatrSeek=function(time){ drawFrame(time); };
window.__animatrSeek=function(time){
drawFrame(time);
// Resolve only after the compositor has had a chance to paint, so a
// subsequent capturePage()/screenshot() sees THIS frame and not the
// previous one. Without this wait, Electron's capturePage on a hidden
// window intermittently returns the prior frame (stale/off-by-one).
// setTimeout is an anti-hang fallback in case rAF is throttled while hidden.
return new Promise(function(res){
var done=false;
function finish(){ if(!done){ done=true; res(true); } }
requestAnimationFrame(function(){ requestAnimationFrame(finish); });
setTimeout(finish, 1000);
});
};
})();
</script>
</body>
+16 -67
View File
@@ -6,6 +6,7 @@ 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)
@@ -36,6 +37,17 @@ 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')
@@ -214,25 +226,6 @@ function notify(job) {
}
}
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
@@ -260,6 +253,9 @@ async function runJob(job, scene, options) {
'-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',
@@ -327,54 +323,7 @@ async function cleanupJob(jobId) {
}
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
return validateSharedRenderRequest(scene, options, RENDER_LIMITS)
}
function chromeArgs() {
+139
View File
@@ -0,0 +1,139 @@
const assert = require('node:assert/strict')
const { spawn } = require('node:child_process')
const net = require('node:net')
const { test } = require('node:test')
const HOST = '127.0.0.1'
const TOKEN = 'server-smoke-token'
test('render server health, auth, and validation routes work', async (t) => {
const port = await freePort()
const server = spawn(process.execPath, ['server.js'], {
cwd: __dirname,
env: {
...process.env,
HOST,
PORT: String(port),
RENDER_TOKEN: TOKEN,
RENDER_MAX_BODY: '1mb',
RENDER_RATE_MAX: '100',
PUPPETEER_EXECUTABLE_PATH: '/not-used-by-smoke-test',
},
stdio: ['ignore', 'pipe', 'pipe'],
})
let output = ''
server.stdout.on('data', (chunk) => {
output += chunk.toString()
})
server.stderr.on('data', (chunk) => {
output += chunk.toString()
})
t.after(async () => {
if (server.exitCode === null) {
server.kill('SIGTERM')
await waitForExit(server)
}
})
await waitForServer(server, port, () => output)
const base = `http://${HOST}:${port}`
const health = await fetch(`${base}/health`)
assert.equal(health.status, 200)
assert.deepEqual(await health.json(), { ok: true })
const missingToken = await postRender(base, null, invalidRenderBody())
assert.equal(missingToken.status, 401)
assert.equal((await missingToken.json()).error, 'invalid render token')
const badToken = await postRender(base, 'wrong-token', invalidRenderBody())
assert.equal(badToken.status, 401)
assert.equal((await badToken.json()).error, 'invalid render token')
const invalidRender = await postRender(base, TOKEN, invalidRenderBody())
assert.equal(invalidRender.status, 400)
assert.match((await invalidRender.json()).error, /canvas must be/)
const missingJobEvents = await fetch(`${base}/render/does-not-exist/events?token=bad`)
assert.equal(missingJobEvents.status, 404)
const missingJobFile = await fetch(`${base}/render/does-not-exist/file?token=bad`)
assert.equal(missingJobFile.status, 404)
const missingJobDelete = await fetch(`${base}/render/does-not-exist?token=bad`, { method: 'DELETE' })
assert.equal(missingJobDelete.status, 404)
})
function invalidRenderBody() {
return {
scene: {
canvas: { width: 0, height: 100 },
duration: 1,
fps: 30,
layers: [],
assets: [],
},
options: { fps: 30 },
}
}
function postRender(base, token, body) {
const headers = { 'Content-Type': 'application/json' }
if (token) headers.Authorization = `Bearer ${token}`
return fetch(`${base}/render`, {
method: 'POST',
headers,
body: JSON.stringify(body),
})
}
function freePort() {
return new Promise((resolve, reject) => {
const server = net.createServer()
server.once('error', reject)
server.listen(0, HOST, () => {
const address = server.address()
server.close(() => {
if (!address || typeof address === 'string') reject(new Error('could not allocate test port'))
else resolve(address.port)
})
})
})
}
async function waitForServer(server, port, output) {
const deadline = Date.now() + 5000
let lastError = null
while (Date.now() < deadline) {
if (server.exitCode !== null) {
throw new Error(`server exited early with code ${server.exitCode}\n${output()}`)
}
try {
const res = await fetch(`http://${HOST}:${port}/health`)
if (res.ok) return
} catch (err) {
lastError = err
}
await delay(50)
}
throw new Error(`server did not start: ${lastError?.message || 'timeout'}\n${output()}`)
}
function waitForExit(child) {
return new Promise((resolve) => {
child.once('exit', () => resolve())
setTimeout(() => {
if (child.exitCode === null) child.kill('SIGKILL')
resolve()
}, 1000).unref()
})
}
function delay(ms) {
return new Promise((resolve) => setTimeout(resolve, ms))
}