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,138 @@
|
||||
// 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)
|
||||
}),
|
||||
)
|
||||
Reference in new issue
Block a user