Files
RUWIO/scripts/verify-cancel.cjs
T
thandClaude Opus 4.8 518d43855b 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>
2026-06-06 20:41:25 +02:00

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)
}),
)