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>
89 lines
3.2 KiB
JavaScript
89 lines
3.2 KiB
JavaScript
// Phase B verification of the inactivity watchdog (finding #3): point the
|
|
// worker at a render page whose __animatrSeek never resolves, with a short
|
|
// RUWIO_RENDER_TIMEOUT_MS, and confirm the job aborts with "render timed out"
|
|
// and leaves no partial file.
|
|
//
|
|
// Run: npx tsc -p tsconfig.electron.json && node_modules/.bin/electron scripts/verify-timeout.cjs
|
|
|
|
// Must be set BEFORE requiring the worker — the timeout is read at module load.
|
|
process.env.RUWIO_RENDER_TIMEOUT_MS = '2000'
|
|
|
|
const { app } = require('electron')
|
|
const path = require('node:path')
|
|
const os = require('node:os')
|
|
const fs = require('node:fs')
|
|
|
|
// Build a temp project dir with a server/render.html that hangs on seek, then
|
|
// chdir into it so the worker's loadRenderPage (process.cwd()/server/render.html)
|
|
// picks it up.
|
|
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ruwio-timeout-'))
|
|
fs.mkdirSync(path.join(tmpDir, 'server'))
|
|
fs.writeFileSync(
|
|
path.join(tmpDir, 'server', 'render.html'),
|
|
`<!doctype html><html><body><script>
|
|
window.__animatrInit = function(){ return Promise.resolve(true) }
|
|
window.__animatrSeek = function(){ return new Promise(function(){}) } // never resolves
|
|
</script></body></html>`,
|
|
)
|
|
process.chdir(tmpDir)
|
|
|
|
const { renderMp4Job } = require('../dist-electron/renderWorker.cjs')
|
|
|
|
const fps = 12
|
|
const duration = 2
|
|
const scene = {
|
|
canvas: { width: 160, height: 120, background: '#202030' },
|
|
duration,
|
|
assets: [{ id: 'a1', kind: 'rect', width: 20, height: 20, fill: '#ff4040' }],
|
|
layers: [
|
|
{
|
|
id: 'l1',
|
|
assetId: 'a1',
|
|
visible: true,
|
|
base: { x: 20, y: 60, scaleX: 1, scaleY: 1, rotation: 0, opacity: 1 },
|
|
keyframes: [{ time: 0, props: { x: 20 } }, { time: 2, props: { x: 140 } }],
|
|
},
|
|
],
|
|
}
|
|
const options = { fps, duration, totalFrames: fps * duration + 1, crf: '20', preset: 'ultrafast' }
|
|
|
|
app.on('window-all-closed', () => {})
|
|
|
|
app.whenReady().then(async () => {
|
|
const outPath = path.join(tmpDir, 'out.mp4')
|
|
const controller = new AbortController()
|
|
const startedAt = process.hrtime.bigint()
|
|
|
|
let rejected = false
|
|
let message = ''
|
|
try {
|
|
await renderMp4Job({
|
|
jobId: 'timeout',
|
|
scene,
|
|
options,
|
|
outputPath: outPath,
|
|
signal: controller.signal,
|
|
onProgress: () => {},
|
|
})
|
|
} catch (err) {
|
|
rejected = true
|
|
message = err.message
|
|
}
|
|
const elapsedMs = Number(process.hrtime.bigint() - startedAt) / 1e6
|
|
await new Promise((r) => setTimeout(r, 200))
|
|
|
|
const notes = []
|
|
let pass = true
|
|
if (!rejected) { pass = false; notes.push('job did not reject') }
|
|
if (!/timed out/i.test(message)) { pass = false; notes.push(`message was "${message}", expected "render timed out"`) }
|
|
if (elapsedMs < 1500 || elapsedMs > 8000) { pass = false; notes.push(`elapsed ${elapsedMs.toFixed(0)}ms outside [1500,8000] (timeout=2000)`) }
|
|
if (fs.existsSync(outPath)) { pass = false; notes.push('partial output left behind') }
|
|
|
|
console.log(`rejected=${rejected} message="${message}" elapsed=${elapsedMs.toFixed(0)}ms outExists=${fs.existsSync(outPath)}`)
|
|
console.log(notes.length ? 'NOTES:\n ' + notes.join('\n ') : 'NOTES: none')
|
|
console.log(pass ? 'RESULT: PASS' : 'RESULT: FAIL')
|
|
|
|
fs.rmSync(tmpDir, { recursive: true, force: true })
|
|
app.exit(pass ? 0 : 1)
|
|
})
|