Skip to content

fix: resolve green MP4 exports on CachyOS/Arch Linux (Wayland)#330

Open
maxbailey wants to merge 1 commit intosiddharthvaddem:mainfrom
maxbailey:main
Open

fix: resolve green MP4 exports on CachyOS/Arch Linux (Wayland)#330
maxbailey wants to merge 1 commit intosiddharthvaddem:mainfrom
maxbailey:main

Conversation

@maxbailey
Copy link
Copy Markdown

@maxbailey maxbailey commented Apr 5, 2026

Description

On Linux/Wayland (specifically CachyOS/Arch with KDE), exported MP4 videos are solid green despite audio working correctly. This fix uses explicit gl.readPixels() to copy pixel data from the WebGL framebuffer to CPU memory, bypassing the broken shared-image path entirely.

Motivation

PR #281 resolved green MP4 exports on Ubuntu by reading raw pixels at the VideoFrame construction step. However, on CachyOS/Arch Linux with Wayland/KDE, the corruption occurs one layer deeper in FrameRenderer.compositeWithShadows(), where ctx.drawImage(webglCanvas, ...) copies the PixiJS WebGL canvas onto the 2D composite canvas. By the time the VideoFrame fix reads pixels, the composite canvas already contains corrupted data.

This PR fixes the compositing layer by reading back the WebGL framebuffer directly via gl.readPixels(), which always performs an explicit GPU-to-CPU copy.

Type of Change

  • New Feature
  • Bug Fix
  • Refactor / Code Cleanup
  • Documentation Update
  • Other (please specify)

Related Issue(s)

#264

Checklist

  • I have performed a self-review of my code.
  • I have added any necessary screenshots or videos.
  • I have linked related issue(s) and updated the changelog if applicable.

Summary by CodeRabbit

  • Bug Fixes
    • Improved rendering stability and cross-platform compatibility for shadow compositing operations.

On Linux/Wayland the implicit GPU-to-2D texture-sharing path used by
drawImage(webglCanvas) fails silently (EGL/Ozone), producing green
frames. Use explicit gl.readPixels to copy from GPU to CPU memory,
bypassing that path.
@coderabbitai
Copy link
Copy Markdown

coderabbitai bot commented Apr 5, 2026

📝 Walkthrough

Walkthrough

FrameRenderer adds GPU-to-CPU pixel readback functionality. An offscreen raster canvas and 2D context are initialized and cleaned up in destroy(). A new private readbackVideoCanvas() method reads WebGL pixels via gl.readPixels, applies vertical flipping, and writes the buffer to the raster canvas. compositeWithShadows() is modified to use this readback mechanism instead of directly accessing the WebGL canvas.

Changes

Cohort / File(s) Summary
GPU Pixel Readback
src/lib/exporter/frameRenderer.ts
Added raster canvas initialization and cleanup; introduced readbackVideoCanvas() private method for GPU→CPU pixel transfer with buffer flipping; updated compositeWithShadows() to use readback path instead of direct canvas access.

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~20 minutes

Suggested reviewers

  • siddharthvaddem

Poem

🐰 A canvas twice now takes the stage,
GPU whispers to the page,
Pixels flip in rhythm true,
From WebGL's glow to raster's view—
The renderer's dance, renewed! ✨

🚥 Pre-merge checks | ✅ 3
✅ Passed checks (3 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly and specifically identifies the fix as resolving green MP4 exports on CachyOS/Arch Linux with Wayland, which matches the main objective of the changeset.
Description check ✅ Passed The description covers all required sections: purpose, motivation, type of change (Bug Fix selected), related issues (#264 linked), and relevant checklist items completed.
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

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 and usage tips.

Copy link
Copy Markdown

@chatgpt-codex-connector chatgpt-codex-connector bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 3b5ad5064e

ℹ️ About Codex in GitHub

Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".

if (!this.compositeCanvas || !this.compositeCtx || !this.app) return;

const videoCanvas = this.app.canvas as HTMLCanvasElement;
const videoCanvas = this.readbackVideoCanvas();
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Gate readback fallback to affected environments

compositeWithShadows now always uses readbackVideoCanvas(), which in turn calls gl.readPixels(...) on every frame when WebGL is available. That introduces a synchronous GPU→CPU transfer for all exports, not just Linux/Wayland, and can heavily degrade throughput on common paths (especially high-resolution exports). Since this commit is a platform-specific workaround, the readback path should be conditional/opt-in instead of unconditional.

Useful? React with 👍 / 👎.


const w = glCanvas.width;
const h = glCanvas.height;
const buf = new Uint8Array(w * h * 4);
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Reuse frame buffer memory across render calls

This allocates a fresh Uint8Array sized w*h*4 for every frame, which creates very large transient allocations during export (about 32MB per frame at 4K). Over long videos this causes substantial GC pressure and memory spikes, slowing or destabilizing exports. Keep a persistent pixel buffer (and related ImageData) keyed by dimensions to avoid per-frame heap churn.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@src/lib/exporter/frameRenderer.ts`:
- Around line 704-719: The current path in compositeWithShadows()
unconditionally calls readbackVideoCanvas() which always does a gl.readPixels()
into a freshly allocated Uint8Array and temp buffer then flips rows and
putImageData, causing synchronous stalls and per-frame allocations; change this
so the default path uses the existing drawImage() branch and only uses the
readbackVideoCanvas() code when a platform/workaround flag (detect affected
Wayland/Linux cases) is set, and when readback is enabled reuse buffers across
frames by promoting buf and the row temp buffer to reusable properties (cache a
Uint8Array for pixel data and a row-sized temp Uint8Array) instead of allocating
them inside readbackVideoCanvas(), and avoid repeated ImageData allocations by
reusing a single ImageData / Uint8ClampedArray view where possible before
calling this.rasterCtx.putImageData; keep references to compositeWithShadows()
and readbackVideoCanvas() to locate the change.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 7e8c45f0-1a34-4572-83fa-b2fb6c7b55f8

📥 Commits

Reviewing files that changed from the base of the PR and between 21893f0 and 3b5ad50.

⛔ Files ignored due to path filters (1)
  • package-lock.json is excluded by !**/package-lock.json
📒 Files selected for processing (1)
  • src/lib/exporter/frameRenderer.ts

Comment on lines +704 to +719
const buf = new Uint8Array(w * h * 4);
gl.readPixels(0, 0, w, h, gl.RGBA, gl.UNSIGNED_BYTE, buf);

// readPixels returns rows bottom-to-top; flip vertically
const rowSize = w * 4;
const temp = new Uint8Array(rowSize);
for (let top = 0, bot = h - 1; top < bot; top++, bot--) {
const tOff = top * rowSize;
const bOff = bot * rowSize;
temp.set(buf.subarray(tOff, tOff + rowSize));
buf.copyWithin(tOff, bOff, bOff + rowSize);
buf.set(temp, bOff);
}

const imageData = new ImageData(new Uint8ClampedArray(buf.buffer), w, h);
this.rasterCtx.putImageData(imageData, 0, 0);
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

sed -n '700,750p' src/lib/exporter/frameRenderer.ts

Repository: siddharthvaddem/openscreen

Length of output: 1611


🏁 Script executed:

rg -n "readbackVideoCanvas" src/lib/exporter/frameRenderer.ts

Repository: siddharthvaddem/openscreen

Length of output: 179


🏁 Script executed:

rg -n "drawImage" src/lib/exporter/frameRenderer.ts -B 2 -A 2

Repository: siddharthvaddem/openscreen

Length of output: 1359


🏁 Script executed:

rg -n "compositeWithShadows" src/lib/exporter/frameRenderer.ts -B 3 -A 3

Repository: siddharthvaddem/openscreen

Length of output: 542


🏁 Script executed:

sed -n '680,800p' src/lib/exporter/frameRenderer.ts

Repository: siddharthvaddem/openscreen

Length of output: 4173


Don't make CPU readback the default export path.

compositeWithShadows() is called unconditionally on every frame (line 410), and within it line 727 unconditionally routes through readbackVideoCanvas(), which allocates and flips a full RGBA buffer on each call. This turns a Linux/Wayland workaround (documented at lines 688–691) into the global path, introducing a synchronous gl.readPixels() stall plus ~8 MiB/frame at 1080p and ~33 MiB/frame at 4K, with no buffer reuse. Keep the old drawImage() path as the default and only enable readback for affected environments, reusing buffers once that path is active.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/lib/exporter/frameRenderer.ts` around lines 704 - 719, The current path
in compositeWithShadows() unconditionally calls readbackVideoCanvas() which
always does a gl.readPixels() into a freshly allocated Uint8Array and temp
buffer then flips rows and putImageData, causing synchronous stalls and
per-frame allocations; change this so the default path uses the existing
drawImage() branch and only uses the readbackVideoCanvas() code when a
platform/workaround flag (detect affected Wayland/Linux cases) is set, and when
readback is enabled reuse buffers across frames by promoting buf and the row
temp buffer to reusable properties (cache a Uint8Array for pixel data and a
row-sized temp Uint8Array) instead of allocating them inside
readbackVideoCanvas(), and avoid repeated ImageData allocations by reusing a
single ImageData / Uint8ClampedArray view where possible before calling
this.rasterCtx.putImageData; keep references to compositeWithShadows() and
readbackVideoCanvas() to locate the change.

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.

1 participant