Add Wayland support for --screencast (xdg-desktop-portal + PipeWire + GStreamer)#484
Open
brycesub wants to merge 14 commits into
Open
Add Wayland support for --screencast (xdg-desktop-portal + PipeWire + GStreamer)#484brycesub wants to merge 14 commits into
brycesub wants to merge 14 commits into
Conversation
…it tests - Fix 1: guard _open_pipewire_remote fd<0 → raise PortalError - Fix 2: add return after each utils.terminate() in _flask_init Wayland block - Fix 3: remove dead media_type local in _flask_init - Fix 4: document post-fork bus acquisition in PortalScreenCastSession.__init__ - Fix 5: document intentional v1 non-close of _active_wayland_session - Fix 6: add tests/test_video.py with two _flask_init Wayland failure-branch tests Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01N378i519nMnpRJApCaJ2FF
…Streamer pipewiregrab is not in mainline ffmpeg (absent even from ffmpeg 8.1), so the Wayland path could never produce frames on a stock ffmpeg. Replace it with a gst-launch-1.0 pipeline (pipewiresrc + pulsesrc -> H.264/AAC -> fragmented mp4 on stdout) that consumes the same portal fd + node. The portal handshake and pass_fds plumbing are unchanged. Also move the capability check to the main process (wayland_screencast_preflight in cast_video) so a missing-dependency failure exits cleanly before casting, instead of dying in the forked streaming child while the main process casts to a dead stream (the RequestFailed traceback seen in the first live test). The portal handshake must stay in the child because multiprocessing uses forkserver on Python 3.14, so the PipeWire fd is only valid in the process that opens it. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01N378i519nMnpRJApCaJ2FF
…ariants
CreateSession crashed with KeyError: 0 because each portal call wrapped its
options as GLib.Variant("a{sv}", ...) and then embedded that Variant in the
outer tuple format ((a{sv}), (oa{sv}), (osa{sv})). PyGObject expects a native
dict there and tries to iterate the pre-built Variant as a dict. Pass plain
dicts (Variant values) and let the outer format build the a{sv}.
Extract the arg builders from open()'s closures into methods so they can be
unit-tested without a live D-Bus session; add regression tests asserting each
builds a valid GVariant of the right type.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01N378i519nMnpRJApCaJ2FF
…tibility The gst pipeline let videoconvert negotiate 4:4:4 (yuv444p, High 4:4:4 profile), which Chromecast cannot decode — the device showed a blue backdrop with a valid-but-undecodable stream. Pin the chroma to I420 (yuv420p), scale to the configured resolution (default 1080p, since the raw monitor may exceed what 1080p-class devices accept), and constrain the encoder to High profile — matching the Chromecast config the ffmpeg path used via -pix_fmt yuv420p. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01N378i519nMnpRJApCaJ2FF
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01N378i519nMnpRJApCaJ2FF
Importing mkchromecast.video pulled in screencast_wayland, which imported gi unconditionally. That broke macOS video, input-file, and X11 screencast paths at import time on systems without PyGObject, even though the portal code is unused there. Defer the gi import into _ensure_gi(), called only from PortalScreenCastSession.__init__ (the sole code that drives the portal handshake). Module-level helpers used by video.py's preflight (is_wayland_session, gstreamer_screencast_available) no longer require gi. A missing/broken gi now raises PortalError with an actionable install message instead of a raw ImportError at import time. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
play_media(autoplay=True) starts playback only once the device fetches the
stream and establishes a media session. That handshake is asynchronous, but
play_cast() slept a fixed 5 seconds and then unconditionally called
media_controller.play(). When the session was not active yet, play() raised
pychromecast RequestFailed ("Failed to execute play.") and the unhandled
exception killed the whole cast.
This is a pre-existing race (the sleep+play block dates to 2024-02 and lives
on master untouched by the Wayland branch). It surfaces reliably with the new
Wayland --screencast path, whose cold start (portal grant + GStreamer/x264
init + buffering) routinely exceeds 5 seconds; faster paths usually beat the
sleep and so rarely tripped it.
Replace the blind sleep with media_controller.block_until_active(timeout=30),
which returns as soon as the session is ready. Then only play() when a session
is actually active (and skip it if autoplay is already playing), printing an
actionable warning instead of crashing if no session forms in time. The fix is
generic and helps every cast path, not just Wayland.
Add tests/test_cast_play.py covering the three handshake outcomes: no session
(must not crash), active session (must play), already playing (must not
re-issue play).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The portal PipeWire fd is single-use, but _stream_video re-spawned the gst pipeline on every /stream GET reusing one baked-in fd. The Chromecast's reconnect spawned a second pipewiresrc that collided on the consumed fd and tore down the working stream (not-negotiated / play() timeout). The X11 x11grab path tolerated re-spawns because grabbing X is stateless; the portal fd is not. Split the portal handshake (open() -> node id) from fd acquisition (open_pipewire_fd()), and have the Flask video server build a fresh command + fd per request via a command_factory, closing the parent's fd copy once the child inherits it. Verified two concurrent consumers coexist with separate fds; native stream is BGRA system memory (no DMABuf involved). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Adds Wayland support for
--screencast(desktop video casting). Previously--screencastused ffmpeg'sx11grab, which captures nothing under a Wayland session. This adds a Wayland path that captures the desktop throughxdg-desktop-portal+ PipeWire and encodes with GStreamer, while leaving the X11 path completely unchanged.Detection is automatic via the session type — X11 sessions keep using the existing ffmpeg/
x11grabcommand (byte-for-byte identical), and Wayland sessions use the new portal + GStreamer path.How it works
XDG_SESSION_TYPE/WAYLAND_DISPLAY).org.freedesktop.portal.ScreenCastviaGio/GLib):CreateSession → SelectSources(monitor) → Start → OpenPipeWireRemote, yielding a PipeWire fd + node id. This shows the compositor's "choose what to share" picker.gst-launch-1.0pipeline (pipewiresrcvideo +pulsesrcaudio → H.264 High/4:2:0 + AAC → fragmented MP4 on stdout), which the existing Flask server relays to the Cast device. The PipeWire fd is inherited by the pipeline viapass_fds.Dependencies
The Wayland path requires GStreamer:
gst-launch-1.0plus thepipewiresrc,x264enc,h264parse,mp4mux,pulsesrc,avenc_aac, andaacparseelements (on Arch:gst-plugins-base/good/bad/ugly,gst-libav, and the PipeWire GStreamer plugin). The portal handshake adds no new Python dependency — it usesGio/GLibfrom the already-requiredPyGObject. X11 users are unaffected.Scope (v1)
--resolution(default 1080p) for Chromecast compatibility.multiprocessingusesforkserveron Python 3.14), so that check can't move earlier.Testing
Notes for reviewers
--screencastcommand is unchanged and covered by a regression test.--displayis documented as X11-only (ignored under Wayland, where the picker selects the monitor).🤖 Generated with Claude Code