Skip to content

fix: recover preview from WebGL context loss on Linux/Wayland#19

Merged
EtienneLescot merged 4 commits into
mainfrom
codex/fix-export-preview-webgl-context-loss
Jul 15, 2026
Merged

fix: recover preview from WebGL context loss on Linux/Wayland#19
EtienneLescot merged 4 commits into
mainfrom
codex/fix-export-preview-webgl-context-loss

Conversation

@EtienneLescot

@EtienneLescot EtienneLescot commented Jun 22, 2026

Copy link
Copy Markdown
Collaborator

Summary

  • Attaches a webglcontextlost listener to the preview Pixi canvas in VideoPlayback.tsx.
  • On loss, the listener calls event.preventDefault() (to opt in to Chromium's restoration attempt) and bumps a pixiGeneration state so the existing useEffect cleanup tears the broken app down. The next mount rebuilds it from scratch with a fresh WebGL context.
  • The wallpaper (rendered to a 2D canvas in frameRenderer.ts:212) survives the context loss, so this fix makes the "only background remains" symptom in issue [Bug]: Video disappears from editor after exporting (only background remains) #8 self-healing.
  • Extracted the handler into createWebGLContextLostHandler for unit testability and added VideoPlayback.test.ts with three focused tests (preventDefault is called, regenerate fires, generation is logged).
  • Logged both webglcontextlost and webglcontextrestored events so the recovery is visible in the terminal — combined with the console-message forwarding from PR [codex] Add export preview diagnostics #11, this gives a complete trace of any future loss/restoration on Linux.

Why

The bug only reproduces on Manjaro / CachyOS (Arch Linux + KDE Wayland, Mesa/EGL) and only after MP4 export — VideoEncoder uses the prefer-hardware path on Linux (videoExporter.ts:681), and the FrameRenderer also takes a Linux-specific CPU-readback branch (videoExporter.ts:397 and frameRenderer.ts:914).

grep webglcontextlost against the codebase before this PR returned 0 hits: the preview never registered a recovery listener, so once the GL context died the video sprite stayed gone. The wallpaper kept rendering because frameRenderer.ts:212 deliberately composites it on a plain 2D canvas outside of PixiJS, which is why the symptom is "wallpaper stays, video disappears" instead of a fully black preview.

Trade-offs

  • I deliberately did not force the encoder to prefer-software on Linux. Software H.264 is 3–10× slower on this workload and would have been a real UX regression for Manjaro users for a problem we had not yet confirmed. This fix keeps the encoder preferences untouched and makes the bug self-heal at the renderer instead.
  • I also did not change the encoder to fall back to software only after a context-loss event was seen — that would be a follow-up if the context-loss hypothesis turns out to be wrong, once PR [codex] Add export preview diagnostics #11's improved diagnostics confirm the root cause from cfinnberg's machine.

Test plan

  • npx tsc --noEmit — clean
  • npm run lint — clean
  • npm test — 244 passed (3 new in VideoPlayback.test.ts)
  • npm run build-vite — clean
  • Manual smoke (deferred): reproducing the bug requires real Manjaro KDE hardware, which I cannot do from a worktree. The handler is unit-tested for the two invariants the bug hinges on, and a real context-loss event will trigger the same code path the tests cover.

Follow-ups

  • Once PR [codex] Add export preview diagnostics #11 ships and we have a real trace from cfinnberg, confirm the WebGL context loss hypothesis and decide whether to also flip the Linux encoder preference to ["prefer-software", "prefer-hardware"] (the same order Windows uses at videoExporter.ts:679).
  • Optionally: add a webglcontextlost listener to the FrameRenderer's Pixi canvas in frameRenderer.ts as well. It only matters during export, where a lost context currently shows as a single green/empty frame, so it is much lower priority.

Related

Summary by CodeRabbit

Bug Fixes

  • Video preview now automatically recovers when WebGL context is lost, enhancing application stability and reliability while ensuring uninterrupted, seamless editing experience.

Tests

  • Added comprehensive test coverage for WebGL context loss handling and recovery, including validation of error management and diagnostic logging.

On Manjaro / CachyOS (Linux/Wayland with Mesa/EGL) the preview Pixi app
can lose its WebGL context during heavy GPU usage from the exporter —
most likely the `VideoEncoder`'s `prefer-hardware` path or the
Linux-only CPU readback in `frameRenderer.ts.readbackVideoCanvas`.
The wallpaper is rendered to a 2D canvas (`frameRenderer.ts:212`,
"Background renders separately, not in PixiJS") and survives the
context loss, but the video sprite lives inside the lost Pixi GL
context and never reappears. That is the "only background remains"
symptom in issue #8.

Fix: attach a `webglcontextlost` listener to the preview Pixi canvas.
`preventDefault()` opts in to Chromium's restoration attempt, and
bumping a `pixiGeneration` state tears the broken app down via the
existing `useEffect` cleanup so the next mount rebuilds it from
scratch with a fresh WebGL context. The recovery is logged for
diagnostics ("[VideoPlayback] WebGL context lost, recreating Pixi
app"), which will now reach the terminal thanks to the
console-message forwarding added to `electron/main.ts` in PR #11.

Extracted the handler into `createWebGLContextLostHandler` for unit
testability and added a small focused test that covers the two
invariants the bug hinges on: the event's `preventDefault` is called
and the regenerate callback fires.

Tested:
  - npx tsc --noEmit
  - npm run lint
  - npm test (244 passed, +3 new)
  - npm run build-vite
@chatgpt-codex-connector

Copy link
Copy Markdown

You have reached your Codex usage limits for code reviews. You can see your limits in the Codex usage dashboard.

@coderabbitai

coderabbitai Bot commented Jun 22, 2026

Copy link
Copy Markdown

Review Change Stack

Warning

Review limit reached

@EtienneLescot, you've reached your PR review limit, so we couldn't start this review.

Next review available in: 24 minutes

Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available.
You're only billed for reviews past your plan's rate limits ($0.25/file).

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews.

How do review limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: e615dadd-b1a6-4bfd-8ba3-90f33835d9f3

📥 Commits

Reviewing files that changed from the base of the PR and between 5b67fd7 and 6458cc9.

📒 Files selected for processing (2)
  • src/components/video-editor/VideoPlayback.test.ts
  • src/components/video-editor/VideoPlayback.tsx
📝 Walkthrough

Walkthrough

VideoPlayback.tsx gains WebGL context loss recovery: a new exported createWebGLContextLostHandler helper, a pixiGeneration state counter, webglcontextlost/webglcontextrestored listener registration on the Pixi canvas, a try/catch around app.init, and cleanup/dependency-array changes to trigger Pixi rebuild. A Vitest suite validates the new helper.

Changes

WebGL Context Loss Recovery

Layer / File(s) Summary
createWebGLContextLostHandler helper and pixiGeneration state
src/components/video-editor/VideoPlayback.tsx
Exports createWebGLContextLostHandler which calls event.preventDefault(), logs context loss with a captured generation value, and invokes a regenerate callback. Adds pixiGeneration React state to version Pixi canvas instances for teardown/rebuild tracking.
Pixi effect wiring: init guard, listener registration, cleanup, and dependency
src/components/video-editor/VideoPlayback.tsx
Declares handleContextLost/handleContextRestored variables for cleanup scope, wraps app.init in try/catch with early return on failure, attaches both context event listeners to app.canvas, extends cleanup to remove listeners before Pixi teardown, and changes the effect dependency array from [] to [pixiGeneration] so Pixi reinitializes after each context loss.
Vitest tests for createWebGLContextLostHandler
src/components/video-editor/VideoPlayback.test.ts
Adds three test cases verifying event.preventDefault() is called, the regenerate callback is invoked, and console.warn logs the construction-time generation value.

Estimated code review effort

🎯 2 (Simple) | ⏱️ ~10 minutes

Poem

🐇 When WebGL goes dark and the canvas turns gray,
A handler hops in and saves the day!
preventDefault, log, then regenerate—
A new generation spawns, never too late.
With tests to confirm every bounce and call,
This rabbit-built fix will handle it all! 🎉

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Title check ✅ Passed The title accurately and concisely summarizes the main change: implementing recovery for WebGL context loss on Linux/Wayland systems that affects the video preview.
Description check ✅ Passed The description is comprehensive and well-structured with all key sections addressed including summary, related issue, type of change, release impact, desktop impact, testing, and follow-ups.
Linked Issues check ✅ Passed The PR fully addresses the requirement in issue #8 by implementing WebGL context loss recovery to restore video visibility in the preview after export on Linux/Wayland systems.
Out of Scope Changes check ✅ Passed All changes are directly scoped to fixing the WebGL context loss issue: test file for the handler, handler implementation in VideoPlayback, and listener registration within the Pixi initialization effect.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch codex/fix-export-preview-webgl-context-loss

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

EtienneLescot added a commit that referenced this pull request Jun 24, 2026
- Add ROADMAP.md at repo root for top-level discoverability.
- Stability queue pulled from open issues / PRs on getopenscreen/openscreen (#8, #21, #22, #19, #18, #24).
- AI direction framed as opt-in / off by default, with per-feature tags.
- Extend .github/workflows/discord.yaml with a roadmap-notify job that posts an embed to the #🗺️・roadmap Discord channel whenever a PR changes ROADMAP.md (or docs/roadmap.md). Mirrors the existing PR -> #pr-reviews sync pattern. Activated by setting the DISCORD_ROADMAP_WEBHOOK_URL repo secret; silently skipped otherwise so CI never breaks.
EtienneLescot added a commit that referenced this pull request Jun 24, 2026
- Add ROADMAP.md at repo root for top-level discoverability.
- Stability queue pulled from open issues / PRs on getopenscreen/openscreen (#8, #21, #22, #19, #18, #24).
- AI direction framed as opt-in / off by default, with per-feature tags.
- Link the new roadmap from the README Community section.
- Extend .github/workflows/discord.yaml with a roadmap-notify job that posts an embed to the #🗺️・roadmap Discord channel whenever a PR changes ROADMAP.md (or docs/roadmap.md). Mirrors the existing PR -> #pr-reviews sync pattern. Activated by setting the DISCORD_ROADMAP_WEBHOOK_URL repo secret; silently skipped otherwise so CI never breaks.
EtienneLescot added a commit that referenced this pull request Jun 24, 2026
- Add ROADMAP.md at repo root for top-level discoverability.
- Stability queue pulled from open issues / PRs on getopenscreen/openscreen (#8, #21, #22, #19, #18, #24).
- AI direction framed as opt-in / off by default, with per-feature tags.
- Link the new roadmap from the README Community section.
- Extend .github/workflows/discord.yaml with a roadmap-notify job that posts an embed to the #🗺️・roadmap Discord channel whenever a PR changes ROADMAP.md (or docs/roadmap.md). Mirrors the existing PR -> #pr-reviews sync pattern. Activated by setting the DISCORD_ROADMAP_WEBHOOK_URL repo secret; silently skipped otherwise so CI never breaks.
EtienneLescot added a commit that referenced this pull request Jul 1, 2026
Bugs from the spine test pass:

#4 - clips display as 60s instead of the real video length. Root
cause: insertClipAt falls back to PLACEHOLDER_DURATION_SEC (60s) when
asset.durationSec is undefined, and the loadedmetadata auto-correct
(handleLoadedMetadata) didn't fire reliably (in the browser-shim the
preview path is unreliable; in the real Electron window the preview
<video> remounts only when the activeSource swaps, so a freshly-added
asset's metadata never reaches the auto-correct). Fix: probe the file
directly via a throwaway <video> (src/lib/ai-edition/timeline/duration.ts,
5 unit tests) and insert the clip at the real duration synchronously.
Persist the probed duration back onto the asset so subsequent inserts
don't re-probe. Falls back to PLACEHOLDER on probe failure.

#17 - dragging a skip's left/right chevron appeared to move the whole
skip instead of just one edge. Root cause: clipSegments() was called
once with the source skipRanges, then the cut segment was patched with
dragPreview.startSec/endSec - but the surrounding keep segments
weren't regenerated, so their flex widths didn't shrink to make room
and the cut's visual position drifted. Fix: build a virtual skipRanges
with the dragPreview'd bounds and re-run clipSegments() end-to-end so
keep+cut stay consistent.

#18 - clicking the trash button on a skip didn't remove it. Root cause:
startClipReorder calls setPointerCapture on the clip block, so the
subsequent pointerup is captured on the block (not the trash button)
and the click event fires on the block (not the button). Fix: stop
pointerdown propagation on the trash button so the clip block never
captures the pointer.

#5/#19 - clips were overlapping by 1px (the join-border overlap from
T09). User: this is NOT wanted for clips - only for skip strips
inside a clip. Fix: remove the JS left/width shift on hasJoinedPrev.
CSS .joinedPrev/.joinedNext still zero the adjacent border-radius +
border-width so corners blend.

#6 - the reorder marker wasn't visible. Root cause: z-index 11, but
the moving clip has z-index 20, and the marker's soft box-shadow was
only 8px so it disappeared against dark backgrounds. Fix: bump width
to 4px, z-index to 30 (above the moving clip), and stronger box-shadow.

Verified: tsc clean, biome clean, 412 tests pass.
EtienneLescot added a commit that referenced this pull request Jul 1, 2026
…he correct boundary

Two follow-up bugs from the spine test pass:

#5/#19 — clips were STILL merging visually when adjacent. I had
already removed the JS left/width shift on hasJoinedPrev in f9d62e1,
but the .joinedPrev / .joinedNext CSS classes were still in the
rendered classList, and the CSS for them was still zeroing the
border-radius + border-width on the joined side. User feedback
(round 2): there is NO reason to merge clips visually, ever —
clips keep their own border + border-radius on every side, even
when adjacent (and even more so when there are gaps between them,
which the user pointed out can happen). Dropped the hasJoinedPrev /
hasJoinedNext calculations, the .joinedPrev / .joinedNext classList
additions, the CLIP_JOIN_THRESHOLD_PX constant, and the CSS rules.
Only the keep-vs-cut segment rendering (which IS the right place to
overlap freely) keeps its proportional layout.

#6 — the reorder marker was at the wrong boundary. My port kept
axcut's exact formula: boundarySec = remainingClips[insertIndex].
timelineStartSec. In axcut that's correct because clips can have
gaps, so the moving clip's new START = the next non-moving clip's
original START. In our model clips are resequenced to be contiguous,
so the moving clip's new START = the END of the preceding clip (not
the START of the next). With clips [A(0-10), B(15-25), C(30-40)]
and B moving to slot 1, axcut puts the marker at 30 (C.start);
ours should put it at 10 (A.end == new B.start). Fix: use
remainingClips[insertIndex-1].timelineEndSec as the boundary. Also
simplifies — insertIndex >= remaining.length no longer needs a
special-case since remaining.length-1's timelineEndSec equals
virtualDurationSec.

Verified: tsc clean, biome clean, 412 tests pass.
EtienneLescot added a commit that referenced this pull request Jul 2, 2026
Bugs from the spine test pass:

#4 - clips display as 60s instead of the real video length. Root
cause: insertClipAt falls back to PLACEHOLDER_DURATION_SEC (60s) when
asset.durationSec is undefined, and the loadedmetadata auto-correct
(handleLoadedMetadata) didn't fire reliably (in the browser-shim the
preview path is unreliable; in the real Electron window the preview
<video> remounts only when the activeSource swaps, so a freshly-added
asset's metadata never reaches the auto-correct). Fix: probe the file
directly via a throwaway <video> (src/lib/ai-edition/timeline/duration.ts,
5 unit tests) and insert the clip at the real duration synchronously.
Persist the probed duration back onto the asset so subsequent inserts
don't re-probe. Falls back to PLACEHOLDER on probe failure.

#17 - dragging a skip's left/right chevron appeared to move the whole
skip instead of just one edge. Root cause: clipSegments() was called
once with the source skipRanges, then the cut segment was patched with
dragPreview.startSec/endSec - but the surrounding keep segments
weren't regenerated, so their flex widths didn't shrink to make room
and the cut's visual position drifted. Fix: build a virtual skipRanges
with the dragPreview'd bounds and re-run clipSegments() end-to-end so
keep+cut stay consistent.

#18 - clicking the trash button on a skip didn't remove it. Root cause:
startClipReorder calls setPointerCapture on the clip block, so the
subsequent pointerup is captured on the block (not the trash button)
and the click event fires on the block (not the button). Fix: stop
pointerdown propagation on the trash button so the clip block never
captures the pointer.

#5/#19 - clips were overlapping by 1px (the join-border overlap from
T09). User: this is NOT wanted for clips - only for skip strips
inside a clip. Fix: remove the JS left/width shift on hasJoinedPrev.
CSS .joinedPrev/.joinedNext still zero the adjacent border-radius +
border-width so corners blend.

#6 - the reorder marker wasn't visible. Root cause: z-index 11, but
the moving clip has z-index 20, and the marker's soft box-shadow was
only 8px so it disappeared against dark backgrounds. Fix: bump width
to 4px, z-index to 30 (above the moving clip), and stronger box-shadow.

Verified: tsc clean, biome clean, 412 tests pass.
EtienneLescot added a commit that referenced this pull request Jul 2, 2026
…he correct boundary

Two follow-up bugs from the spine test pass:

#5/#19 — clips were STILL merging visually when adjacent. I had
already removed the JS left/width shift on hasJoinedPrev in f9d62e1,
but the .joinedPrev / .joinedNext CSS classes were still in the
rendered classList, and the CSS for them was still zeroing the
border-radius + border-width on the joined side. User feedback
(round 2): there is NO reason to merge clips visually, ever —
clips keep their own border + border-radius on every side, even
when adjacent (and even more so when there are gaps between them,
which the user pointed out can happen). Dropped the hasJoinedPrev /
hasJoinedNext calculations, the .joinedPrev / .joinedNext classList
additions, the CLIP_JOIN_THRESHOLD_PX constant, and the CSS rules.
Only the keep-vs-cut segment rendering (which IS the right place to
overlap freely) keeps its proportional layout.

#6 — the reorder marker was at the wrong boundary. My port kept
axcut's exact formula: boundarySec = remainingClips[insertIndex].
timelineStartSec. In axcut that's correct because clips can have
gaps, so the moving clip's new START = the next non-moving clip's
original START. In our model clips are resequenced to be contiguous,
so the moving clip's new START = the END of the preceding clip (not
the START of the next). With clips [A(0-10), B(15-25), C(30-40)]
and B moving to slot 1, axcut puts the marker at 30 (C.start);
ours should put it at 10 (A.end == new B.start). Fix: use
remainingClips[insertIndex-1].timelineEndSec as the boundary. Also
simplifies — insertIndex >= remaining.length no longer needs a
special-case since remaining.length-1's timelineEndSec equals
virtualDurationSec.

Verified: tsc clean, biome clean, 412 tests pass.
EtienneLescot added a commit to gede-cahya/openscreen that referenced this pull request Jul 15, 2026
Adds an in-app File/Edit/View menu bar to the editor titlebar and
auto-hides the native OS menu bar on Windows/Linux (electron/windows.ts),
for a consistent custom-styled menu across platforms.

Maintainer cleanup on top of the original PR getopenscreen#87:
- Dropped the WebGL context-loss fix that was duplicated from getopenscreen#19 (this
  branch had merged getopenscreen#19's branch in); that fix now lands solely via getopenscreen#19.
- Dropped the unrelated package.json `allowScripts` block and the
  @electron/windows-sign / postject / cross-dirname lockfile additions.
- Extracted the menu into a testable EditorMenuBar component built from a
  pure model, with unit + interaction tests (EditorMenuBar.test.tsx).
- Removed the unnecessary `as never` i18n casts and dead `|| "…"`
  fallbacks; the keys already exist in common/dialogs, so rawT() is
  called directly (matches the existing showInFolder usage).
- Made shortcut hints platform-aware (⌘ on macOS, Ctrl elsewhere),
  matching the accelerators wired in the native menu (electron/main.ts)
  and the editor keydown handler.
- Routed Quit through a new `app-quit` IPC (app.quit()) instead of
  window.close(), matching the native menu's role:"quit".

Co-authored-by: Gede Cahya <49280905+gede-cahya@users.noreply.github.com>
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@EtienneLescot

Copy link
Copy Markdown
Collaborator Author

This is the canonical fix for the preview WebGL context-loss recovery. #87 had merged this branch in and shipped the same VideoPlayback.tsx + VideoPlayback.test.ts changes verbatim; I've now trimmed #87 down to just its custom menu-bar work, so this PR solely owns the context-loss recovery + tests. No changes needed here to deconflict — the duplication lives only on the #87 side and has been removed.

@EtienneLescot EtienneLescot merged commit 1fd3185 into main Jul 15, 2026
10 checks passed
@EtienneLescot EtienneLescot deleted the codex/fix-export-preview-webgl-context-loss branch July 15, 2026 08:33
EtienneLescot added a commit that referenced this pull request Jul 15, 2026
Adds an in-app File/Edit/View menu bar to the editor titlebar and
auto-hides the native OS menu bar on Windows/Linux (electron/windows.ts),
for a consistent custom-styled menu across platforms.

Maintainer cleanup on top of the original PR #87:
- Dropped the WebGL context-loss fix that was duplicated from #19 (this
  branch had merged #19's branch in); that fix now lands solely via #19.
- Dropped the unrelated package.json `allowScripts` block and the
  @electron/windows-sign / postject / cross-dirname lockfile additions.
- Extracted the menu into a testable EditorMenuBar component built from a
  pure model, with unit + interaction tests (EditorMenuBar.test.tsx).
- Removed the unnecessary `as never` i18n casts and dead `|| "…"`
  fallbacks; the keys already exist in common/dialogs, so rawT() is
  called directly (matches the existing showInFolder usage).
- Made shortcut hints platform-aware (⌘ on macOS, Ctrl elsewhere),
  matching the accelerators wired in the native menu (electron/main.ts)
  and the editor keydown handler.
- Routed Quit through a new `app-quit` IPC (app.quit()) instead of
  window.close(), matching the native menu's role:"quit".

Co-authored-by: Gede Cahya <49280905+gede-cahya@users.noreply.github.com>
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[Bug]: Video disappears from editor after exporting (only background remains)

1 participant