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>
67 lines
2.0 KiB
JavaScript
67 lines
2.0 KiB
JavaScript
// 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)
|
|
}),
|
|
)
|