diff --git a/breadcast-core/src/caststream.rs b/breadcast-core/src/caststream.rs index 4b778d8..433efa1 100644 --- a/breadcast-core/src/caststream.rs +++ b/breadcast-core/src/caststream.rs @@ -48,18 +48,26 @@ pub struct VideoParams { impl Default for VideoParams { fn default() -> Self { Self { - // Must match what `build_video_pipeline_for_streaming` actually - // encodes (`breadcast-core/src/pipeline/mod.rs`), not just what - // we'd like to send -- this OFFER's resolution is what the - // receiver allocates its decoder/output surface for. Advertising - // a resolution other than what's actually sent is a real - // protocol mismatch that plausibly explains a receiver decoder - // corrupting/freezing rather than just looking soft. - // Reverted from a brief 1920x1080 experiment -- see - // `build_video_pipeline_for_streaming`'s doc comment for why - // (the freeze wasn't a resolution/bandwidth problem at all). - width: 1280, - height: 720, + // NOT the value that goes on the wire. `build_video_pipeline_for_streaming` + // returns the geometry it will really encode, and both + // `breadcastd::cast_mirror` and `cast_stream_test` pass *that* + // to `CastStreamSender::start` -- this default only supplies the + // fields that don't vary with the capture path (bitrate, frame + // rate denominator). + // + // It has to work that way because the pipeline picks its capture + // backend at runtime, and the two backends differ in resolution + // and frame rate. This OFFER's resolution is what the receiver + // allocates its decoder/output surface for, so advertising + // anything other than what's actually sent is a real protocol + // mismatch -- one that plausibly explains a receiver decoder + // corrupting/freezing rather than just looking soft. Keeping the + // two in sync by hand across two files is exactly how that got + // out of step before; returning it from the pipeline builder + // makes the mismatch unrepresentable. The values below are the + // DMA-BUF path's, kept only as a sane standalone default. + width: 1920, + height: 1080, // Kept equal to `breadcastd::cast_mirror::MAX_BITRATE_KBPS * // 1000` -- see that constant's doc comment for why 8 Mbps // (this struct's previous value) isn't used here: real hardware @@ -70,7 +78,14 @@ impl Default for VideoParams { // producing repeated multi-second freezes rather than just // softer video. max_bitrate_bps: 6_000_000, - max_frame_rate_numerator: 30, + // A *ceiling*, not a promise -- which is what makes it safe for + // the DMA-BUF path, where nothing caps the rate (`vapostproc` is + // a per-frame transform and can't do temporal conversion, see + // `build_video_pipeline_for_streaming`) and frames arrive at + // whatever the compositor delivers, up to this machine's 60Hz + // refresh. The wl_shm path does have a `videorate` capping it + // hard, and overrides this with the rate it actually enforces. + max_frame_rate_numerator: 60, max_frame_rate_denominator: 1, } } diff --git a/breadcast-core/src/examples/cast_stream_test.rs b/breadcast-core/src/examples/cast_stream_test.rs index 6ceec30..81a24b1 100644 --- a/breadcast-core/src/examples/cast_stream_test.rs +++ b/breadcast-core/src/examples/cast_stream_test.rs @@ -10,7 +10,7 @@ use std::sync::Arc; use std::sync::atomic::{AtomicBool, Ordering}; use std::time::Duration; -use breadcast_core::caststream::{CastStreamEvent, VideoParams, WEBRTC_NAMESPACE}; +use breadcast_core::caststream::{CastStreamEvent, WEBRTC_NAMESPACE}; use breadcast_core::net::local_lan_ip; use breadcast_core::pipeline::{ build_video_pipeline_for_streaming, pull_encoded_frame, request_key_frame, set_video_bitrate_kbps, @@ -58,7 +58,7 @@ async fn main() -> anyhow::Result<()> { let capture = breadcast_core::CaptureSession::start().await?; println!("Got PipeWire video node id: {}", capture.video_node_id()); - let (pipeline, appsink, encoder) = build_video_pipeline_for_streaming(capture.video_node_id())?; + let (pipeline, appsink, encoder, video_params) = build_video_pipeline_for_streaming(capture.video_node_id())?; // Watch the encode pipeline's own bus in the background -- see // mirror_test.rs's identical block for why this matters. @@ -81,7 +81,7 @@ async fn main() -> anyhow::Result<()> { &device.host, "sender-0", session.transport_id(), - VideoParams::default(), + video_params, )?; let sender = Arc::new(sender); diff --git a/breadcast-core/src/pipeline/mod.rs b/breadcast-core/src/pipeline/mod.rs index b5466ab..fe46558 100644 --- a/breadcast-core/src/pipeline/mod.rs +++ b/breadcast-core/src/pipeline/mod.rs @@ -1,4 +1,6 @@ +use std::io::{Read as _, Write as _}; use std::os::unix::fs::DirBuilderExt; +use std::os::unix::net::UnixStream; use std::path::{Path, PathBuf}; use std::time::Duration; @@ -8,6 +10,8 @@ use gstreamer::prelude::*; use gstreamer_app as gst_app; use gstreamer_video as gst_video; +use crate::caststream::VideoParams; + /// Builds (but doesn't start) the capture → encode → mux → HLS pipeline for /// a single video source. `output_dir` is created if it doesn't exist; /// `hlssink3` writes `segment%05d.ts` files and `playlist.m3u8` there. @@ -145,31 +149,243 @@ pub fn build_video_pipeline(video_node_id: u32, output_dir: &Path) -> Result +/// `getOrCreateRenderbuffer`. If `CGLRenderbuffer`'s constructor fails to +/// import the buffer (`createEGLImage` returns `EGL_NO_IMAGE_KHR`) it +/// early-returns leaving `m_framebuffer` null -- and v0.55.4's destructor +/// then unconditionally does `unbind(); m_framebuffer->release();` on that +/// null pointer while the failed renderbuffer is being torn down. The +/// resulting abort takes down Hyprland, every window, and (separately, +/// same instant) `xdg-desktop-portal-hyprland`. Upstream's fix is a one-line +/// `if (m_framebuffer)` guard; it converts the abort into a dropped frame. +/// +/// Reported upstream at least three times (hyprwm/Hyprland #13487, #13543, +/// #13653, all v0.54.x, all on AMD) and auto-closed unread by the +/// issues-are-disabled bot rather than triaged, so the "is it fixed?" +/// question can only be answered from the commit log, not the tracker. The +/// reported triggers (window-group tab switching, touchpad gestures, an +/// emulator in a Discord stream) have nothing in common with each other or +/// with resolution -- treat the import failure as intermittent, not as +/// something a particular capture geometry or DRM modifier provokes. +/// +/// Note in particular that this is *not* avoidable by requesting a +/// "simpler" buffer layout. `vapostproc` advertises exactly one AMD DRM +/// modifier on its `video/x-raw(memory:DMABuf)` pads -- `0x0200000008401b04` +/// = GFX11, 64K_R_X tiling, `DCC=0` (verified with `gst-inspect-1.0 +/// vapostproc` and `drm_fourcc.h`'s field shifts). It offers no LINEAR +/// alternative, and the one modifier it does offer is already uncompressed, +/// so there is no tiling/compression hazard left to negotiate away. +const HYPRLAND_MIN_SAFE_DMABUF: (u32, u32, u32) = (0, 56, 0); + +/// Reads the running Hyprland's version over its own IPC socket (the same +/// `j/version` request `hyprctl version -j` makes) without spawning +/// `hyprctl`, which needn't be installed. `None` if this isn't a Hyprland +/// session at all, or if the version can't be determined. +fn hyprland_version() -> Option<(u32, u32, u32)> { + let signature = std::env::var("HYPRLAND_INSTANCE_SIGNATURE").ok()?; + let runtime_dir = std::env::var("XDG_RUNTIME_DIR").ok()?; + + let mut socket = UnixStream::connect(format!("{runtime_dir}/hypr/{signature}/.socket.sock")).ok()?; + // Bounded on both halves: this runs on the way into starting a mirror + // session, and a wedged compositor must not be able to hang that. + socket.set_write_timeout(Some(Duration::from_secs(1))).ok()?; + socket.set_read_timeout(Some(Duration::from_secs(1))).ok()?; + socket.write_all(b"j/version").ok()?; + + let mut response = String::new(); + socket.read_to_string(&mut response).ok()?; + let parsed: serde_json::Value = serde_json::from_str(&response).ok()?; + + // `version` is the plain "0.55.4"; `tag` is "v0.55.4" and is what older + // Hyprlands report, so accept either. + let raw = parsed.get("version").or_else(|| parsed.get("tag"))?.as_str()?; + parse_hyprland_version(raw) +} + +/// Splits a Hyprland version string into comparable components. Strips a +/// leading `v` (`tag` carries one, `version` doesn't) and anything from the +/// first `-` (a git build's tag looks like `v0.55.4-123-gdeadbee`). +fn parse_hyprland_version(raw: &str) -> Option<(u32, u32, u32)> { + let mut parts = raw.trim().trim_start_matches('v').split('-').next()?.split('.'); + let major = parts.next()?.parse().ok()?; + let minor = parts.next()?.parse().ok()?; + // A two-component "0.56" is treated as 0.56.0 rather than rejected -- + // erring toward *parsing* here is safe, since the comparison against + // `HYPRLAND_MIN_SAFE_DMABUF` is what decides anything. + let patch = parts.next().unwrap_or("0").parse().ok()?; + Some((major, minor, patch)) +} + +/// Picks the capture path, trading two *different* real bugs off against +/// each other rather than pretending either one is hypothetical. +/// +/// [`CaptureBackend::Dmabuf`] is the better path and the default: it is +/// genuinely zero-copy, and it sidesteps `xdg-desktop-portal-hyprland`'s +/// `wl_shm` stall entirely. That stall is not a hiccup -- it is terminal. +/// In xdpw's `src/portals/Screencopy.cpp`, when the PipeWire consumer is +/// holding every buffer, the portal logs "Out of buffers" and re-queues a +/// frame only while `copyRetries++ < MAX_RETRIES` (10); `copyRetries` is +/// reset to 0 *only* on a successful copy. So ten consecutive misses and +/// the portal stops requesting frames forever, without sending an error to +/// PipeWire -- which is exactly why a 45-second freeze showed up in +/// `journalctl` and nowhere on this pipeline's own GStreamer bus. (Worth +/// keeping in mind that "the consumer is holding every buffer" means the +/// stall can *originate* downstream: a brief encoder or RTP-send stall stops +/// buffers being recycled, and the portal's give-up logic then makes it +/// permanent. [`pull_encoded_frame`]'s watchdog is the backstop for both.) +/// +/// But on Hyprland older than [`HYPRLAND_MIN_SAFE_DMABUF`] the DMA-BUF path +/// can abort the compositor outright, which is a categorically worse outcome +/// than a stalled cast -- so there, fall back to `wl_shm` and let the +/// watchdog bound the damage. Non-Hyprland sessions are unaffected by that +/// bug and keep DMA-BUF. +fn choose_capture_backend() -> CaptureBackend { + let Some(version) = hyprland_version() else { + // Either not Hyprland (so the Hyprland-specific crash can't apply), + // or Hyprland with an unreadable version. The latter is the + // ambiguous case; prefer the path that cannot take the desktop down. + if std::env::var_os("HYPRLAND_INSTANCE_SIGNATURE").is_some() { + tracing::warn!( + "running under Hyprland but could not read its version; using the slower wl_shm \ + capture path, since DMA-BUF screencast aborts the compositor before v{}.{}.{}", + HYPRLAND_MIN_SAFE_DMABUF.0, + HYPRLAND_MIN_SAFE_DMABUF.1, + HYPRLAND_MIN_SAFE_DMABUF.2, + ); + return CaptureBackend::Shm; + } + return CaptureBackend::Dmabuf; + }; + + if version < HYPRLAND_MIN_SAFE_DMABUF { + tracing::warn!( + hyprland = format!("{}.{}.{}", version.0, version.1, version.2), + "this Hyprland predates the fix for the DMA-BUF screencast compositor crash \ + (upstream PR #15167, released in v0.56.0) -- falling back to the slower, \ + stall-prone wl_shm capture path. Updating Hyprland restores zero-copy capture." + ); + return CaptureBackend::Shm; + } + + CaptureBackend::Dmabuf +} + pub fn build_video_pipeline_for_streaming( video_node_id: u32, -) -> Result<(gst::Pipeline, gst_app::AppSink, gst::Element)> { +) -> Result<(gst::Pipeline, gst_app::AppSink, gst::Element, VideoParams)> { gst::init().context("failed to initialize GStreamer")?; - // 1280x720@30 Main profile. Briefly raised to 1080p, then reverted here: - // a near-instant freeze *on a faster network* turned out to have nothing - // to do with resolution or bandwidth at all -- see `frame_chain_broken` - // in `breadcast-caststream-sys/src/facade.cc` for the actual bug (the - // FFI silently drops frames under openscreen's in-flight budget and lets - // the encoder's reference chain corrupt as a result). 1080p roughly - // tripled the per-frame packet count, which made that bug's real trigger - // -- exceeding the in-flight window -- worse, not the resolution itself. - // Reverted alongside fixing that bug rather than keeping both variables - // in motion at once; revisit once `frame_chain_broken` on its own is - // confirmed to have fixed the freeze at 720p. - let pipeline_str = "pipewiresrc path=%VIDEO_NODE_ID% do-timestamp=true ! \ - videoconvert ! videoscale ! videorate ! \ - video/x-raw,format=NV12,width=1280,height=720,framerate=30/1 ! \ - vah264enc name=venc bitrate=4000 key-int-max=60 rate-control=cbr ! \ - video/x-h264,profile=main ! \ - h264parse name=h264parse config-interval=-1 ! \ - video/x-h264,stream-format=byte-stream,alignment=au ! \ - appsink name=appsink emit-signals=false sync=false max-buffers=4 drop=true" - .replace("%VIDEO_NODE_ID%", &video_node_id.to_string()); + // 1920x1080@native-rate Main profile. Went 1080p -> 720p -> 1080p again + // tonight: the first 1080p attempt froze near-instantly, but that had + // nothing to do with resolution -- it was the `xdg-desktop-portal-hyprland` + // wl_shm buffer-exhaustion bug below (real, structural, whatever the + // resolution) compounded by openscreen's in-flight RTP budget being too + // tight for this receiver's actual RTT (see `frame_chain_broken` in + // `breadcast-caststream-sys/src/facade.cc`, and the playout-delay tuning + // in `breadcast-caststream-sys/src/session.cc`). With both of those + // fixed -- confirmed via a real, freeze-free 720p session -- the + // packet-count increase 1080p brings back is no longer landing on an + // already-struggling budget, so it's worth trying again on its own + // merits. + // + // On the DMA-BUF path `pipewiresrc` deliberately does *not* go through + // `videoconvert ! videoscale ! videorate ! video/x-raw,...` (plain + // system-memory caps) -- doing so forces PipeWire to hand the + // compositor's portal implementation a `wl_shm` (shared-memory) buffer + // request, and on this system (`xdg-desktop-portal-hyprland`) that path + // is real-world buggy: journalctl during a live freeze showed it + // repeatedly logging "Asked for a wl_shm buffer which is legacy" / "Out + // of buffers" / "Retrying screencopy" in a tight loop that never + // actually delivered a frame -- multi-second (once 45+ second) stalls + // with *zero* signal on breadcast's own GStreamer bus, since nothing + // here was erroring, it was just starved waiting on a buffer the + // portal's legacy path never produced. See `choose_capture_backend` for + // why that stall is permanent rather than transient, and for the one + // case where it's still the lesser evil. + // + // `vapostproc` (VA-API postprocessor -- confirmed present via + // `gst-inspect-1.0 vapostproc`, ships in `gst-plugins-bad`'s `va` + // plugin) accepts `video/x-raw(memory:DMABuf)` directly from + // `pipewiresrc` and outputs `video/x-raw(memory:VAMemory)`, which + // `vah264enc` also accepts natively -- a fully zero-copy DMA-BUF path + // from portal to hardware encoder that never touches the legacy wl_shm + // fallback at all. No `videorate` in this path: `vapostproc` is a + // per-frame transform (scale/convert), not a temporal one, so it can't + // do frame-rate reduction the way `videorate` does on raw memory -- + // frames flow at whatever rate PipeWire actually delivers rather than a + // forced 30fps. This is fine for RTP: `facade.cc` derives RTP timestamps + // from each frame's real capture time regardless of the nominal rate, + // and openscreen's own frame pacing doesn't assume a fixed source rate + // either. The returned `VideoParams` advertises 60 as a *ceiling* + // (`max_frame_rate_*`), which stays truthful whether the compositor + // actually delivers 60 or fewer; the wl_shm path's `videorate` does cap + // hard, so it advertises the rate it really enforces. + // + // Both branches return the geometry they actually encode, and the caller + // hands that straight to the Cast OFFER. That coupling is deliberate: + // advertising a resolution other than what's really sent is a genuine + // protocol mismatch this project has already been bitten by once, and + // keeping two constants manually in sync across two files is how that + // happened. Returning it makes the mismatch unrepresentable. + let (pipeline_str, params) = match choose_capture_backend() { + CaptureBackend::Dmabuf => ( + "pipewiresrc path=%VIDEO_NODE_ID% do-timestamp=true ! \ + video/x-raw(memory:DMABuf),format=DMA_DRM ! \ + vapostproc ! \ + video/x-raw(memory:VAMemory),format=NV12,width=1920,height=1080 ! \ + vah264enc name=venc bitrate=4000 key-int-max=60 rate-control=cbr ! \ + video/x-h264,profile=main ! \ + h264parse name=h264parse config-interval=-1 ! \ + video/x-h264,stream-format=byte-stream,alignment=au ! \ + appsink name=appsink emit-signals=false sync=false max-buffers=4 drop=true", + VideoParams { width: 1920, height: 1080, max_frame_rate_numerator: 60, ..VideoParams::default() }, + ), + // 720p30 rather than 1080p60 on this path on purpose: every frame is + // a CPU convert + scale here, and CPU cost is precisely what makes + // the portal's "Out of buffers" give-up more likely, since the + // portal runs out exactly when the consumer is slow to recycle + // buffers. The lighter shape is also what was last known to work on + // real hardware before the DMA-BUF switch. + CaptureBackend::Shm => ( + "pipewiresrc path=%VIDEO_NODE_ID% do-timestamp=true ! \ + videoconvert ! videoscale ! videorate ! \ + video/x-raw,format=NV12,width=1280,height=720,framerate=30/1 ! \ + vah264enc name=venc bitrate=4000 key-int-max=60 rate-control=cbr ! \ + video/x-h264,profile=main ! \ + h264parse name=h264parse config-interval=-1 ! \ + video/x-h264,stream-format=byte-stream,alignment=au ! \ + appsink name=appsink emit-signals=false sync=false max-buffers=4 drop=true", + VideoParams { width: 1280, height: 720, max_frame_rate_numerator: 30, ..VideoParams::default() }, + ), + }; + let pipeline_str = pipeline_str.replace("%VIDEO_NODE_ID%", &video_node_id.to_string()); let element = gst::parse::launch(&pipeline_str).context("failed to parse GStreamer pipeline")?; let Ok(pipeline) = element.downcast::() else { @@ -184,12 +400,41 @@ pub fn build_video_pipeline_for_streaming( let encoder = pipeline.by_name("venc").context("parsed pipeline has no element named 'venc'")?; - Ok((pipeline, appsink, encoder)) + Ok((pipeline, appsink, encoder, params)) } +/// How long [`pull_encoded_frame`] waits per `try_pull_sample` call. Short +/// enough that a teardown from another thread is noticed promptly, long +/// enough not to spin. +const CAPTURE_STALL_POLL: Duration = Duration::from_millis(250); + +/// How long [`pull_encoded_frame`] tolerates a *playing* pipeline producing +/// no frames at all before declaring the capture dead. +/// +/// This exists because the failure it catches is otherwise completely +/// silent. `xdg-desktop-portal-hyprland` stops requesting frames after ten +/// consecutive "Out of buffers" misses and never sends an error to PipeWire +/// (see [`choose_capture_backend`]); a compositor that fails to import a +/// capture buffer likewise just drops the frame. In both cases GStreamer has +/// nothing to report -- no bus error, no EOS, no flow-return failure -- so +/// without a timeout here the frame pump blocks in `pull_sample` forever and +/// the mirror session appears frozen with nothing anywhere saying why. That +/// is precisely the 45-second freeze that took a `journalctl` dig to +/// explain. +/// +/// Bailing propagates out of `breadcastd`'s frame-pump thread, which already +/// reports `DaemonCommand::SessionEnded` on exit, so the session tears down +/// and the failure surfaces as a real event instead of a hang. Generous +/// enough (10s) that a merely slow moment -- a heavy compositor frame, a +/// bitrate renegotiation -- doesn't trip it; anything longer than this is +/// not a hiccup, since neither of the known failure modes recovers. +const CAPTURE_STALL_TIMEOUT: Duration = Duration::from_secs(10); + /// Pulls one complete Annex-B H.264 access unit from `appsink`, blocking /// until one is available. Returns `None` once the pipeline reaches EOS or -/// the sink otherwise stops (e.g. pipeline torn down from another thread). +/// the sink otherwise stops (e.g. pipeline torn down from another thread), +/// and errors if the pipeline is still playing but has gone +/// [`CAPTURE_STALL_TIMEOUT`] without producing a frame. /// /// A buffer with no PTS is skipped (this pulls the next one instead) rather /// than reported with a substituted timestamp, as an earlier version did @@ -203,12 +448,38 @@ pub fn build_video_pipeline_for_streaming( /// redundant, since `enqueue_frame` is a public FFI entry point that has to /// hold up against any caller. pub fn pull_encoded_frame(appsink: &gst_app::AppSink) -> Result, bool, i64)>> { + let poll = gst::ClockTime::from_mseconds(CAPTURE_STALL_POLL.as_millis() as u64); + let mut stalled_for = Duration::ZERO; loop { - let sample = match appsink.pull_sample() { - Ok(sample) => sample, - Err(_) if appsink.is_eos() => return Ok(None), - Err(e) => bail!("appsink pull_sample failed: {e}"), + let Some(sample) = appsink.try_pull_sample(Some(poll)) else { + // EOS is the ordinary end: the user hit "Stop sharing" in the + // portal, or the source went away. + if appsink.is_eos() { + return Ok(None); + } + // Teardown from another thread (`CastMirrorSession::stop` sets + // the pipeline to Null) makes the sink flush, and a flushing + // sink returns `None` *immediately* rather than after the + // timeout. Treat that as a clean end too -- otherwise this would + // busy-spin for the whole stall budget and then report a + // spurious "capture stalled" on every normal stop. + if !matches!(appsink.current_state(), gst::State::Playing | gst::State::Paused) { + return Ok(None); + } + + stalled_for += CAPTURE_STALL_POLL; + if stalled_for < CAPTURE_STALL_TIMEOUT { + continue; + } + bail!( + "capture stalled: no encoded frame for {}s while the pipeline was still \ + playing (no GStreamer error, no EOS). This is the shape of a portal-side \ + give-up -- see `choose_capture_backend` -- rather than a pipeline fault, \ + and it will not recover on its own", + CAPTURE_STALL_TIMEOUT.as_secs() + ); }; + stalled_for = Duration::ZERO; let buffer = sample.buffer().context("pulled sample had no buffer")?; let Some(capture_time_us) = buffer.pts().map(|t| t.useconds() as i64) else { tracing::debug!("skipped an encoded frame with no PTS"); @@ -352,3 +623,33 @@ pub async fn wait_for_playlist_segments(playlist_path: &Path, min_segments: usiz tokio::time::sleep(Duration::from_millis(250)).await; } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn parses_both_shapes_hyprland_reports() { + // `version` (plain) and `tag` (v-prefixed) from the same running + // compositor, plus the `-N-gSHA` suffix a git build's tag carries. + assert_eq!(parse_hyprland_version("0.55.4"), Some((0, 55, 4))); + assert_eq!(parse_hyprland_version("v0.55.4"), Some((0, 55, 4))); + assert_eq!(parse_hyprland_version("v0.56.0-123-gdeadbee"), Some((0, 56, 0))); + assert_eq!(parse_hyprland_version("0.56"), Some((0, 56, 0))); + assert_eq!(parse_hyprland_version(" v0.56.2\n"), Some((0, 56, 2))); + + assert_eq!(parse_hyprland_version(""), None); + assert_eq!(parse_hyprland_version("unknown"), None); + } + + #[test] + fn straddles_the_dmabuf_crash_fix_correctly() { + // The whole point of the constant: v0.55.4 aborts the compositor on + // a DMA-BUF screencast, v0.56.0 is the first release with the fix. + assert!(parse_hyprland_version("0.55.4").unwrap() < HYPRLAND_MIN_SAFE_DMABUF); + assert!(parse_hyprland_version("0.55.99").unwrap() < HYPRLAND_MIN_SAFE_DMABUF); + assert!(parse_hyprland_version("0.56.0").unwrap() >= HYPRLAND_MIN_SAFE_DMABUF); + assert!(parse_hyprland_version("0.56.1").unwrap() >= HYPRLAND_MIN_SAFE_DMABUF); + assert!(parse_hyprland_version("1.0.0").unwrap() >= HYPRLAND_MIN_SAFE_DMABUF); + } +} diff --git a/breadcastd/src/cast_mirror.rs b/breadcastd/src/cast_mirror.rs index 5bb928c..2990077 100644 --- a/breadcastd/src/cast_mirror.rs +++ b/breadcastd/src/cast_mirror.rs @@ -13,7 +13,7 @@ use std::sync::Arc; use std::sync::atomic::{AtomicBool, Ordering}; use anyhow::{Context, Result}; -use breadcast_core::caststream::{CastStreamEvent, VideoParams, WEBRTC_NAMESPACE}; +use breadcast_core::caststream::{CastStreamEvent, WEBRTC_NAMESPACE}; use breadcast_core::pipeline::{ build_video_pipeline_for_streaming, pull_encoded_frame, request_key_frame, set_video_bitrate_kbps, }; @@ -87,7 +87,13 @@ impl CastMirrorSession { let capture = CaptureSession::start().await.context("failed to start portal screen capture")?; let video_node_id = capture.video_node_id(); - let (pipeline, appsink, encoder) = + // `video_params` describes what this pipeline will *actually* encode + // -- it isn't a constant, because the pipeline picks its capture path + // at runtime (see `build_video_pipeline_for_streaming`) and the two + // paths differ in resolution and frame rate. It's threaded into the + // OFFER below rather than re-derived there, so the advertised stream + // and the encoded stream cannot drift apart. + let (pipeline, appsink, encoder, video_params) = build_video_pipeline_for_streaming(video_node_id).context("failed to build the encode pipeline")?; { @@ -116,7 +122,7 @@ impl CastMirrorSession { .context("failed to connect and launch the Mirroring receiver")?; let (sender, stream_events) = - CastStreamSender::start(&device.host, "sender-0", session.transport_id(), VideoParams::default()) + CastStreamSender::start(&device.host, "sender-0", session.transport_id(), video_params) .context("failed to start the Cast Streaming session")?; let sender = Arc::new(sender);