// Phase A verification harness (05_electron-rendering-integration-plan_2.md, finding #1/#2). // // Reproduces renderWorker.cts's window-creation + capturePage path against a // scene with one moving + fading layer, then checks that captured frames are // (a) non-blank and (b) actually advance over time. This validates or refutes // "capturePage on a hidden, non-offscreen window returns blank/stale frames". // // Run: node_modules/.bin/electron scripts/verify-capture.cjs // Set OFFSCREEN=1 to exercise the offscreen+paint-settle code path instead. const { app, BrowserWindow } = require('electron') const path = require('node:path') const { pathToFileURL } = require('node:url') const OFFSCREEN = process.env.OFFSCREEN === '1' // A 320x240 scene: a red rect slides left->right and fades over 2s on a dark bg. const scene = { canvas: { width: 320, height: 240, background: '#202030' }, duration: 2, assets: [{ id: 'a1', kind: 'rect', width: 40, height: 40, fill: '#ff4040' }], layers: [ { id: 'l1', assetId: 'a1', visible: true, base: { x: 20, y: 100, scaleX: 1, scaleY: 1, rotation: 0, opacity: 1 }, keyframes: [ { time: 0, props: { x: 20 } }, { time: 2, props: { x: 260 } }, ], }, ], } function createRenderWindow() { return new BrowserWindow({ width: scene.canvas.width, height: scene.canvas.height, show: false, frame: false, transparent: true, backgroundColor: '#00000000', webPreferences: { contextIsolation: true, nodeIntegration: false, sandbox: true, offscreen: OFFSCREEN, }, }) } // Cheap fingerprint of a NativeImage: total pixels, count of non-(near)background // pixels, and a coarse sum so we can tell frames apart. function fingerprint(image) { const bmp = image.getBitmap() // BGRA let nonBg = 0 let redish = 0 // Position-weighted hash: changes under pure translation (an order-independent // sum would not), so two frames that differ only in where the rect sits hash // differently. let hash = 0 for (let i = 0; i < bmp.length; i += 4) { const b = bmp[i] const g = bmp[i + 1] const r = bmp[i + 2] const px = i / 4 if (Math.abs(r - 0x20) + Math.abs(g - 0x20) + Math.abs(b - 0x30) > 60) nonBg++ if (r > 150 && g < 120 && b < 120) { redish++ hash = (hash + px * 2654435761) >>> 0 } } return { pixels: bmp.length / 4, nonBg, redish, hash, size: image.getSize() } } async function main() { const win = createRenderWindow() win.webContents.setWindowOpenHandler(() => ({ action: 'deny' })) const renderHtml = path.join(process.cwd(), 'server', 'render.html') await win.loadURL(pathToFileURL(renderHtml).toString()) await win.webContents.executeJavaScript(`window.__animatrInit(${JSON.stringify(scene)})`, true) const captureRect = { x: 0, y: 0, width: scene.canvas.width, height: scene.canvas.height } const times = [0, 0.5, 1, 1.5, 2] const frames = [] for (const t of times) { // __animatrSeek now resolves only after a compositor paint settle, so the // worker just awaits it — no extra settle here (mirrors renderWorker.cts). await win.webContents.executeJavaScript(`window.__animatrSeek(${t})`, true) const image = await win.webContents.capturePage(captureRect) frames.push({ t, ...fingerprint(image) }) } // Analysis let pass = true const notes = [] for (const f of frames) { if (f.size.width !== scene.canvas.width || f.size.height !== scene.canvas.height) { pass = false notes.push(`frame t=${f.t}: wrong size ${f.size.width}x${f.size.height}`) } if (f.nonBg < 50) { pass = false notes.push(`frame t=${f.t}: looks blank (nonBg=${f.nonBg})`) } } // Frames must differ (motion advanced, not frozen on frame 0). const hashes = new Set(frames.map((f) => f.hash)) if (hashes.size < frames.length) { pass = false notes.push(`frames not all distinct: ${frames.length} captured, ${hashes.size} unique hashes`) } console.log('OFFSCREEN =', OFFSCREEN) console.log('captures:') for (const f of frames) { console.log( ` t=${f.t} nonBg=${f.nonBg} redish=${f.redish} hash=${f.hash} size=${f.size.width}x${f.size.height}`, ) } console.log(notes.length ? 'NOTES:\n ' + notes.join('\n ') : 'NOTES: none') console.log(pass ? 'RESULT: PASS' : 'RESULT: FAIL') if (!win.isDestroyed()) win.close() app.exit(pass ? 0 : 1) } app.on('window-all-closed', () => {}) app.whenReady().then(() => main().catch((err) => { console.error('HARNESS ERROR:', err) app.exit(2) }), )