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,89 @@
|
||||
// Phase B verification of transparent-region flattening (finding #4): a scene
|
||||
// with backgroundVideo set makes render.html skip the background fill, leaving
|
||||
// transparent areas. With the opaque-white render window those areas must
|
||||
// capture as white (~255), matching the server's omitBackground:false, not
|
||||
// black. Builds the window exactly like renderWorker.createRenderWindow.
|
||||
//
|
||||
// Run: node_modules/.bin/electron scripts/verify-bg.cjs
|
||||
|
||||
const { app, BrowserWindow } = require('electron')
|
||||
const path = require('node:path')
|
||||
const { pathToFileURL } = require('node:url')
|
||||
|
||||
const RENDER_PARTITION = 'ruwio-render'
|
||||
|
||||
// backgroundVideo present -> render.html does NOT fill canvas.background.
|
||||
const scene = {
|
||||
canvas: { width: 160, height: 120, background: '#202030' },
|
||||
backgroundVideo: { src: 'data:video/mp4,', duration: 1 },
|
||||
duration: 1,
|
||||
assets: [{ id: 'a1', kind: 'rect', width: 20, height: 20, fill: '#ff0000' }],
|
||||
layers: [
|
||||
{
|
||||
id: 'l1',
|
||||
assetId: 'a1',
|
||||
visible: true,
|
||||
base: { x: 30, y: 30, scaleX: 1, scaleY: 1, rotation: 0, opacity: 1 },
|
||||
keyframes: [],
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
function createRenderWindow() {
|
||||
const win = new BrowserWindow({
|
||||
width: scene.canvas.width,
|
||||
height: scene.canvas.height,
|
||||
show: false,
|
||||
frame: false,
|
||||
transparent: false,
|
||||
backgroundColor: '#ffffff',
|
||||
webPreferences: {
|
||||
contextIsolation: true,
|
||||
nodeIntegration: false,
|
||||
sandbox: true,
|
||||
partition: RENDER_PARTITION,
|
||||
},
|
||||
})
|
||||
win.webContents.session.webRequest.onBeforeRequest({ urls: ['*://*/*'] }, (_d, cb) =>
|
||||
cb({ cancel: true }),
|
||||
)
|
||||
return win
|
||||
}
|
||||
|
||||
app.on('window-all-closed', () => {})
|
||||
|
||||
app.whenReady().then(async () => {
|
||||
const win = createRenderWindow()
|
||||
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)
|
||||
await win.webContents.executeJavaScript('window.__animatrSeek(0)', true)
|
||||
const img = await win.webContents.capturePage({ x: 0, y: 0, width: 160, height: 120 })
|
||||
const bmp = img.getBitmap() // BGRA
|
||||
|
||||
// Sample a corner pixel (far from the red rect at 30,30): index 0 == (0,0).
|
||||
const corner = { b: bmp[0], g: bmp[1], r: bmp[2], a: bmp[3] }
|
||||
let white = 0
|
||||
let black = 0
|
||||
for (let i = 0; i < bmp.length; i += 4) {
|
||||
if (bmp[i] > 240 && bmp[i + 1] > 240 && bmp[i + 2] > 240) white++
|
||||
if (bmp[i] < 15 && bmp[i + 1] < 15 && bmp[i + 2] < 15) black++
|
||||
}
|
||||
|
||||
const notes = []
|
||||
let pass = true
|
||||
if (!(corner.r > 240 && corner.g > 240 && corner.b > 240)) {
|
||||
pass = false
|
||||
notes.push(`corner is not white: rgb(${corner.r},${corner.g},${corner.b})`)
|
||||
}
|
||||
if (black > 100) {
|
||||
pass = false
|
||||
notes.push(`${black} near-black px present (transparent flattened to black?)`)
|
||||
}
|
||||
|
||||
console.log(`corner=rgb(${corner.r},${corner.g},${corner.b}) whitePx=${white} blackPx=${black}`)
|
||||
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)
|
||||
})
|
||||
Reference in new issue
Block a user