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>
139 lines
4.5 KiB
JavaScript
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)
|
|
}),
|
|
)
|