Files
RUWIO/scripts/verify-capture.cjs
2026-06-07 11:10:05 +02:00

139 lines
4.5 KiB
JavaScript

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