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:
thandClaude Opus 4.8 committed 2026-06-06 20:41:25 +02:00
1 parent 46f9f32aef
commit 518d43855b
41 files changed
+3907 -86

No files matched your search

+65
View File
@@ -0,0 +1,65 @@
#!/usr/bin/env node
const { copyFileSync, chmodSync, existsSync, mkdirSync, statSync } = require('node:fs')
const path = require('node:path')
const { spawnSync } = require('node:child_process')
const args = process.argv.slice(2)
const optional = args.includes('--optional')
const sourceIndex = args.indexOf('--source')
const explicitSource = sourceIndex >= 0 ? args[sourceIndex + 1] : ''
const root = path.resolve(__dirname, '..')
const exe = process.platform === 'win32' ? 'ffmpeg.exe' : 'ffmpeg'
const targetDir = path.join(root, 'bin', process.platform)
const target = path.join(targetDir, exe)
try {
mkdirSync(targetDir, { recursive: true })
if (existsSync(target) && statSync(target).isFile()) {
chmodIfNeeded(target)
console.log(`ffmpeg already staged at ${path.relative(root, target)}`)
process.exit(0)
}
const source = explicitSource || process.env.FFMPEG_PATH || findOnPath()
if (!source) {
finishWithoutBinary('ffmpeg was not found. Set FFMPEG_PATH or pass --source /path/to/ffmpeg.')
}
const resolvedSource = path.resolve(source)
if (!existsSync(resolvedSource) || !statSync(resolvedSource).isFile()) {
finishWithoutBinary(`ffmpeg source is not a file: ${source}`)
}
copyFileSync(resolvedSource, target)
chmodIfNeeded(target)
console.log(`staged ffmpeg from ${resolvedSource} to ${path.relative(root, target)}`)
} catch (err) {
if (optional) {
console.warn(`warning: could not stage ffmpeg: ${err.message}`)
process.exit(0)
}
throw err
}
function findOnPath() {
const command = process.platform === 'win32' ? 'where' : 'which'
const result = spawnSync(command, [exe], { encoding: 'utf8' })
if (result.status !== 0) return ''
return result.stdout.split(/\r?\n/).map((line) => line.trim()).find(Boolean) || ''
}
function chmodIfNeeded(file) {
if (process.platform !== 'win32') chmodSync(file, 0o755)
}
function finishWithoutBinary(message) {
if (optional) {
console.warn(`warning: ${message}`)
process.exit(0)
}
console.error(message)
process.exit(1)
}
+109
View File
@@ -0,0 +1,109 @@
// Phase B verification of the render-window network block (finding #5):
// - a local data: image asset still loads and draws (block must not break it)
// - an http: image asset is blocked and skipped without hanging the render
//
// Uses the REAL worker window (createRenderWindow) by loading render.html in a
// window built the same way, so it exercises the actual webRequest filter.
//
// Run: node_modules/.bin/electron scripts/verify-assets.cjs
const { app, BrowserWindow } = require('electron')
const path = require('node:path')
const { pathToFileURL } = require('node:url')
const RENDER_PARTITION = 'ruwio-render'
// 40x40 green square as an inline SVG data URL (no network needed).
const greenSvg =
"data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='40' height='40'%3E%3Crect width='40' height='40' fill='%2300ff00'/%3E%3C/svg%3E"
function makeScene(src) {
return {
canvas: { width: 160, height: 120, background: '#202030' },
duration: 1,
assets: [{ id: 'img1', kind: 'image', width: 40, height: 40, src }],
layers: [
{
id: 'l1',
assetId: 'img1',
visible: true,
base: { x: 80, y: 60, scaleX: 1, scaleY: 1, rotation: 0, opacity: 1 },
keyframes: [],
},
],
}
}
function createRenderWindow(scene) {
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 }),
)
win.webContents.setWindowOpenHandler(() => ({ action: 'deny' }))
win.webContents.on('will-navigate', (e) => e.preventDefault())
return win
}
function countGreen(image) {
const bmp = image.getBitmap() // BGRA
let green = 0
for (let i = 0; i < bmp.length; i += 4) {
if (bmp[i] < 120 && bmp[i + 1] > 150 && bmp[i + 2] < 120) green++
}
return green
}
async function renderOnce(src) {
const scene = makeScene(src)
const win = createRenderWindow(scene)
const renderHtml = path.join(process.cwd(), 'server', 'render.html')
await win.loadURL(pathToFileURL(renderHtml).toString())
// Bound by Promise.race so a network-blocked asset that mis-handles can't hang.
const initDone = win.webContents.executeJavaScript(
`window.__animatrInit(${JSON.stringify(scene)})`,
true,
)
const timedOut = await Promise.race([
initDone.then(() => false),
new Promise((r) => setTimeout(() => r(true), 5000)),
])
await win.webContents.executeJavaScript('window.__animatrSeek(0)', true)
const img = await win.webContents.capturePage({ x: 0, y: 0, width: 160, height: 120 })
const green = countGreen(img)
if (!win.isDestroyed()) win.close()
return { green, timedOut }
}
app.on('window-all-closed', () => {})
app.whenReady().then(async () => {
const local = await renderOnce(greenSvg)
const remote = await renderOnce('http://10.255.255.1/never.png') // unroutable; must not hang
const notes = []
let pass = true
// ~1600 green px expected for a 40x40 image; allow for AA.
if (local.green < 1000) { pass = false; notes.push(`local data: image did not draw (green=${local.green})`) }
if (local.timedOut) { pass = false; notes.push('local image init timed out') }
if (remote.green > 50) { pass = false; notes.push(`remote image drew despite block (green=${remote.green})`) }
if (remote.timedOut) { pass = false; notes.push('remote (blocked) image init hung past 5s') }
console.log(`local: green=${local.green} timedOut=${local.timedOut}`)
console.log(`remote(blocked): green=${remote.green} timedOut=${remote.timedOut}`)
console.log(notes.length ? 'NOTES:\n ' + notes.join('\n ') : 'NOTES: none')
console.log(pass ? 'RESULT: PASS' : 'RESULT: FAIL')
app.exit(pass ? 0 : 1)
})
+89
View File
@@ -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)
})
+100
View File
@@ -0,0 +1,100 @@
// Phase A cancel verification: abort renderMp4Job mid-export, confirm it
// rejects, leaves no partial output file, and the ffmpeg child is gone.
//
// Run: npx tsc -p tsconfig.electron.json && node_modules/.bin/electron scripts/verify-cancel.cjs
const { app } = require('electron')
const path = require('node:path')
const os = require('node:os')
const fs = require('node:fs')
const { renderMp4Job } = require('../dist-electron/renderWorker.cjs')
const fps = 24
const duration = 6 // long enough that we can cancel before it finishes
const scene = {
canvas: { width: 480, height: 360, background: '#202030' },
duration,
assets: [{ id: 'a1', kind: 'rect', width: 60, height: 60, fill: '#40c0ff' }],
layers: [
{
id: 'l1',
assetId: 'a1',
visible: true,
base: { x: 40, y: 180, scaleX: 1, scaleY: 1, rotation: 0, opacity: 1 },
keyframes: [
{ time: 0, props: { x: 40 } },
{ time: 6, props: { x: 420 } },
],
},
],
}
const options = { fps, duration, totalFrames: fps * duration + 1, crf: '20', preset: 'ultrafast' }
async function main() {
const outPath = path.join(os.tmpdir(), 'ruwio-verify-cancel.mp4')
fs.rmSync(outPath, { force: true })
const controller = new AbortController()
let progressAtCancel = null
const job = renderMp4Job({
jobId: 'cancel',
scene,
options,
outputPath: outPath,
signal: controller.signal,
onProgress: (p) => {
// Cancel once we've made real progress but are nowhere near done.
if (progressAtCancel === null && p > 0.1) {
progressAtCancel = p
controller.abort()
}
},
})
let rejected = false
let message = ''
try {
await job
} catch (err) {
rejected = true
message = err.message
}
// Give the OS a moment to reap the killed ffmpeg + finish fs.rm cleanup.
await new Promise((r) => setTimeout(r, 300))
const notes = []
let pass = true
if (progressAtCancel === null) {
pass = false
notes.push('never reached a progress point to cancel at (job too fast)')
}
if (!rejected) {
pass = false
notes.push('job resolved instead of rejecting after abort')
} else if (!/abort/i.test(message)) {
notes.push(`rejected, but message was: ${message}`)
}
if (fs.existsSync(outPath)) {
pass = false
notes.push(`partial output left behind: ${outPath} (${fs.statSync(outPath).size}B)`)
}
console.log(`progressAtCancel=${progressAtCancel} rejected=${rejected} message="${message}"`)
console.log(`output exists after cancel: ${fs.existsSync(outPath)}`)
console.log(notes.length ? 'NOTES:\n ' + notes.join('\n ') : 'NOTES: none')
console.log(pass ? 'RESULT: PASS' : 'RESULT: FAIL')
app.exit(pass ? 0 : 1)
}
// Suppress Electron's default "quit when all windows close": renderMp4Job
// closes its render window, and the auto-quit would race our result printing.
app.on('window-all-closed', () => {})
app.whenReady().then(() =>
main().catch((err) => {
console.error('HARNESS ERROR:', err)
app.exit(2)
}),
)
+120
View File
@@ -0,0 +1,120 @@
// Compares Electron capturePage output against render.html's own canvas pixels
// for deterministic fixture scenes. This guards the capture step itself:
// wrong scale-factor normalization, stale frames, and clipping show up here.
const { app, BrowserWindow, nativeImage } = require('electron')
const fs = require('node:fs')
const path = require('node:path')
const { pathToFileURL } = require('node:url')
const { diffBgra } = require('../test/lib/pixelDiff.cjs')
const fixtures = [
{
file: path.join(process.cwd(), 'test', 'fixtures', 'scenes', 'capture-basic.json'),
times: [0, 0.5, 1],
},
]
function createRenderWindow(scene) {
return 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,
backgroundThrottling: false,
partition: 'ruwio-capture-fidelity',
},
})
}
async function main() {
let pass = true
const notes = []
const renderHtml = path.join(process.cwd(), 'server', 'render.html')
for (const fixture of fixtures) {
const scene = JSON.parse(fs.readFileSync(fixture.file, 'utf8'))
const win = createRenderWindow(scene)
win.webContents.session.webRequest.onBeforeRequest(
{ urls: ['*://*/*'] },
(_details, callback) => callback({ cancel: true }),
)
win.webContents.setWindowOpenHandler(() => ({ action: 'deny' }))
win.webContents.on('will-navigate', (event) => event.preventDefault())
try {
await win.loadURL(pathToFileURL(renderHtml).toString())
await win.webContents.executeJavaScript(`window.__animatrInit(${JSON.stringify(scene)})`, true)
for (const t of fixture.times) {
await win.webContents.executeJavaScript(`window.__animatrSeek(${t})`, true)
const capture = await win.webContents.capturePage({
x: 0,
y: 0,
width: scene.canvas.width,
height: scene.canvas.height,
})
const canvasDataUrl = await win.webContents.executeJavaScript(
`document.querySelector('canvas').toDataURL('image/png')`,
true,
)
const expected = nativeImage.createFromDataURL(canvasDataUrl)
const captureSize = capture.getSize()
const expectedSize = expected.getSize()
if (captureSize.width !== scene.canvas.width || captureSize.height !== scene.canvas.height) {
pass = false
notes.push(`${path.basename(fixture.file)} t=${t}: capture size ${captureSize.width}x${captureSize.height}`)
continue
}
if (expectedSize.width !== scene.canvas.width || expectedSize.height !== scene.canvas.height) {
pass = false
notes.push(`${path.basename(fixture.file)} t=${t}: canvas size ${expectedSize.width}x${expectedSize.height}`)
continue
}
const diff = diffBgra(capture.getBitmap(), expected.getBitmap(), {
// capturePage composites the hidden window, which introduces
// sub-perceptual AA/rounding noise (~maxDelta 6/255 on shape edges)
// versus the raw canvas toDataURL. Tolerate that; genuine render
// drift (wrong color, missing or stale shape, off-by-one frame)
// produces much larger deltas over a contiguous region and still
// trips the ratio gate. The explicit size checks above catch #10/#11.
perChannelTolerance: 8,
maxMismatchedRatio: 0.02,
})
console.log(
`${path.basename(fixture.file)} t=${t}: mismatch=${diff.mismatchedPixels}/${diff.pixels} ` +
`(${(diff.mismatchedRatio * 100).toFixed(4)}%) maxDelta=${diff.maxDelta} meanDelta=${diff.meanDelta.toFixed(3)}`,
)
if (!diff.ok) {
pass = false
notes.push(
`${path.basename(fixture.file)} t=${t}: diff exceeds tolerance ` +
`${diff.mismatchedPixels}/${diff.pixels}, maxDelta=${diff.maxDelta}`,
)
}
}
} finally {
if (!win.isDestroyed()) win.close()
}
}
console.log(notes.length ? 'NOTES:\n ' + notes.join('\n ') : 'NOTES: none')
console.log(pass ? 'RESULT: PASS' : 'RESULT: FAIL')
app.exit(pass ? 0 : 1)
}
app.on('window-all-closed', () => {})
app.commandLine.appendSwitch('disable-gpu')
app.whenReady().then(() =>
main().catch((err) => {
console.error('HARNESS ERROR:', err)
app.exit(2)
}),
)
+138
View File
@@ -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)
}),
)
+39
View File
@@ -0,0 +1,39 @@
// Runs the desktop-render verification harnesses (Phase A/B of
// electron-rendering-integration-plan_2.md) under Electron and aggregates the
// results. Each harness exits 0 on PASS, non-zero on FAIL.
//
// npm run test:render-desktop (compiles electron TS first, then runs this)
//
// Requires a display (DISPLAY set), ffmpeg + ffprobe on PATH, and a built
// dist-electron/ (the npm script's `tsc` step handles the latter).
const { spawnSync } = require('node:child_process')
const path = require('node:path')
const electron = require('electron') // resolves to the electron binary path
const harnesses = [
['capture', 'verify-capture.cjs', 'hidden-window capturePage produces advancing, non-blank frames (#1/#2)'],
['capture-fidelity', 'verify-capture-fidelity.cjs', 'capturePage matches render.html canvas pixels'],
['export', 'verify-export.cjs', 'real worker renders a playable H.264 MP4 with motion'],
['resolution', 'verify-resolution.cjs', 'MP4 dimensions match the requested canvas on HiDPI (#10)'],
['odd-dims', 'verify-odd-dims.cjs', 'odd canvas dimensions pad to an even MP4 instead of failing (#13)'],
['cancel', 'verify-cancel.cjs', 'cancel mid-export aborts cleanly, no partial file'],
['timeout', 'verify-timeout.cjs', 'inactivity watchdog aborts a wedged render (#3)'],
['assets', 'verify-assets.cjs', 'local data: assets load; remote http: blocked, no hang (#5)'],
['background', 'verify-bg.cjs', 'transparent regions flatten to white, not black (#4)'],
]
let failures = 0
for (const [name, file, desc] of harnesses) {
process.stdout.write(`\n=== ${name}: ${desc} ===\n`)
const res = spawnSync(electron, [path.join(__dirname, file)], {
stdio: ['ignore', 'inherit', 'ignore'], // drop Electron's chatty stderr
})
if (res.status !== 0) {
failures += 1
process.stdout.write(`*** ${name} FAILED (exit ${res.status})\n`)
}
}
process.stdout.write(`\n${failures === 0 ? 'ALL DESKTOP RENDER CHECKS PASSED' : failures + ' CHECK(S) FAILED'}\n`)
process.exit(failures === 0 ? 0 : 1)
+125
View File
@@ -0,0 +1,125 @@
// Phase A end-to-end verification: drives the REAL compiled renderMp4Job
// (dist-electron/renderWorker.cjs) through ffmpeg and ffprobes the result.
//
// Run: npx tsc -p tsconfig.electron.json && node_modules/.bin/electron scripts/verify-export.cjs
const { app } = require('electron')
const path = require('node:path')
const os = require('node:os')
const fs = require('node:fs')
const { execFileSync } = require('node:child_process')
const { renderMp4Job } = require('../dist-electron/renderWorker.cjs')
const fps = 12
const duration = 2
const scene = {
canvas: { width: 320, height: 240, background: '#202030' },
duration,
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 } },
],
},
],
}
const options = {
fps,
duration,
totalFrames: fps * duration + 1,
crf: '20',
preset: 'ultrafast',
}
async function main() {
const outPath = path.join(os.tmpdir(), 'ruwio-verify-export.mp4')
fs.rmSync(outPath, { force: true })
const controller = new AbortController()
let lastProgress = 0
const startedAt = Date.now()
await renderMp4Job({
jobId: 'verify',
scene,
options,
outputPath: outPath,
signal: controller.signal,
onProgress: (p) => {
lastProgress = p
},
})
const elapsedMs = Date.now() - startedAt
const msPerFrame = elapsedMs / options.totalFrames
const notes = []
let pass = true
if (!fs.existsSync(outPath)) {
pass = false
notes.push('output file missing')
} else {
const sizeKb = (fs.statSync(outPath).size / 1024).toFixed(1)
const probe = JSON.parse(
execFileSync('ffprobe', [
'-v', 'error',
'-select_streams', 'v:0',
'-show_entries', 'stream=codec_name,width,height,nb_read_frames',
'-count_frames',
'-of', 'json',
outPath,
]).toString(),
)
const s = probe.streams[0]
console.log(`output: ${outPath} (${sizeKb} KB)`)
console.log(`codec=${s.codec_name} ${s.width}x${s.height} frames=${s.nb_read_frames} progress=${lastProgress}`)
console.log(`elapsed=${elapsedMs}ms (${msPerFrame.toFixed(1)}ms/frame)`)
if (s.codec_name !== 'h264') { pass = false; notes.push(`codec ${s.codec_name} != h264`) }
if (s.width !== 320 || s.height !== 240) { pass = false; notes.push(`wrong dims ${s.width}x${s.height}`) }
if (Number(s.nb_read_frames) !== options.totalFrames) {
pass = false
notes.push(`frames ${s.nb_read_frames} != expected ${options.totalFrames}`)
}
if (lastProgress !== 1) { pass = false; notes.push(`final progress ${lastProgress} != 1`) }
const maxMsPerFrame = Number(process.env.RUWIO_VERIFY_MAX_MS_PER_FRAME || 800)
if (msPerFrame > maxMsPerFrame) {
pass = false
notes.push(
`render throughput ${msPerFrame.toFixed(1)}ms/frame exceeds ${maxMsPerFrame}ms/frame ` +
'(hidden render window may be throttled)',
)
}
// Decode first & last frame, confirm the rect moved (motion preserved).
const f0 = path.join(os.tmpdir(), 'ruwio-f0.png')
const f1 = path.join(os.tmpdir(), 'ruwio-flast.png')
execFileSync('ffmpeg', ['-y', '-v', 'error', '-i', outPath, '-vf', 'select=eq(n\\,0)', '-frames:v', '1', f0])
execFileSync('ffmpeg', ['-y', '-v', 'error', '-sseof', '-0.1', '-i', outPath, '-frames:v', '1', f1])
const sz0 = fs.statSync(f0).size
const sz1 = fs.statSync(f1).size
const b0 = fs.readFileSync(f0)
const b1 = fs.readFileSync(f1)
const same = b0.length === b1.length && b0.equals(b1)
console.log(`first frame ${sz0}B, last frame ${sz1}B, identical=${same}`)
if (same) { pass = false; notes.push('first and last frame identical (no motion)') }
}
console.log(notes.length ? 'NOTES:\n ' + notes.join('\n ') : 'NOTES: none')
console.log(pass ? 'RESULT: PASS' : 'RESULT: FAIL')
app.exit(pass ? 0 : 1)
}
// renderMp4Job closes its render window; suppress the default auto-quit so it
// doesn't race result printing.
app.on('window-all-closed', () => {})
app.whenReady().then(() =>
main().catch((err) => {
console.error('HARNESS ERROR:', err)
app.exit(2)
}),
)
+66
View File
@@ -0,0 +1,66 @@
// Regression for odd canvas dimensions (#13): yuv420p MP4 export pads to even
// dimensions instead of failing with "height not divisible by 2".
const { app } = require('electron')
const fs = require('node:fs')
const os = require('node:os')
const path = require('node:path')
const { execFileSync } = require('node:child_process')
const { renderMp4Job } = require('../dist-electron/renderWorker.cjs')
const scene = {
canvas: { width: 101, height: 101, background: '#111827' },
duration: 0.5,
assets: [{ id: 'rect', kind: 'rect', width: 45, height: 35, fill: '#f97316' }],
layers: [
{
id: 'layer-rect',
assetId: 'rect',
visible: true,
base: { x: 50, y: 50, scaleX: 1, scaleY: 1, rotation: 0, opacity: 1 },
keyframes: [
{ time: 0, props: { x: 38 } },
{ time: 0.5, props: { x: 62 } },
],
},
],
}
async function main() {
const outPath = path.join(os.tmpdir(), 'ruwio-verify-odd-dims.mp4')
fs.rmSync(outPath, { force: true })
await renderMp4Job({
jobId: 'verify-odd-dims',
scene,
options: { fps: 4, duration: 0.5, totalFrames: 3, crf: '28', preset: 'ultrafast' },
outputPath: outPath,
signal: new AbortController().signal,
onProgress: () => {},
})
const probe = JSON.parse(execFileSync('ffprobe', [
'-v', 'error',
'-select_streams', 'v:0',
'-show_entries', 'stream=codec_name,width,height,nb_read_frames',
'-count_frames',
'-of', 'json',
outPath,
]).toString())
const stream = probe.streams[0]
const pass =
stream.codec_name === 'h264' &&
stream.width === 102 &&
stream.height === 102 &&
Number(stream.nb_read_frames) === 3
console.log(`codec=${stream.codec_name} dimensions=${stream.width}x${stream.height} frames=${stream.nb_read_frames}`)
console.log(pass ? 'RESULT: PASS' : 'RESULT: FAIL')
app.exit(pass ? 0 : 1)
}
app.on('window-all-closed', () => {})
app.whenReady().then(() =>
main().catch((err) => {
console.error('HARNESS ERROR:', err)
app.exit(2)
}),
)
+58
View File
@@ -0,0 +1,58 @@
// Regression for capture resolution normalization (#10). On scale-factor-1
// displays this is a no-op, while HiDPI displays catch capturePage inflation.
const { app } = require('electron')
const fs = require('node:fs')
const os = require('node:os')
const path = require('node:path')
const { execFileSync } = require('node:child_process')
const { renderMp4Job } = require('../dist-electron/renderWorker.cjs')
const scene = {
canvas: { width: 320, height: 240, background: '#ffffff' },
duration: 0.25,
assets: [{ id: 'rect', kind: 'rect', width: 80, height: 60, fill: '#2563eb' }],
layers: [
{
id: 'layer-rect',
assetId: 'rect',
visible: true,
base: { x: 160, y: 120, scaleX: 1, scaleY: 1, rotation: 0, opacity: 1 },
keyframes: [{ time: 0, props: {} }],
},
],
}
async function main() {
const outPath = path.join(os.tmpdir(), 'ruwio-verify-resolution.mp4')
fs.rmSync(outPath, { force: true })
await renderMp4Job({
jobId: 'verify-resolution',
scene,
options: { fps: 2, duration: 0.25, totalFrames: 1, crf: '28', preset: 'ultrafast' },
outputPath: outPath,
signal: new AbortController().signal,
onProgress: () => {},
})
const probe = JSON.parse(execFileSync('ffprobe', [
'-v', 'error',
'-select_streams', 'v:0',
'-show_entries', 'stream=width,height',
'-of', 'json',
outPath,
]).toString())
const stream = probe.streams[0]
const pass = stream.width === scene.canvas.width && stream.height === scene.canvas.height
console.log(`ffprobe dimensions: ${stream.width}x${stream.height}`)
console.log(pass ? 'RESULT: PASS' : 'RESULT: FAIL')
app.exit(pass ? 0 : 1)
}
app.on('window-all-closed', () => {})
app.whenReady().then(() =>
main().catch((err) => {
console.error('HARNESS ERROR:', err)
app.exit(2)
}),
)
+88
View File
@@ -0,0 +1,88 @@
// Phase B verification of the inactivity watchdog (finding #3): point the
// worker at a render page whose __animatrSeek never resolves, with a short
// RUWIO_RENDER_TIMEOUT_MS, and confirm the job aborts with "render timed out"
// and leaves no partial file.
//
// Run: npx tsc -p tsconfig.electron.json && node_modules/.bin/electron scripts/verify-timeout.cjs
// Must be set BEFORE requiring the worker — the timeout is read at module load.
process.env.RUWIO_RENDER_TIMEOUT_MS = '2000'
const { app } = require('electron')
const path = require('node:path')
const os = require('node:os')
const fs = require('node:fs')
// Build a temp project dir with a server/render.html that hangs on seek, then
// chdir into it so the worker's loadRenderPage (process.cwd()/server/render.html)
// picks it up.
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ruwio-timeout-'))
fs.mkdirSync(path.join(tmpDir, 'server'))
fs.writeFileSync(
path.join(tmpDir, 'server', 'render.html'),
`<!doctype html><html><body><script>
window.__animatrInit = function(){ return Promise.resolve(true) }
window.__animatrSeek = function(){ return new Promise(function(){}) } // never resolves
</script></body></html>`,
)
process.chdir(tmpDir)
const { renderMp4Job } = require('../dist-electron/renderWorker.cjs')
const fps = 12
const duration = 2
const scene = {
canvas: { width: 160, height: 120, background: '#202030' },
duration,
assets: [{ id: 'a1', kind: 'rect', width: 20, height: 20, fill: '#ff4040' }],
layers: [
{
id: 'l1',
assetId: 'a1',
visible: true,
base: { x: 20, y: 60, scaleX: 1, scaleY: 1, rotation: 0, opacity: 1 },
keyframes: [{ time: 0, props: { x: 20 } }, { time: 2, props: { x: 140 } }],
},
],
}
const options = { fps, duration, totalFrames: fps * duration + 1, crf: '20', preset: 'ultrafast' }
app.on('window-all-closed', () => {})
app.whenReady().then(async () => {
const outPath = path.join(tmpDir, 'out.mp4')
const controller = new AbortController()
const startedAt = process.hrtime.bigint()
let rejected = false
let message = ''
try {
await renderMp4Job({
jobId: 'timeout',
scene,
options,
outputPath: outPath,
signal: controller.signal,
onProgress: () => {},
})
} catch (err) {
rejected = true
message = err.message
}
const elapsedMs = Number(process.hrtime.bigint() - startedAt) / 1e6
await new Promise((r) => setTimeout(r, 200))
const notes = []
let pass = true
if (!rejected) { pass = false; notes.push('job did not reject') }
if (!/timed out/i.test(message)) { pass = false; notes.push(`message was "${message}", expected "render timed out"`) }
if (elapsedMs < 1500 || elapsedMs > 8000) { pass = false; notes.push(`elapsed ${elapsedMs.toFixed(0)}ms outside [1500,8000] (timeout=2000)`) }
if (fs.existsSync(outPath)) { pass = false; notes.push('partial output left behind') }
console.log(`rejected=${rejected} message="${message}" elapsed=${elapsedMs.toFixed(0)}ms outExists=${fs.existsSync(outPath)}`)
console.log(notes.length ? 'NOTES:\n ' + notes.join('\n ') : 'NOTES: none')
console.log(pass ? 'RESULT: PASS' : 'RESULT: FAIL')
fs.rmSync(tmpDir, { recursive: true, force: true })
app.exit(pass ? 0 : 1)
})