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>
126 lines
4.3 KiB
JavaScript
126 lines
4.3 KiB
JavaScript
// 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)
|
|
}),
|
|
)
|