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>
This commit is contained in:
1 parent
46f9f32aef
commit
518d43855b
41 files changed
+3907
-86
No files matched your search
@@ -0,0 +1,120 @@
|
||||
// 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)
|
||||
}),
|
||||
)
|
||||
Reference in new issue
Block a user