From 572dd2f37cce2f4244e7246601d3d4bf7701904c Mon Sep 17 00:00:00 2001 From: Shilai Li <2649730335git@gmail.com> Date: Tue, 27 Oct 2026 07:00:25 -0700 Subject: [PATCH 01/27] perf(linux): read write-combined dmabufs with streaming loads xdg-desktop-portal-hyprland hands out LINEAR dmabufs, which the helper maps and copies on the PipeWire thread. The driver maps them write-combined, so ordinary reads are uncached: on an Intel Arc B390 a 3840x2160 frame took ~100 ms through extend_from_slice (0.33 GB/s). While the copy ran the compositor ran out of buffers, rebuilt the stream several times a second ("Out of buffers" in the portal log) and the recording stuttered at 15-35 fps. Copy with SSE4.1 streaming loads (MOVNTDQA) instead: ~3.3 ms per 4K frame (10 GB/s). On ordinary write-back memory a streaming load is a plain load, so shm and memfd buffers take the same path. CPUs without SSE4.1 and non-x86 builds keep extend_from_slice. --- electron/native/pipewire-capture/src/shim.rs | 76 +++++++++++++++++++- 1 file changed, 75 insertions(+), 1 deletion(-) diff --git a/electron/native/pipewire-capture/src/shim.rs b/electron/native/pipewire-capture/src/shim.rs index d85ad084ae..fd09e84cb4 100644 --- a/electron/native/pipewire-capture/src/shim.rs +++ b/electron/native/pipewire-capture/src/shim.rs @@ -348,6 +348,64 @@ struct Mailbox { spare: Option>, } +/// Appends `source` to `dest`, reading it with streaming loads where the CPU +/// has them. +/// +/// `source` is often a CPU mapping of a LINEAR dmabuf, which the driver maps +/// write-combined: writes are fast, ordinary reads are uncached and crawl. +/// Measured on an Intel Arc B390 (xdg-desktop-portal-hyprland hands out exactly +/// these buffers), a 3840x2160 frame took ~100 ms through `extend_from_slice` +/// (0.33 GB/s) and ~3.3 ms through MOVNTDQA (10 GB/s). This copy runs on the +/// PipeWire thread, so at 100 ms the compositor ran out of buffers, rebuilt the +/// stream several times a second and the recording stuttered at 15-35 fps. +/// +/// On ordinary write-back memory (shm, memfd) a streaming load is a plain load, +/// so the same path serves every buffer type. +fn copy_from_mapping(dest: &mut Vec, source: &[u8]) { + #[cfg(target_arch = "x86_64")] + if std::arch::is_x86_feature_detected!("sse4.1") { + dest.reserve(source.len()); + let start = dest.len(); + // SAFETY: `reserve` made room for `source.len()` bytes past `start`, the + // copy initialises all of them, and the two regions cannot overlap + // (`dest` is our own heap allocation, `source` the compositor's mapping). + unsafe { + stream_copy_sse41(dest.as_mut_ptr().add(start), source); + dest.set_len(start + source.len()); + } + return; + } + dest.extend_from_slice(source); +} + +/// MOVNTDQA needs a 16-byte-aligned source, so the head up to the first +/// boundary and the tail past the last 64-byte block go through a plain copy. +#[cfg(target_arch = "x86_64")] +#[target_feature(enable = "sse4.1")] +unsafe fn stream_copy_sse41(dest: *mut u8, source: &[u8]) { + use std::arch::x86_64::{__m128i, _mm_storeu_si128, _mm_stream_load_si128}; + + let len = source.len(); + let base = source.as_ptr(); + let head = base.align_offset(16).min(len); + std::ptr::copy_nonoverlapping(base, dest, head); + let mut offset = head; + while offset + 64 <= len { + let from = base.add(offset) as *const __m128i; + let a = _mm_stream_load_si128(from); + let b = _mm_stream_load_si128(from.add(1)); + let c = _mm_stream_load_si128(from.add(2)); + let d = _mm_stream_load_si128(from.add(3)); + let to = dest.add(offset) as *mut __m128i; + _mm_storeu_si128(to, a); + _mm_storeu_si128(to.add(1), b); + _mm_storeu_si128(to.add(2), c); + _mm_storeu_si128(to.add(3), d); + offset += 64; + } + std::ptr::copy_nonoverlapping(base.add(offset), dest.add(offset), len - offset); +} + impl FrameMailbox { /// Copies `pixels` into the slot, replacing whatever was there. /// @@ -373,7 +431,7 @@ impl FrameMailbox { None => inner.spare.take().unwrap_or_default(), }; pixels.clear(); - pixels.extend_from_slice(source); + copy_from_mapping(&mut pixels, source); inner.pending = Some(Frame { pixels, @@ -1321,6 +1379,22 @@ mod tests { use std::sync::mpsc; use std::time::Duration; + /// Every head misalignment and tail length the streaming copy splits on must + /// come out byte-identical, appended after whatever `dest` already held. + #[test] + fn copy_from_mapping_is_byte_exact_at_any_alignment_and_length() { + let source: Vec = (0..1024u32).map(|i| (i * 7 + 3) as u8).collect(); + for skew in 0..16 { + for len in [0, 1, 15, 16, 63, 64, 65, 127, 128, 200, 1000 - skew] { + let slice = &source[skew..skew + len]; + let mut dest = vec![0xAA, 0xBB]; + copy_from_mapping(&mut dest, slice); + assert_eq!(&dest[..2], &[0xAA, 0xBB], "skew {skew} len {len}"); + assert_eq!(&dest[2..], slice, "skew {skew} len {len}"); + } + } + } + /// The bound that made Stage 1 produce nothing on the first real run. /// /// Compositors declare SPA_PARAM_META_size for the cursor as a FIXED From 407da2d2f533e00b50537a50812c6be81114a2e6 Mon Sep 17 00:00:00 2001 From: Shilai Li <2649730335git@gmail.com> Date: Tue, 27 Oct 2026 07:00:46 -0700 Subject: [PATCH 02/27] fix(linux): stamp frames by arrival slot, not encode time Capture::advance stamped a fresh frame with current_index(), the clock at the moment it was encoded. At 4K the ~13 ms of conversion and upload before that put about half of a steady 60 Hz stream one slot late; the next frame then found its slot taken and skipped one, so smooth motion was recorded with 33 ms gaps even though the compositor delivered every frame on time. Each frame now carries the instant the PipeWire thread handed it up, and a fresh frame is written in the slot it arrived in (or the first free one after it). The held picture a heartbeat re-encodes is stamped two slots behind the clock, so it can no longer claim the slot a frame arriving right now belongs in; finish() still stamps the true end. --- .../native/pipewire-capture/src/capture.rs | 86 ++++++++++++++++++- electron/native/pipewire-capture/src/main.rs | 1 + electron/native/pipewire-capture/src/shim.rs | 8 +- 3 files changed, 91 insertions(+), 4 deletions(-) diff --git a/electron/native/pipewire-capture/src/capture.rs b/electron/native/pipewire-capture/src/capture.rs index 8eb8b7b01a..602a7338df 100644 --- a/electron/native/pipewire-capture/src/capture.rs +++ b/electron/native/pipewire-capture/src/capture.rs @@ -172,6 +172,11 @@ pub struct Selection { /// At 1920×1080/60 it comes to about 12 Mbit/s. const BITS_PER_PIXEL: f64 = 0.1; +/// How far behind the clock a heartbeat re-stamps the held picture. Two slots +/// cover one frame waiting in the mailbox while the previous one converts, so a +/// held frame never claims a slot a fresh one arrived in. See `advance`. +const HELD_FRAME_LAG_SLOTS: i64 = 2; + /// Picks a video bitrate from the size the compositor actually negotiated. /// /// THE CALLER CANNOT DO THIS. On Wayland the app does not know the capture @@ -227,6 +232,12 @@ pub struct Capture { paused_at: Option, /// The next output frame index to write. next_index: i64, + /// Arrival instant of the first staged frame: index 0 of the arrival clock + /// fresh frames are placed by. See `advance`. + first_arrival: Option, + /// Arrival instant of the staged picture while it is still unwritten. `None` + /// once written, when what is staged is only the held picture. + staged_arrival: Option, frames_written: u64, /// The size the encoder was opened at, latched for the whole file. /// @@ -376,6 +387,8 @@ impl Capture { paused_total: Duration::ZERO, paused_at: None, next_index: 0, + first_arrival: None, + staged_arrival: None, frames_written: 0, committed_width: width, committed_height: height, @@ -491,7 +504,7 @@ impl Capture { // SAFETY: `nv12` is a VAAPI NV12 frame from the pool the encoder was // opened against; the encoder takes ownership. unsafe { self.encoder.stage_hw(nv12) }; - self.mark_started(); + self.mark_staged(frame.arrived); return Ok(StageOutcome::Staged); } @@ -512,7 +525,7 @@ impl Capture { .ok_or_else(|| format!("crop offset {offset} is past the end of the frame"))?; self.encoder.stage(pixels, frame.stride, format)?; - self.mark_started(); + self.mark_staged(frame.arrived); Ok(StageOutcome::Staged) } @@ -536,6 +549,12 @@ impl Capture { } } + fn mark_staged(&mut self, arrived: Instant) { + self.mark_started(); + self.first_arrival.get_or_insert(arrived); + self.staged_arrival = Some(arrived); + } + /// Whether a picture has been staged, which is also whether the timeline has /// started. pub fn started(&self) -> bool { @@ -563,7 +582,20 @@ impl Capture { if self.paused_at.is_some() || !self.encoder.has_staged_frame() { return Ok(0); } - let target = self.current_index(); + // A fresh picture goes in the slot it ARRIVED in, or the first free one + // after it. Stamping it from the clock at encode time put the ~13 ms of + // conversion and upload into the PTS: a 60 Hz stream at 4K landed half its + // frames one slot late, the next frame then skipped a slot, and the file + // showed 33 ms gaps in steady motion. + // + // The held picture is re-stamped HELD_FRAME_LAG_SLOTS behind the clock, + // so a heartbeat can never take the slot a frame that is arriving right + // now belongs in. Only the static-screen tail is affected, and `finish` + // stamps the true end. + let target = match self.staged_arrival { + Some(arrived) => self.arrival_index(arrived).max(self.next_index), + None => self.current_index() - HELD_FRAME_LAG_SLOTS, + }; let Some(muxer) = self.muxer.as_mut() else { return Ok(0); }; @@ -574,6 +606,7 @@ impl Capture { .encode_staged(target, |packet| muxer.write(track, packet))?; self.next_index = target + 1; self.frames_written += 1; + self.staged_arrival = None; written = 1; } @@ -707,6 +740,20 @@ impl Capture { /// Output frame index the wall clock is currently on, excluding paused time. /// `-1` before the first frame is staged, so `advance` writes nothing. + /// The slot a frame that arrived at `arrived` belongs in, on the clock that + /// starts at the first frame's arrival. Paused time is taken off the same way + /// `elapsed_active` takes it off; a frame never arrives during a pause, since + /// `stage` freezes then. + fn arrival_index(&self, arrived: Instant) -> i64 { + let Some(first) = self.first_arrival else { + return 0; + }; + let elapsed = arrived + .saturating_duration_since(first) + .saturating_sub(self.paused_total); + (elapsed.as_nanos() as i64 * self.fps as i64) / 1_000_000_000 + } + fn current_index(&self) -> i64 { match self.elapsed_active() { Some(elapsed) => (elapsed.as_nanos() as i64 * self.fps as i64) / 1_000_000_000, @@ -780,6 +827,7 @@ mod tests { height, video_format: format, pts_ns: -1, + arrived: std::time::Instant::now(), crop: shim::CropRect { x: 0, y: 0, width, height }, has_crop: false, dmabuf: None, @@ -869,6 +917,38 @@ mod tests { let _ = std::fs::remove_file(&output); } + /// A frame processed late still lands in the slot it arrived in. Stamping by + /// the encode-time clock pushed 4K frames one slot late and left 33 ms gaps. + #[test] + fn a_fresh_frame_is_placed_by_arrival_not_by_when_it_was_encoded() { + let output = std::env::temp_dir().join("openscreen-capture-arrival.mp4"); + let (mut capture, _) = + Capture::start(&output, 320, 240, 30, Some(1_000_000), Some(Backend::Software), Vec::new(), None) + .expect("start"); + let format = shim::constants().video_format_bgrx; + let first = Instant::now(); + let mut frame_at = |arrived: Instant| { + let mut f = frame(320, 240, format); + f.arrived = arrived; + f + }; + + capture.stage(&frame_at(first)).expect("stage"); + assert_eq!(capture.advance().expect("advance"), 1); + assert_eq!(capture.next_index, 1); + + // Arrived 40 ms in (slot 1 at 30 fps) but encoded ~150 ms in (slot 4). + std::thread::sleep(Duration::from_millis(150)); + capture.stage(&frame_at(first + Duration::from_millis(40))).expect("stage"); + assert_eq!(capture.advance().expect("advance"), 1); + assert_eq!(capture.next_index, 2, "written at its arrival slot, not the clock's"); + + // The held picture is then re-stamped HELD_FRAME_LAG_SLOTS behind the clock. + assert_eq!(capture.advance().expect("advance"), 1); + assert_eq!(capture.next_index, capture.current_index() - HELD_FRAME_LAG_SLOTS + 1); + let _ = std::fs::remove_file(&output); + } + /// The window-capture bug, at the layer where it produced wrong pixels. /// /// mutter hands a window stream MONITOR-sized buffers and reports the diff --git a/electron/native/pipewire-capture/src/main.rs b/electron/native/pipewire-capture/src/main.rs index 685b64d606..fc4fe4b581 100644 --- a/electron/native/pipewire-capture/src/main.rs +++ b/electron/native/pipewire-capture/src/main.rs @@ -1791,6 +1791,7 @@ mod cursor_sample_tests { height: 1080, video_format: shim::constants().video_format_bgrx, pts_ns: -1, + arrived: std::time::Instant::now(), crop: shim::CropRect { x: 100, y: 50, width: 320, height: 240 }, has_crop: true, dmabuf: None, diff --git a/electron/native/pipewire-capture/src/shim.rs b/electron/native/pipewire-capture/src/shim.rs index fd09e84cb4..0e1792b67b 100644 --- a/electron/native/pipewire-capture/src/shim.rs +++ b/electron/native/pipewire-capture/src/shim.rs @@ -231,7 +231,7 @@ pub struct CursorBitmap { } /// One captured frame, copied out of the PipeWire buffer. -#[derive(Debug, Default)] +#[derive(Debug)] pub struct Frame { pub pixels: Vec, pub stride: usize, @@ -241,6 +241,10 @@ pub struct Frame { /// Compositor monotonic clock in nanoseconds, or -1 when the buffer carried /// no SPA_META_Header. pub pts_ns: i64, + /// When the PipeWire thread handed this frame up, before any conversion. The + /// encoder places the frame on the timeline by this instant rather than by + /// when the main loop got round to it (see `Capture::advance`). + pub arrived: std::time::Instant, /// The sub-rectangle of `pixels` holding content, from SPA_META_VideoCrop. /// Defaults to the whole frame, so it is always safe to read. pub crop: CropRect, @@ -440,6 +444,7 @@ impl FrameMailbox { height: meta.height, video_format: meta.video_format, pts_ns: meta.pts_ns, + arrived: std::time::Instant::now(), crop: CropRect { x: meta.crop_x, y: meta.crop_y, @@ -476,6 +481,7 @@ impl FrameMailbox { height: meta.height, video_format: meta.video_format, pts_ns: meta.pts_ns, + arrived: std::time::Instant::now(), crop: CropRect { x: meta.crop_x, y: meta.crop_y, From 74c03cc43a3f705ba8c72672d733f76653277663 Mon Sep 17 00:00:00 2001 From: Shilai Li <2649730335git@gmail.com> Date: Tue, 27 Oct 2026 07:00:57 -0700 Subject: [PATCH 03/27] perf(linux): thread colour conversion via sws_scale_frame The helper asked libswscale for threads but converted with sws_scale, the pointer API, which runs single-threaded whatever the context's `threads` says. That is why the measurement behind the old default of one thread saw no difference between one and eight. Only sws_scale_frame slices the work. Convert through sws_scale_frame with a source frame that borrows the captured pixels (no copy), and default to half the CPU's threads, at most eight. 3840x2160 BGRx->NV12 drops from 7.6 ms/frame on one thread to 4.0 ms on eight (Core Ultra X7 358H), which leaves a third of the 4K60 frame budget instead of almost none; the output is byte-identical. OPENSCREEN_LINUX_SWS_THREADS still overrides the count. --- .../native/pipewire-capture/src/encoder.rs | 71 ++++++++++++------- 1 file changed, 44 insertions(+), 27 deletions(-) diff --git a/electron/native/pipewire-capture/src/encoder.rs b/electron/native/pipewire-capture/src/encoder.rs index 5f0a2fe49e..e745a785b9 100644 --- a/electron/native/pipewire-capture/src/encoder.rs +++ b/electron/native/pipewire-capture/src/encoder.rs @@ -258,6 +258,9 @@ pub struct VideoEncoder { upload_is_safe: bool, sws: *mut ff::SwsContext, sws_src_format: ff::AVPixelFormat, + /// A frame with no buffers of its own, re-pointed at each captured frame's + /// pixels so `sws_scale_frame` reads them in place. See [`Self::stage`]. + sws_src: *mut ff::AVFrame, packet: *mut ff::AVPacket, params: VideoParams, stats: EncodeStats, @@ -374,6 +377,7 @@ impl VideoEncoder { upload_is_safe: true, sws: ptr::null_mut(), sws_src_format: ff::AV_PIX_FMT_NONE, + sws_src: ptr::null_mut(), packet: ptr::null_mut(), params: VideoParams { width: params.width, @@ -620,21 +624,30 @@ impl VideoEncoder { )); } - let src_data = [pixels.as_ptr(), ptr::null(), ptr::null(), ptr::null()]; - let src_stride = [stride as i32, 0, 0, 0]; + // `sws_scale_frame`, not `sws_scale`: only the frame API slices the + // conversion across the context's `threads`. The pointer API runs on + // one thread whatever was asked for, which is why threading once + // measured as useless. The source frame only borrows `pixels` (no + // buffer refs), so nothing is copied and nothing is freed with it. + if self.sws_src.is_null() { + self.sws_src = ff::av_frame_alloc(); + if self.sws_src.is_null() { + return Err("av_frame_alloc returned null".to_owned()); + } + } + let src = &mut *self.sws_src; + src.format = src_format as i32; + src.width = self.params.width; + src.height = self.params.height; + src.data[0] = pixels.as_ptr().cast_mut(); + src.linesize[0] = stride as i32; let convert_started = std::time::Instant::now(); - let scaled = ff::sws_scale( - self.sws, - src_data.as_ptr(), - src_stride.as_ptr(), - 0, - self.params.height, - (*self.sw_frame).data.as_mut_ptr(), - (*self.sw_frame).linesize.as_mut_ptr(), - ); + let scaled = ff::sws_scale_frame(self.sws, self.sw_frame, self.sws_src); self.stats.convert_ns += convert_started.elapsed().as_nanos(); + // Never leave a pointer into the compositor's frame behind. + (*self.sws_src).data[0] = ptr::null_mut(); if scaled < 0 { - return Err(format!("sws_scale: {}", ff::err_to_string(scaled))); + return Err(format!("sws_scale_frame: {}", ff::err_to_string(scaled))); } // This CPU frame is now what `encode_staged` must send, so drop any hw // surface a previous dmabuf frame left staged — e.g. after the stream @@ -799,8 +812,8 @@ impl VideoEncoder { } // Built through the option API rather than `sws_getContext` so that // `threads` can be set at all — `sws_getContext` has no way to express - // it. It is set to 1 by default; see [`sws_thread_count`] for the - // measurement behind that. + // it. See [`sws_thread_count`] for how many, and [`Self::stage`] for why + // they only take effect through `sws_scale_frame`. // // Source and destination are the same size, so no resampling filter is // involved and the cheapest kernel is the right one: this is a pure @@ -890,6 +903,9 @@ impl Drop for VideoEncoder { if !self.sw_frame.is_null() { ff::av_frame_free(&mut self.sw_frame); } + if !self.sws_src.is_null() { + ff::av_frame_free(&mut self.sws_src); + } if !self.codec_ctx.is_null() { ff::avcodec_free_context(&mut self.codec_ctx); } @@ -1354,27 +1370,28 @@ impl Drop for Muxer { } } -/// Threads to give libswscale. One, by measurement. +/// Threads to give libswscale: half the CPU's, at most eight. /// -/// BGRA→NV12 at 1920×1080 was timed at 3.80 ms/frame with one thread and -/// 3.84 ms with eight, on an 8-thread Radeon 610M APU. The request is not being -/// ignored — [`VideoEncoder::sws_threads`] reads the granted count back out of -/// libswscale and it matches — the conversion is simply memory-bandwidth bound, -/// and extra threads contend for the same bandwidth instead of adding any. -/// Spawning eight threads per recording to gain 1% of nothing is not worth the -/// scheduler churn on a machine that is simultaneously compositing the screen -/// being captured. +/// This used to be one, on a measurement that could not see threading at all: +/// BGRA→NV12 timed the same with one thread and eight because `sws_scale` (the +/// pointer API) runs single-threaded whatever the context's `threads` says. Only +/// `sws_scale_frame` slices the work, and through it 3840×2160 BGRx→NV12 went +/// from 7.6 ms/frame on one thread to 5.2 ms on four and 4.0 ms on eight (Intel +/// Core Ultra X7 358H, 16 threads). At 4K60 that is the difference between +/// using almost all of the 16.7 ms frame budget and keeping a third of it. /// -/// The override exists because that measurement is one machine at one -/// resolution; a 4K capture on a many-core desktop with more memory channels may -/// well behave differently, and this makes checking it a one-liner. +/// Half, so the compositor producing the frames keeps cores to itself; eight, +/// because the gain flattens past that. `OPENSCREEN_LINUX_SWS_THREADS` still +/// overrides it. fn sws_thread_count() -> usize { if let Ok(raw) = std::env::var("OPENSCREEN_LINUX_SWS_THREADS") { if let Ok(count) = raw.trim().parse::() { return count.clamp(1, 32); } } - 1 + std::thread::available_parallelism() + .map(|threads| (threads.get() / 2).clamp(1, 8)) + .unwrap_or(1) } /// Parses `OPENSCREEN_LINUX_ENCODER`. Returns `None` when unset, which means From 7237e1bccc52697aa0130f85051ef5c78946f94c Mon Sep 17 00:00:00 2001 From: Shilai Li <2649730335git@gmail.com> Date: Tue, 27 Oct 2026 07:00:58 -0700 Subject: [PATCH 04/27] fix(linux): pass the real dmabuf size to the VA-API import The importer left AVDRMFrameDescriptor.objects[0].size at 0 for the driver to recover from the fd. Intel's iHD driver does not do that for a LINEAR buffer: vaCreateSurfaces fails with "resource allocation failed" and av_hwframe_map returns EIO, so every linear frame reaching the GPU import was refused. Read the size by seeking a dup of the fd to its end, which is how the kernel exposes a dmabuf's size, and fall back to 0 if that fails. Reproduced against a linear 3840x2160 GBM buffer: size 0 fails, the real size imports. --- .../pipewire-capture/src/dmabuf_import.rs | 17 ++++++++++++++++- 1 file changed, 16 insertions(+), 1 deletion(-) diff --git a/electron/native/pipewire-capture/src/dmabuf_import.rs b/electron/native/pipewire-capture/src/dmabuf_import.rs index 59e900a84a..9cc5e9ff97 100644 --- a/electron/native/pipewire-capture/src/dmabuf_import.rs +++ b/electron/native/pipewire-capture/src/dmabuf_import.rs @@ -50,6 +50,17 @@ pub struct DmabufFrame<'a> { pub planes: &'a [crate::shim::DmabufPlane], } +/// A dmabuf's allocation size, from seeking its fd to the end, which is how the +/// kernel exposes it. 0 ("let the driver find out") if that fails; the seek goes +/// through a dup so the plane's own fd is not touched. +fn dmabuf_size(fd: &std::os::fd::OwnedFd) -> usize { + use std::io::{Seek, SeekFrom}; + fd.try_clone() + .map(std::fs::File::from) + .and_then(|mut file| file.seek(SeekFrom::End(0))) + .map_or(0, |size| size as usize) +} + /// The pixel format the VAAPI-mapped surface presents, derived from the dmabuf's /// DRM fourcc. Only the 32-bit RGB layouts we negotiate are handled. fn sw_format_for_fourcc(drm_fourcc: u32) -> Option { @@ -357,7 +368,11 @@ impl DmabufImporter { } (*desc).nb_objects = 1; (*desc).objects[0].fd = frame.planes[0].fd.as_raw_fd(); - (*desc).objects[0].size = 0; // recovered by the driver from the fd + // The real allocation size, not 0. Intel's iHD driver does not recover + // it from the fd for a LINEAR buffer: vaCreateSurfaces fails with + // "resource allocation failed" and the map with EIO, which is why every + // linear frame xdg-desktop-portal-hyprland hands out was refused. + (*desc).objects[0].size = dmabuf_size(&frame.planes[0].fd); (*desc).objects[0].format_modifier = frame.modifier; (*desc).nb_layers = 1; (*desc).layers[0].format = frame.drm_fourcc; From 4d646eddda7458bfa6e074569c4aac27b8aa539a Mon Sep 17 00:00:00 2001 From: Shilai Li <2649730335git@gmail.com> Date: Tue, 27 Oct 2026 07:01:05 -0700 Subject: [PATCH 05/27] fix(linux): fall back to the embedded cursor when the portal lacks METADATA xdg-desktop-portal-hyprland offers only the HIDDEN and EMBEDDED cursor modes (AvailableCursorModes = 3). With the HUD on its default editable cursor, the helper requested METADATA, failed with cursor-metadata-unsupported, and nothing was recorded at all. A session that writes video now falls back to EMBEDDED, the mirror of the existing EMBEDDED -> METADATA fallback for niri (#1357): the recording gets the system cursor painted in rather than no recording. A cursor-only session has no frames to paint into, so it keeps METADATA and is still refused. handlers.ts records the mode actually negotiated, so the editor treats such a take as system-cursor instead of drawing a second cursor over the painted one. --- electron/ipc/handlers.ts | 8 +-- electron/native/pipewire-capture/src/main.rs | 7 +-- .../native/pipewire-capture/src/portal.rs | 51 ++++++++++++++----- 3 files changed, 48 insertions(+), 18 deletions(-) diff --git a/electron/ipc/handlers.ts b/electron/ipc/handlers.ts index 75c3a23df7..830f157dda 100644 --- a/electron/ipc/handlers.ts +++ b/electron/ipc/handlers.ts @@ -2772,10 +2772,12 @@ export function registerIpcHandlers( linuxNativeCaptureSession = session; linuxNativeCaptureRecordingId = recordingId; - // The portal may lack EMBEDDED and get METADATA instead (#1357): the - // pointer is then not in the pixels, so the editor has to draw it. + // The negotiated mode, not the requested one: the portal may lack + // EMBEDDED and get METADATA instead (#1357), so the editor has to draw + // the pointer; or lack METADATA and get EMBEDDED (Hyprland), so the + // pointer is already in the pixels and there are no samples to draw. linuxNativeCaptureCursorMode = - session.cursorMode === "metadata" ? "editable-overlay" : cursorCaptureMode; + session.cursorMode === "metadata" ? "editable-overlay" : "system"; // The portal's answer, not an in-app selection — on Wayland there // is none to have. This used to read `selectedSource || { name: diff --git a/electron/native/pipewire-capture/src/main.rs b/electron/native/pipewire-capture/src/main.rs index fc4fe4b581..cd231de7e4 100644 --- a/electron/native/pipewire-capture/src/main.rs +++ b/electron/native/pipewire-capture/src/main.rs @@ -284,8 +284,9 @@ fn main() { // Probed before `ready` so the event can report it, and so an unsupported // compositor fails immediately instead of after a pointless picker. Only - // fatal when METADATA is the mode actually requested — a recording that - // wants the system cursor painted in has no use for it. + // fatal when METADATA is the mode actually negotiated — a recording that + // wants the system cursor painted in has no use for it, and a recording that + // asked for METADATA falls back to EMBEDDED where the portal lacks it. let offered_cursor_modes = match pollster::block_on(portal::offered_cursor_modes()) { Ok(modes) => modes, Err(error) => fail(&mut emitter, error.code(), &error.message()), @@ -293,7 +294,7 @@ fn main() { let cursor_metadata_supported = offered_cursor_modes.contains(&portal::CursorMode::Metadata); // Resolved before anything reads it, so the portal, the click readers and // `ready` all see the mode that is really negotiated. - let cursor_mode = cursor_mode.resolve(&offered_cursor_modes); + let cursor_mode = cursor_mode.resolve(&offered_cursor_modes, output_path.is_some()); if !cursor_metadata_supported && cursor_mode.reports_cursor() { let error = portal::PortalError::CursorMetadataUnsupported; fail(&mut emitter, error.code(), &error.message()); diff --git a/electron/native/pipewire-capture/src/portal.rs b/electron/native/pipewire-capture/src/portal.rs index 68ef7156de..e4c3dbaedf 100644 --- a/electron/native/pipewire-capture/src/portal.rs +++ b/electron/native/pipewire-capture/src/portal.rs @@ -69,15 +69,29 @@ impl CursorMode { /// Some portals offer no EMBEDDED at all (niri's, #1357). Refusing to record /// there leaves the user nothing, so EMBEDDED falls back to METADATA: the /// pointer stays out of the pixels and the editor draws it from the samples - /// instead. Any other miss is returned unchanged, for the negotiation to - /// refuse with its own error. - pub fn resolve(self, offered: &[CursorMode]) -> CursorMode { + /// instead. + /// + /// Others offer no METADATA (xdg-desktop-portal-hyprland offers only HIDDEN + /// and EMBEDDED). The same reasoning runs the other way for a session that + /// writes video: METADATA falls back to EMBEDDED, and the recording gets the + /// system cursor painted in rather than no recording at all. A cursor-only + /// session (`records_video` false) has no frames to paint into, so it keeps + /// METADATA and is refused. Any other miss is returned unchanged, for the + /// negotiation to refuse with its own error. + pub fn resolve(self, offered: &[CursorMode], records_video: bool) -> CursorMode { if self == Self::Embedded && !offered.contains(&Self::Embedded) && offered.contains(&Self::Metadata) { return Self::Metadata; } + if self == Self::Metadata + && records_video + && !offered.contains(&Self::Metadata) + && offered.contains(&Self::Embedded) + { + return Self::Embedded; + } self } } @@ -129,10 +143,10 @@ pub struct PortalStream { #[derive(Debug)] pub enum PortalError { - /// The compositor's portal does not offer METADATA cursor mode. Nothing - /// this helper does can work without it, and falling back to EMBEDDED would - /// bake the cursor into the pixels, which is the opposite of what the - /// editor needs. + /// The compositor's portal does not offer METADATA cursor mode, and the + /// session cannot fall back to EMBEDDED: it records no video to paint the + /// pointer into, or the portal offers no EMBEDDED either (see + /// `CursorMode::resolve`). CursorMetadataUnsupported, /// The user dismissed the picker, or the portal refused. Cancelled, @@ -311,18 +325,31 @@ mod tests { #[test] fn embedded_falls_back_to_metadata_when_the_portal_lacks_it() { - assert_eq!(Embedded.resolve(&[Metadata, Hidden]), Metadata); + assert_eq!(Embedded.resolve(&[Metadata, Hidden], true), Metadata); + assert_eq!(Embedded.resolve(&[Metadata, Hidden], false), Metadata); + } + + #[test] + fn metadata_falls_back_to_embedded_when_recording_video() { + // xdg-desktop-portal-hyprland's offer. + assert_eq!(Metadata.resolve(&[Embedded, Hidden], true), Embedded); + } + + #[test] + fn a_cursor_only_session_keeps_metadata_for_negotiation_to_refuse() { + assert_eq!(Metadata.resolve(&[Embedded, Hidden], false), Metadata); } #[test] fn an_offered_mode_is_kept() { - assert_eq!(Embedded.resolve(&[Metadata, Embedded]), Embedded); - assert_eq!(Metadata.resolve(&[Metadata]), Metadata); + assert_eq!(Embedded.resolve(&[Metadata, Embedded], true), Embedded); + assert_eq!(Metadata.resolve(&[Metadata, Embedded], true), Metadata); + assert_eq!(Metadata.resolve(&[Metadata], false), Metadata); } #[test] fn a_miss_without_a_fallback_is_left_for_negotiation_to_refuse() { - assert_eq!(Embedded.resolve(&[Hidden]), Embedded); - assert_eq!(Metadata.resolve(&[Embedded]), Metadata); + assert_eq!(Embedded.resolve(&[Hidden], true), Embedded); + assert_eq!(Metadata.resolve(&[Hidden], true), Metadata); } } From 5a9914b81128e78363e10bf689e4208eb41af31a Mon Sep 17 00:00:00 2001 From: Shilai Li <2649730335git@gmail.com> Date: Tue, 27 Oct 2026 09:17:04 -0700 Subject: [PATCH 06/27] feat(linux): cursor positions and click zooms on Hyprland xdg-desktop-portal-hyprland offers no METADATA cursor mode, so Hyprland takes had no cursor positions: no auto-zoom, no editor-drawn cursor. Hyprland's own IPC reports the pointer, so the capture helper now takes positions from there (hyprland.rs), polled at the sample cadence. - Editable cursor requested: the helper negotiates HIDDEN and limits the picker to monitors, and the editor draws the cursor from these samples (.cursor.json), so hide, styles and click bounce work. Windows are left out because the portal does not say which window was shared. - The granted monitor's position and scale come from Hyprland's monitor list: the portal reports its size in physical pixels, which halved every position on a scale-2 monitor. - A take with the pointer painted in (EMBEDDED) keeps its samples in