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>
311 lines
9.0 KiB
JavaScript
311 lines
9.0 KiB
JavaScript
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))
|
|
}
|