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>
121 lines
4.4 KiB
JavaScript
121 lines
4.4 KiB
JavaScript
// Compares Electron capturePage output against render.html's own canvas pixels
|
|
// for deterministic fixture scenes. This guards the capture step itself:
|
|
// wrong scale-factor normalization, stale frames, and clipping show up here.
|
|
|
|
const { app, BrowserWindow, nativeImage } = require('electron')
|
|
const fs = require('node:fs')
|
|
const path = require('node:path')
|
|
const { pathToFileURL } = require('node:url')
|
|
const { diffBgra } = require('../test/lib/pixelDiff.cjs')
|
|
|
|
const fixtures = [
|
|
{
|
|
file: path.join(process.cwd(), 'test', 'fixtures', 'scenes', 'capture-basic.json'),
|
|
times: [0, 0.5, 1],
|
|
},
|
|
]
|
|
|
|
function createRenderWindow(scene) {
|
|
return 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,
|
|
backgroundThrottling: false,
|
|
partition: 'ruwio-capture-fidelity',
|
|
},
|
|
})
|
|
}
|
|
|
|
async function main() {
|
|
let pass = true
|
|
const notes = []
|
|
const renderHtml = path.join(process.cwd(), 'server', 'render.html')
|
|
|
|
for (const fixture of fixtures) {
|
|
const scene = JSON.parse(fs.readFileSync(fixture.file, 'utf8'))
|
|
const win = createRenderWindow(scene)
|
|
win.webContents.session.webRequest.onBeforeRequest(
|
|
{ urls: ['*://*/*'] },
|
|
(_details, callback) => callback({ cancel: true }),
|
|
)
|
|
win.webContents.setWindowOpenHandler(() => ({ action: 'deny' }))
|
|
win.webContents.on('will-navigate', (event) => event.preventDefault())
|
|
|
|
try {
|
|
await win.loadURL(pathToFileURL(renderHtml).toString())
|
|
await win.webContents.executeJavaScript(`window.__animatrInit(${JSON.stringify(scene)})`, true)
|
|
|
|
for (const t of fixture.times) {
|
|
await win.webContents.executeJavaScript(`window.__animatrSeek(${t})`, true)
|
|
const capture = await win.webContents.capturePage({
|
|
x: 0,
|
|
y: 0,
|
|
width: scene.canvas.width,
|
|
height: scene.canvas.height,
|
|
})
|
|
const canvasDataUrl = await win.webContents.executeJavaScript(
|
|
`document.querySelector('canvas').toDataURL('image/png')`,
|
|
true,
|
|
)
|
|
const expected = nativeImage.createFromDataURL(canvasDataUrl)
|
|
const captureSize = capture.getSize()
|
|
const expectedSize = expected.getSize()
|
|
if (captureSize.width !== scene.canvas.width || captureSize.height !== scene.canvas.height) {
|
|
pass = false
|
|
notes.push(`${path.basename(fixture.file)} t=${t}: capture size ${captureSize.width}x${captureSize.height}`)
|
|
continue
|
|
}
|
|
if (expectedSize.width !== scene.canvas.width || expectedSize.height !== scene.canvas.height) {
|
|
pass = false
|
|
notes.push(`${path.basename(fixture.file)} t=${t}: canvas size ${expectedSize.width}x${expectedSize.height}`)
|
|
continue
|
|
}
|
|
const diff = diffBgra(capture.getBitmap(), expected.getBitmap(), {
|
|
// capturePage composites the hidden window, which introduces
|
|
// sub-perceptual AA/rounding noise (~maxDelta 6/255 on shape edges)
|
|
// versus the raw canvas toDataURL. Tolerate that; genuine render
|
|
// drift (wrong color, missing or stale shape, off-by-one frame)
|
|
// produces much larger deltas over a contiguous region and still
|
|
// trips the ratio gate. The explicit size checks above catch #10/#11.
|
|
perChannelTolerance: 8,
|
|
maxMismatchedRatio: 0.02,
|
|
})
|
|
console.log(
|
|
`${path.basename(fixture.file)} t=${t}: mismatch=${diff.mismatchedPixels}/${diff.pixels} ` +
|
|
`(${(diff.mismatchedRatio * 100).toFixed(4)}%) maxDelta=${diff.maxDelta} meanDelta=${diff.meanDelta.toFixed(3)}`,
|
|
)
|
|
if (!diff.ok) {
|
|
pass = false
|
|
notes.push(
|
|
`${path.basename(fixture.file)} t=${t}: diff exceeds tolerance ` +
|
|
`${diff.mismatchedPixels}/${diff.pixels}, maxDelta=${diff.maxDelta}`,
|
|
)
|
|
}
|
|
}
|
|
} finally {
|
|
if (!win.isDestroyed()) win.close()
|
|
}
|
|
}
|
|
|
|
console.log(notes.length ? 'NOTES:\n ' + notes.join('\n ') : 'NOTES: none')
|
|
console.log(pass ? 'RESULT: PASS' : 'RESULT: FAIL')
|
|
app.exit(pass ? 0 : 1)
|
|
}
|
|
|
|
app.on('window-all-closed', () => {})
|
|
app.commandLine.appendSwitch('disable-gpu')
|
|
|
|
app.whenReady().then(() =>
|
|
main().catch((err) => {
|
|
console.error('HARNESS ERROR:', err)
|
|
app.exit(2)
|
|
}),
|
|
)
|