Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 5 additions & 1 deletion bin/mkchromecast
Original file line number Diff line number Diff line change
Expand Up @@ -123,14 +123,18 @@ class CastProcess(object):
def cast_video(self):
"""This method launches video casting"""

import mkchromecast.video
# Fail fast (in this main process) if a Wayland screencast can't run,
# before creating sinks or attempting to cast.
mkchromecast.video.wayland_screencast_preflight(self.mkcc)

if self.mkcc.platform == 'Linux':
print('Creating Pulseaudio Sink...')
print(colors.warning('Open Pavucontrol and Select the '
'Mkchromecast Sink.'))
create_sink()

print(colors.important('Starting Video Cast Process...'))
import mkchromecast.video
mkchromecast.video.main()
self.cc.initialize_cast()
self.get_devices(self.mkcc.select_device)
Expand Down
11 changes: 11 additions & 0 deletions changelog.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,17 @@
- You can attach devices to a running streaming audio session
(experimental).
- Move from SoundFlower to BlackHole for macOS. Closes #289.
- `--screencast` now works under Wayland sessions. The desktop is captured
through `xdg-desktop-portal` + PipeWire and encoded with GStreamer
(H.264/AAC), instead of the X11-only `x11grab`. X11 sessions are
unchanged. The Wayland path requires GStreamer (`gst-launch-1.0` with the
`pipewiresrc`, `x264enc`, `h264parse`, `mp4mux`, `pulsesrc`, `avenc_aac`,
and `aacparse` elements).
- Fixed an intermittent crash at the start of a cast (`RequestFailed:
Failed to execute play.`). The cast handshake now waits for the device's
media session to become active instead of sleeping for a fixed 5 seconds
and unconditionally issuing `play()`, which failed when a slow cold start
(such as a Wayland screencast) had not established a session yet.

* mkchromecast (0.3.8.1) **2017/12/24**

Expand Down
5 changes: 3 additions & 2 deletions mkchromecast/_arg_parsing.py
Original file line number Diff line number Diff line change
Expand Up @@ -201,8 +201,9 @@ def raise_arg_type_error():
type=str,
default=os.environ.get("DISPLAY", ":0"),
help="""
Set the DISPLAY for screen captures. Defaults to current environment
value of DISPLAY or ':0' if DISPLAY is unset.
Set the DISPLAY for X11 screen captures. Defaults to current environment
value of DISPLAY or ':0' if DISPLAY is unset. Ignored under Wayland, where
the monitor is chosen via the desktop portal picker.
""",
)

Expand Down
21 changes: 17 additions & 4 deletions mkchromecast/cast.py
Original file line number Diff line number Diff line change
Expand Up @@ -326,17 +326,30 @@ def play_cast(self):
play_url, media_type, title=self.title, stream_type="LIVE",
)

if media_controller.is_active:
media_controller.play()
# play_media(autoplay=True) starts playback once the device fetches the
# stream and establishes a media session. That handshake is async and,
# for a cold start (e.g. a Wayland screencast: portal grant + GStreamer
# / x264 init + buffering), can take well over the few seconds we used
# to sleep blindly. Wait for the session to actually become active
# instead. Issuing play() before a session exists raises RequestFailed
# and previously crashed the whole cast even though the stream was fine.
media_controller.block_until_active(timeout=30.0)

print(" ")
print(colors.important("Cast media controller status"))
print(" ")
print(self.cast.status)
print(" ")

time.sleep(5.0)
media_controller.play()
if media_controller.status.player_is_playing:
# autoplay already started playback; nothing more to do.
pass
elif media_controller.is_active:
media_controller.play()
else:
print(colors.warning(
"The cast device did not establish a media session in time. "
"If playback does not start on its own, please retry the cast."))

if self.mkcc.hijack is True:
self.r = Thread(target=self.hijack_cc)
Expand Down
55 changes: 55 additions & 0 deletions mkchromecast/pipeline_builder.py
Original file line number Diff line number Diff line change
Expand Up @@ -221,6 +221,7 @@ class VideoSettings:
user_command: Optional[str] # TODO(xsdg): check type.
vcodec: str
youtube_url: Optional[str]
wayland_capture: Optional[tuple[int, int]] = None


class Video:
Expand Down Expand Up @@ -255,6 +256,13 @@ def command(self) -> SubprocessCommand:
f"{self._settings.operation}")

def _screencast_command(self) -> list[str]:
# Wayland can't be grabbed with x11grab; capture via the portal +
# PipeWire using a GStreamer pipeline instead. The X11 path is unchanged.
if self._settings.wayland_capture is not None:
return self._wayland_screencast_command()
return self._x11_screencast_command()

def _x11_screencast_command(self) -> list[str]:
screen_size = resolution.resolution(
self._settings.resolution or "1080p",
self._settings.screencast
Expand Down Expand Up @@ -293,6 +301,53 @@ def _screencast_command(self) -> list[str]:
"pipe:1",
]

def _wayland_screencast_command(self) -> list[str]:
"""A gst-launch pipeline: portal/PipeWire video + pulse audio → mp4.

Reads the PipeWire stream (whose remote fd is inherited by this process
and named via `fd=`) and the PulseAudio monitor sink, encodes H.264/AAC,
and muxes a fragmented MP4 to stdout (fd 1) for the Flask server to relay.
"""
fd, node = self._settings.wayland_capture
fps = str(self._settings.fps)
key_int_max = str(int(fps) * 2) if fps.isdigit() else "60"

# Chromecast needs H.264 High profile, 4:2:0 (yuv420p / I420), at a
# supported resolution. videoconvert otherwise negotiates 4:4:4 (which
# the device can't decode), and the raw monitor may be >1080p, so pin
# the chroma to I420, scale to the configured size (default 1080p), and
# constrain the encoder to High profile.
screen_size = resolution.resolution(
self._settings.resolution or "1080p",
self._settings.screencast
)
width, height = screen_size.split("x")

return [
"gst-launch-1.0", "-q",
"pipewiresrc", f"fd={fd}", f"path={node}", "do-timestamp=true",
"!", "videoconvert",
"!", "videoscale",
"!", "videorate",
"!", (f"video/x-raw,format=I420,width={width},height={height},"
f"framerate={fps}/1"),
"!", "x264enc", "tune=zerolatency", "speed-preset=veryfast",
"bitrate=8000", f"key-int-max={key_int_max}",
"!", "video/x-h264,profile=high",
"!", "h264parse",
"!", "queue",
"!", "mp4mux", "name=mux", "fragment-duration=1000",
"streamable=true",
"!", "fdsink", "fd=1",
"pulsesrc", "device=Mkchromecast.monitor",
"!", "audioconvert",
"!", "audioresample",
"!", "avenc_aac",
"!", "aacparse",
"!", "queue",
"!", "mux.",
]

@staticmethod
def _input_file_subtitle(
subtitles: Optional[str],
Expand Down
Loading