Files
RUWIO/scripts/verify-capture-fidelity.cjs
thandClaude Opus 4.8 518d43855b 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>
2026-06-06 20:41:25 +02:00

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)
}),
)