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

+33
View File
@@ -0,0 +1,33 @@
{
"canvas": { "width": 160, "height": 120, "background": "#f8fafc" },
"duration": 1,
"assets": [
{ "id": "rect", "kind": "rect", "name": "Rect", "width": 42, "height": 28, "fill": "#ef4444", "stroke": "#111827", "strokeWidth": 2 },
{ "id": "circle", "kind": "circle", "name": "Circle", "radius": 18, "fill": "#22c55e", "stroke": "#0f172a", "strokeWidth": 2 }
],
"layers": [
{
"id": "layer-rect",
"name": "Rect",
"assetId": "rect",
"visible": true,
"base": { "x": 36, "y": 62, "scaleX": 1, "scaleY": 1, "rotation": 0, "opacity": 1 },
"keyframes": [
{ "id": "rect-0", "time": 0, "easing": "linear", "props": { "x": 36, "rotation": 0 } },
{ "id": "rect-1", "time": 1, "easing": "linear", "props": { "x": 112, "rotation": 18 } }
]
},
{
"id": "layer-circle",
"name": "Circle",
"assetId": "circle",
"visible": true,
"base": { "x": 88, "y": 44, "scaleX": 1, "scaleY": 1, "rotation": 0, "opacity": 0.82 },
"keyframes": [
{ "id": "circle-0", "time": 0, "easing": "linear", "props": { "y": 44 } },
{ "id": "circle-1", "time": 1, "easing": "linear", "props": { "y": 78 } }
]
}
],
"trackOrder": ["layer-circle", "layer-rect"]
}
+41
View File
@@ -0,0 +1,41 @@
function diffBgra(a, b, options = {}) {
if (!a || !b || a.length !== b.length) {
throw new Error(`bitmap size mismatch: ${a?.length || 0} != ${b?.length || 0}`)
}
const perChannelTolerance = Number.isFinite(options.perChannelTolerance)
? options.perChannelTolerance
: 2
const maxMismatchedRatio = Number.isFinite(options.maxMismatchedRatio)
? options.maxMismatchedRatio
: 0.001
let mismatchedPixels = 0
let maxDelta = 0
let totalDelta = 0
const pixels = a.length / 4
for (let i = 0; i < a.length; i += 4) {
let pixelMismatch = false
for (let c = 0; c < 4; c += 1) {
const delta = Math.abs(a[i + c] - b[i + c])
maxDelta = Math.max(maxDelta, delta)
totalDelta += delta
if (delta > perChannelTolerance) pixelMismatch = true
}
if (pixelMismatch) mismatchedPixels += 1
}
const mismatchedRatio = pixels ? mismatchedPixels / pixels : 0
return {
ok: mismatchedRatio <= maxMismatchedRatio,
pixels,
mismatchedPixels,
mismatchedRatio,
maxDelta,
meanDelta: pixels ? totalDelta / (pixels * 4) : 0,
perChannelTolerance,
maxMismatchedRatio,
}
}
module.exports = { diffBgra }