17 KiB
Electron Rendering — Fix Plan (Second-Review Findings #7–#13)
Date: 2026-06-06
This document is a follow-on to 05_electron-rendering-integration-plan_2.md.
That plan tracked findings #1–#6 (all resolved). A second review of the
uncommitted desktop-render changes surfaced a new set of issues, numbered
#7–#13 here to avoid collision. Two of them (#7, #8) are user-facing data
loss / crash bugs and should be fixed before any GUI/packaging sign-off.
Status (2026-06-06): all of #7–#13 are now IMPLEMENTED and verified. #10 and #11 took the interim-mitigation path (per-frame resize back to canvas size + fail-fast on canvases larger than the display), not the offscreen rewrite; #13 pads odd dimensions up to even on both render paths. All six
test:render-desktopchecks pass, as do the server smoke + e2e tests; #7, #8, and #13 were additionally verified with throwaway harnesses (overwrite safety; early-ffmpeg-exit surfaces an error without crash or hang; a 101×101 canvas encodes to a clean 102×102 MP4). See the per-finding "Status" notes below.
Note: plan_2's "What is already correct" section claims "Partial output file is removed on failure/cancel" as a safety property. That is precisely the mechanism behind finding #7 — the worker writes ffmpeg output straight to the user's chosen path, so "removing the partial file" deletes a pre-existing file the user chose to overwrite. The note should be corrected when #7 lands.
Summary table
| # | Sev | File | One-line |
|---|---|---|---|
| 7 | CRITICAL | electron/renderWorker.cts |
Direct-to-target ffmpeg write + fs.rm destroys a pre-existing file on cancel/ffmpeg failure (data loss). |
| 8 | HIGH | electron/renderWorker.cts |
No error listener on ffmpeg.stdin; an early ffmpeg exit makes the next write() throw an uncaught EPIPE that crashes the main process. |
| 9 | HIGH (trivial) | package.json |
"packageManager": "traversal@1.0.0" is a bogus value; breaks corepack-driven tooling. |
| 10 | MEDIUM | electron/renderWorker.cts |
capturePage returns frames at the display scale factor → HiDPI output is N× the requested resolution. |
| 11 | MEDIUM | electron/renderWorker.cts |
A non-offscreen render window can't be captured larger than the display → canvases > screen are truncated. |
| 12 | MEDIUM (perf) | electron/renderWorker.cts + server/render.html |
Hidden-window rAF is throttled, so __animatrSeek hits its 1 s fallback every frame → renders crawl at ~1 fps. |
| 13 | LOW (optional) | electron/renderWorker.cts + server/server.js |
Odd canvas width/height fails yuv420p/libx264 (shared latent bug). |
Recommended sequencing: #9 (1 line) → #7, #8 (correctness) → #12 (one line, big perf win) → decide the #10/#11 strategy (offscreen vs. mitigations) → #13 (optional hardening).
#7 — CRITICAL: desktop export destroys an existing file on cancel/failure
Status: IMPLEMENTED. Worker renders to a sibling
.<name>.<jobId>.partialfile and promotes it withrename(rm-then-rename fallback on Windows) only afterffmpegDoneresolves; the failure/cancel path only ever removes the temp file. One extra change was required: ffmpeg infers its muxer from the output extension, and the temp file has none, so the spawn now passes an explicit-f mp4(this also hardens a final path the user typed without a.mp4extension). Verified: cancel mid-render leaves a pre-existing sentinel file byte-identical with no leftover.partial.
Problem
renderMp4Job spawns ffmpeg writing directly to the user-chosen path:
const ffmpeg = spawn(ffmpegPath, [ '-y', ..., input.outputPath ])
and on any error/cancel does:
await fs.rm(input.outputPath, { force: true }).catch(() => {})
ffmpeg -y <outputPath> opens and truncates the target the instant it
starts. If the user chose to overwrite an existing file (the native save dialog
explicitly offers this), then any of:
- user clicks Cancel mid-render,
- the inactivity watchdog fires,
- ffmpeg exits non-zero,
leaves the original file already truncated, and the catch block then deletes it.
Net result: the user's pre-existing file is gone and there is no replacement.
The standalone server never has this problem because it renders into a private
mkdtemp workDir and only exposes the finished file.
Fix (render to a sibling temp file, promote on success)
Write to a temp file in the same directory as the target (same filesystem ⇒
rename is atomic and can't hit EXDEV), and only touch the real path once the
render has fully succeeded:
const finalPath = input.outputPath
const tmpPath = path.join(
path.dirname(finalPath),
`.${path.basename(finalPath)}.${input.jobId}.partial`,
)
// spawn ffmpeg with tmpPath instead of finalPath
// success path, after `await ffmpegDone`:
await fs.rm(finalPath, { force: true }) // Windows rename won't replace an existing dest
await fs.rename(tmpPath, finalPath)
return { path: finalPath }
// catch path:
await fs.rm(tmpPath, { force: true }).catch(() => {}) // NEVER finalPath
Notes:
- The
rm(finalPath)beforerenameonly runs after a successful render (tmp file is complete), so the old file is replaced, never lost. - Keep
'-movflags', '+faststart'— ffmpeg still finalizes intotmpPath. - Optionally sweep stale
.*.partialfiles in the target dir on worker start (best-effort), to cover hard crashes.
Verification
- New
scripts/verify-overwrite.cjs: pre-create a sentinel file at the target path, start an export to it, cancel mid-render, assert the sentinel bytes are unchanged and no.partialfile remains. - Re-run
scripts/verify-cancel.cjs(already passing) — must stay green.
#8 — HIGH: an early ffmpeg exit crashes/hangs the worker
Status: IMPLEMENTED. The investigation found the EPIPE was only one of three failure modes when ffmpeg exits mid-render; all three are now fixed:
- EPIPE crash — added
ffmpeg.stdin.on('error', …)so a write to the dead pipe can't become an uncaughtException.- Unhandled-rejection crash —
ffmpegDonerejects mid-loop, long before the post-loopawaitattaches a handler; under Node's default--unhandled-rejections=throwthat terminated the process. Now a no-op handler is attached at creation (void ffmpegDone.catch(() => {})); real handling still flows throughawait ffmpegDone. (This is why the original cancel test passed but the early-exit path died — the cancel path attaches its handler within Node's grace window, the early-exit path does not.)- Backpressure hang — when ffmpeg is dead,
stdin.writereturns false and the oldawait stdin.once('drain')waited forever (no drain ever comes, and it wasn't abortable). The wait now racesffmpegDoneandraceAbort(signal).Also added
if (settled) breakat the top of the frame loop so a dead ffmpeg stops being fed. Verified: a stub ffmpeg that exits non-zero mid-stream makesrenderMp4Jobreject with the ffmpeg error, and the process survives and exits 0 (noUNCAUGHT_EXCEPTION/UNHANDLED_REJECTION, no hang).
Problem
The only error listeners are on the ffmpeg ChildProcess (spawn/error/
exit) and ffmpeg.stderr. There is no listener on ffmpeg.stdin. If
ffmpeg exits early (malformed frame, disk full, codec/resource error), the next
loop iteration runs:
if (!ffmpeg.stdin.write(png)) { ... }
Writing to a pipe whose read end is closed emits an asynchronous 'error'
(EPIPE) on the stdin stream. Because it is async, the surrounding try/catch
does not catch it; with no 'error' listener it becomes an
uncaughtException and takes down the entire Electron app (not just the
export). The standalone server has the same write pattern, but there it only
crashes an isolated, restartable server process — in the desktop app it kills
the user's editor.
Fix
-
Swallow the stdin error (the real failure is already surfaced via the
exit/ffmpegDonepath):ffmpeg.stdin.on('error', () => { /* EPIPE after early exit; surfaced via ffmpegDone */ }) -
Stop feeding a dead ffmpeg so the real error surfaces promptly.
settledis already set in theexithandler — check it at the top of the loop:for (let i = 0; i < input.options.totalFrames; i += 1) { if (signal.aborted) throw abortReason(signal) if (settled) break // ffmpeg already exited; quit feeding it ... ffmpeg.stdin.write(png) } ffmpeg.stdin.end() await ffmpegDone // rejects with "ffmpeg exited with code N: <stderr>"
Verification
- New
scripts/verify-ffmpeg-early-exit.cjs: induce an early ffmpeg exit (e.g. pointFFMPEG_PATHat a stub that reads a few bytes thenexit(1), or kill the ffmpeg child mid-stream) and assertrenderMp4Jobrejects with a surfaced ffmpeg error and the process does not crash (the test harness itself surviving is the assertion).
#9 — HIGH (trivial): bogus packageManager field
Status: IMPLEMENTED. Line removed from
package.json; valid JSON, no references remain.
Problem
package.json:6:
"packageManager": "traversal@1.0.0",
traversal is not a package manager. It is referenced nowhere and is absent
from package-lock.json — almost certainly an accidental insertion. In any
corepack-enabled environment (Node ships corepack; CI or contributors may have
run corepack enable), invoking npm/yarn/pnpm reads this field and errors
(Unsupported package manager 'traversal'), blocking installs and builds.
Fix
Delete the line. (If a manager really must be pinned, use a real value like
"npm@10.x".)
Verification
npm install / npm ci and corepack npm -v succeed in a clean checkout.
#10 / #11 / #12 — Capture fidelity & performance
These three share a root cause: the render window is a hidden, non-offscreen
BrowserWindow, and capturePage is coupled to the physical display
(scale factor and size) and to Chromium's background throttling. The strategic
fix is offscreen rendering (already floated in plan_2 finding #1); pragmatic
per-issue mitigations are listed if offscreen is deferred.
#10 — MEDIUM: HiDPI doubles the output resolution
Status: IMPLEMENTED (interim mitigation). The frame loop now calls
frame.getSize()and, when the capturedNativeImagedoesn't match the requested canvas,frame.resize({width, height})back to the canvas dims beforetoPNG(). No-op on scale-factor-1 displays. The offscreen "deep fix" below was not taken. (Can't be reproduced on this scale-1 box; should be sanity-checked once on a HiDPI machine.)
capturePage(captureRect) takes a rect in DIPs but returns a NativeImage at
the display's device scale factor. On a 2× display a 1920×1080 canvas yields
a 3840×2160 PNG, so the MP4 is double the requested resolution and ~4× the size
— diverging from the server, which pins deviceScaleFactor: 1. Likely
unnoticed so far because the verify harness runs at scale factor 1.
Interim mitigation (normalize every frame to the requested size):
const frame = await raceAbort(renderWindow.webContents.capturePage(captureRect), signal)
const { width, height } = frame.getSize()
const png = (width === captureRect.width && height === captureRect.height
? frame
: frame.resize({ width: captureRect.width, height: captureRect.height })
).toPNG()
(Do not use app.commandLine.appendSwitch('force-device-scale-factor','1')
— it is global and would degrade the editor UI on HiDPI.)
#11 — MEDIUM: canvases larger than the display are truncated
Status: IMPLEMENTED (interim mitigation).
main.ctsnow compares the normalized canvas againstscreen.getPrimaryDisplay().sizebefore starting and throws a clear error ("Canvas WxH is larger than this display …") rather than silently truncating. This is desktop-only: the web/server path keeps using a headless viewport and still accepts up to 4096². The offscreen "deep fix" below (which would let the desktop path render oversize canvases too) was not taken.
A non-offscreen window can't be composited/captured beyond the display bounds, so a canvas larger than the screen (validation allows up to 4096²) produces a clipped frame. The server avoids this with a headless Chrome viewport.
Interim mitigation: in main.cts, before starting, compare the (normalized)
canvas against screen.getPrimaryDisplay().workAreaSize and fail fast with a
clear error rather than silently truncating; or document a desktop max-canvas
limit. (The real fix is offscreen.)
#12 — MEDIUM (perf): hidden-window rAF throttling ⇒ ~1 fps
Status: IMPLEMENTED.
backgroundThrottling: falseadded to the render window'swebPreferences. (#10 and #11 remain open; the offscreen-rendering "deep fix" below was not taken.)
__animatrSeek now resolves after a double requestAnimationFrame, with a
setTimeout(finish, 1000) anti-hang fallback. But the render window is
show: false with no backgroundThrottling: false, so Chromium throttles/
pauses rAF for the hidden document — the rAFs don't fire promptly and the 1 s
fallback becomes the per-frame path. A 10 s @ 30 fps render then takes ~5 min.
(The verify harness checks correctness, not throughput, so this slipped through.)
Fix (one line, large win):
webPreferences: {
...
backgroundThrottling: false, // keep rAF firing while the window is hidden
}
Deep fix (recommended) — offscreen rendering, subsumes #10/#11/#12
Switch the render window to webPreferences: { offscreen: true }:
- renders to an in-memory bitmap independent of the physical display (fixes #11 oversize) at a controllable scale (fixes #10 HiDPI),
- isn't background-throttled and pairs with
webContents.setFrameRate(...)(fixes #12), - capture via either the
paintevent /beginFrameSubscriptionorcapturePage(still pair with the existing rAF settle to avoid the off-by-one from plan_2 #2).
Cost/risk: changes the capture mechanism, so the entire test:render-desktop
suite (all 6 checks) must be re-run, plus new HiDPI + large-canvas checks, plus
a GPU-vs-software-rendering sanity pass on each platform. If offscreen lands,
#10/#11/#12 close together and the interim mitigations above are unnecessary.
Verification (either path)
- New
scripts/verify-resolution.cjs: render andffprobethe output; assert width/height exactly equal the canvas dims. (Won't reproduce #10 on a scale-factor-1 CI box; note that, and run it once on a HiDPI machine.) - New
scripts/verify-large-canvas.cjs: render a canvas larger than the test display; assert full-size, non-truncated frames. - New throughput assertion (extend
verify-export.cjs): time N frames and fail if per-frame wall-clock is near the 1 s fallback — guards the #12 regression.
#13 — LOW (optional): odd canvas dimensions fail yuv420p
Status: IMPLEMENTED (pad to even). Both ffmpeg spawns (desktop worker and standalone server) now carry
-vf pad=ceil(iw/2)*2:ceil(ih/2)*2, so an odd canvas encodes (e.g. 101×101 → 102×102) instead of crashing with "height not divisible by 2". Validation was left permissive (no reject). Verified by a throwaway harness rendering a 101×101 scene to a clean 102×102 H.264 MP4, and the server e2e test still passes.
Problem
libx264 with -pix_fmt yuv420p requires even width/height. Validation
allows any integer 1..4096, so an odd-dimension canvas (e.g. 101×101) makes
ffmpeg exit with a confusing "height not divisible by 2" error. This is shared
with the standalone server (pre-existing), but the new desktop path is a fresh
surface for it. On HiDPI it's accidentally masked because the 2× capture is
always even — fixing #10 (normalize to canvas size) re-exposes it.
Fix (pick one, apply to both desktop worker and server)
- Pad to even in the ffmpeg args (least surprising, adds ≤1 px):
'-vf', 'pad=ceil(iw/2)*2:ceil(ih/2)*2', or - Reject odd dimensions in
shared/renderValidation.cjswith a clear message.
Verification
Add an odd-dimension case (e.g. 101×101) to shared/renderValidation.test.cjs
and/or a desktop check asserting a clean result instead of an ffmpeg crash.
Out of scope / already correct (do not redo)
- Validation parity, the abort/watchdog plumbing, the cancel-before-jobId race,
ENOENT friendly message, packaging wiring, and the
__animatrSeekpaint settle (plan_2 #2) are all sound and unchanged by this plan. - Image assets are imported as
data:URLs, so the render window's*://*/*network block (plan_2 #5) does not drop them — no action needed.
Suggested doc bookkeeping
When these land, update 05_electron-rendering-integration-plan_2.md:
- correct the "Partial output file is removed on failure/cancel" note (#7),
- and
06_electron-rendering-manual-checks.mdwith the new verify scripts.