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>
101 lines
3.0 KiB
JavaScript
101 lines
3.0 KiB
JavaScript
// 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)
|
|
}),
|
|
)
|