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>
110 lines
3.9 KiB
JavaScript
110 lines
3.9 KiB
JavaScript
// Phase B verification of the render-window network block (finding #5):
|
|
// - a local data: image asset still loads and draws (block must not break it)
|
|
// - an http: image asset is blocked and skipped without hanging the render
|
|
//
|
|
// Uses the REAL worker window (createRenderWindow) by loading render.html in a
|
|
// window built the same way, so it exercises the actual webRequest filter.
|
|
//
|
|
// Run: node_modules/.bin/electron scripts/verify-assets.cjs
|
|
|
|
const { app, BrowserWindow } = require('electron')
|
|
const path = require('node:path')
|
|
const { pathToFileURL } = require('node:url')
|
|
|
|
const RENDER_PARTITION = 'ruwio-render'
|
|
|
|
// 40x40 green square as an inline SVG data URL (no network needed).
|
|
const greenSvg =
|
|
"data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='40' height='40'%3E%3Crect width='40' height='40' fill='%2300ff00'/%3E%3C/svg%3E"
|
|
|
|
function makeScene(src) {
|
|
return {
|
|
canvas: { width: 160, height: 120, background: '#202030' },
|
|
duration: 1,
|
|
assets: [{ id: 'img1', kind: 'image', width: 40, height: 40, src }],
|
|
layers: [
|
|
{
|
|
id: 'l1',
|
|
assetId: 'img1',
|
|
visible: true,
|
|
base: { x: 80, y: 60, scaleX: 1, scaleY: 1, rotation: 0, opacity: 1 },
|
|
keyframes: [],
|
|
},
|
|
],
|
|
}
|
|
}
|
|
|
|
function createRenderWindow(scene) {
|
|
const win = new BrowserWindow({
|
|
width: scene.canvas.width,
|
|
height: scene.canvas.height,
|
|
show: false,
|
|
frame: false,
|
|
transparent: false,
|
|
backgroundColor: '#ffffff',
|
|
webPreferences: {
|
|
contextIsolation: true,
|
|
nodeIntegration: false,
|
|
sandbox: true,
|
|
partition: RENDER_PARTITION,
|
|
},
|
|
})
|
|
win.webContents.session.webRequest.onBeforeRequest({ urls: ['*://*/*'] }, (_d, cb) =>
|
|
cb({ cancel: true }),
|
|
)
|
|
win.webContents.setWindowOpenHandler(() => ({ action: 'deny' }))
|
|
win.webContents.on('will-navigate', (e) => e.preventDefault())
|
|
return win
|
|
}
|
|
|
|
function countGreen(image) {
|
|
const bmp = image.getBitmap() // BGRA
|
|
let green = 0
|
|
for (let i = 0; i < bmp.length; i += 4) {
|
|
if (bmp[i] < 120 && bmp[i + 1] > 150 && bmp[i + 2] < 120) green++
|
|
}
|
|
return green
|
|
}
|
|
|
|
async function renderOnce(src) {
|
|
const scene = makeScene(src)
|
|
const win = createRenderWindow(scene)
|
|
const renderHtml = path.join(process.cwd(), 'server', 'render.html')
|
|
await win.loadURL(pathToFileURL(renderHtml).toString())
|
|
// Bound by Promise.race so a network-blocked asset that mis-handles can't hang.
|
|
const initDone = win.webContents.executeJavaScript(
|
|
`window.__animatrInit(${JSON.stringify(scene)})`,
|
|
true,
|
|
)
|
|
const timedOut = await Promise.race([
|
|
initDone.then(() => false),
|
|
new Promise((r) => setTimeout(() => r(true), 5000)),
|
|
])
|
|
await win.webContents.executeJavaScript('window.__animatrSeek(0)', true)
|
|
const img = await win.webContents.capturePage({ x: 0, y: 0, width: 160, height: 120 })
|
|
const green = countGreen(img)
|
|
if (!win.isDestroyed()) win.close()
|
|
return { green, timedOut }
|
|
}
|
|
|
|
app.on('window-all-closed', () => {})
|
|
|
|
app.whenReady().then(async () => {
|
|
const local = await renderOnce(greenSvg)
|
|
const remote = await renderOnce('http://10.255.255.1/never.png') // unroutable; must not hang
|
|
|
|
const notes = []
|
|
let pass = true
|
|
// ~1600 green px expected for a 40x40 image; allow for AA.
|
|
if (local.green < 1000) { pass = false; notes.push(`local data: image did not draw (green=${local.green})`) }
|
|
if (local.timedOut) { pass = false; notes.push('local image init timed out') }
|
|
if (remote.green > 50) { pass = false; notes.push(`remote image drew despite block (green=${remote.green})`) }
|
|
if (remote.timedOut) { pass = false; notes.push('remote (blocked) image init hung past 5s') }
|
|
|
|
console.log(`local: green=${local.green} timedOut=${local.timedOut}`)
|
|
console.log(`remote(blocked): green=${remote.green} timedOut=${remote.timedOut}`)
|
|
console.log(notes.length ? 'NOTES:\n ' + notes.join('\n ') : 'NOTES: none')
|
|
console.log(pass ? 'RESULT: PASS' : 'RESULT: FAIL')
|
|
app.exit(pass ? 0 : 1)
|
|
})
|