Files
RUWIO/scripts/verify-capture.cjs
T
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

139 lines
4.5 KiB
JavaScript

// Phase A verification harness (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)
}),
)