pixelflux/x11/mod.rs
1/*
2 * This Source Code Form is subject to the terms of the Mozilla Public
3 * License, v. 2.0. If a copy of the MPL was not distributed with this
4 * file, You can obtain one at https://mozilla.org/MPL/2.0/.
5 */
6
7//! X11 host capture: grab the root window into host memory as BGRA, composite the XFixes hardware
8//! cursor and the watermark on the CPU, and feed each frame to [`pipeline::X11Pipeline`], which owns
9//! damage/stripe/encode. The grab goes through a shared-memory segment (XShm via x11rb) rather than
10//! a plain `GetImage` for one reason: a full-screen frame is far too large to copy through the X
11//! protocol socket every tick, so XShm has the server write the pixels straight into memory this
12//! process already has mapped.
13//!
14//! `run_capture` splits the work across two threads because grabbing the next frame and encoding
15//! the previous one have no reason to wait on each other: the caller's thread grabs frames and owns
16//! the x11rb connection and the pool of shm surfaces, while a spawned encode thread owns the
17//! [`pipeline::X11Pipeline`] (and thus the encoder) and runs the delivery callback, so the two overlap for
18//! throughput. Frames hand off through a bounded `FramePool` that carries only a raw pointer and
19//! geometry — never an X object, none of which is safe to share — so nothing X-related ever crosses
20//! the thread boundary and the encoder never has to touch X. Multi-instance safety for the encoders
21//! is handled inside them (e.g. the libx264 open/close lock); each capture owns its own private xcb
22//! connection, so there is no shared X state to serialize here.
23
24use std::sync::atomic::{AtomicBool, AtomicI32, AtomicU64, Ordering};
25use std::sync::mpsc::Sender;
26use std::sync::{Arc, Condvar, Mutex};
27use std::thread;
28use std::time::{Duration, Instant};
29
30use x11rb::connection::Connection;
31use x11rb::protocol::shm::ConnectionExt as ShmExt;
32use x11rb::protocol::xfixes::ConnectionExt as XfixesExt;
33use x11rb::protocol::xproto::{ConnectionExt as XprotoExt, ImageFormat};
34use x11rb::rust_connection::RustConnection;
35
36use crate::encoders::overlay::blend_pixel_premultiplied;
37use crate::encoders::software::EncodedStripe;
38use crate::pipeline::X11Pipeline;
39use crate::recording_sink::RecordingSink;
40use crate::RustCaptureSettings;
41
42pub mod computer_use;
43pub mod cursor;
44
45/// Cross-thread controls for a running capture: a bag of atomics (plus two mutex-guarded
46/// payloads) the owning `ScreenCapture` pyclass flips from the Python thread and the capture thread
47/// reads at the top of each iteration.
48///
49/// It exists so Python never has to reach into the pipeline. The `X11Pipeline` and its encoder are
50/// only safe to touch from the capture and encode threads, so `request_idr` / rate / fps / region
51/// changes are posted here as atomic flags and applied later on the thread that owns the pipeline,
52/// rather than mutating encoder state across the thread boundary from Python.
53///
54/// 1. **Lifecycle**: `stop` ends the capture loop; `force_idr` requests an on-demand keyframe on the
55/// next processed frame.
56/// 2. **Rate control** (gated by `rate_dirty`): `bitrate_kbps`, `vbv_mult_milli` (the VBV frame-time
57/// multiplier * 1000, held as an integer for atomics; `<= 0` selects the policy default), and
58/// `fps_milli` (target fps * 1000, re-read every frame for dynamic pacing and rate control). These
59/// atomics always hold the CURRENT values, so a pipeline rebuild can carry live rates forward.
60/// 3. **Tunables** (gated by `tunables_dirty`): one `LiveTunables` struct behind `tunables`, set
61/// rarely, carrying the per-frame quality knobs for the encode thread.
62/// 4. **Capture geometry**: `capture_cursor` toggles the cursor overlay (read on every grab);
63/// `region_dirty` guards `region` = `(x, y, w, h)` (w/h `<= 0` extends to the root edge), applied
64/// by the capture thread with the same drain/recreate machinery as auto-adjust.
65pub struct Controls {
66 pub stop: AtomicBool,
67 /// Set by the capture thread as it returns — after it has joined its encode thread and
68 /// so dropped the NVENC/CUDA session. The atexit sweep waits on this (bounded) before
69 /// letting the interpreter finalize, because that hardware-session drop racing process
70 /// exit segfaults, exactly as the Wayland branch fences with a Barrier.
71 pub finished: AtomicBool,
72 pub force_idr: AtomicBool,
73 pub rate_dirty: AtomicBool,
74 pub bitrate_kbps: AtomicI32,
75 pub vbv_mult_milli: AtomicI32,
76 pub fps_milli: AtomicU64,
77 pub tunables_dirty: AtomicBool,
78 pub tunables: Mutex<Option<crate::LiveTunables>>,
79 pub capture_cursor: AtomicBool,
80 pub region_dirty: AtomicBool,
81 pub region: Mutex<(i32, i32, i32, i32)>,
82}
83
84impl Controls {
85 /// Seed the controls from the initial settings so the first loop iteration reads the
86 /// configured bitrate / VBV / fps / region / cursor state rather than defaults.
87 pub fn new(s: &RustCaptureSettings) -> Self {
88 Self {
89 stop: AtomicBool::new(false),
90 finished: AtomicBool::new(false),
91 force_idr: AtomicBool::new(false),
92 rate_dirty: AtomicBool::new(false),
93 bitrate_kbps: AtomicI32::new(s.video_bitrate_kbps),
94 vbv_mult_milli: AtomicI32::new((s.video_vbv_multiplier * 1000.0).round() as i32),
95 fps_milli: AtomicU64::new((s.target_fps.max(1.0) * 1000.0) as u64),
96 tunables_dirty: AtomicBool::new(false),
97 tunables: Mutex::new(None),
98 capture_cursor: AtomicBool::new(s.capture_cursor),
99 region_dirty: AtomicBool::new(false),
100 region: Mutex::new((s.capture_x, s.capture_y, s.width, s.height)),
101 }
102 }
103}
104
105/// A shared-memory image surface: a POSIX shm segment mapped into both this process and the
106/// X server, into which `shm_get_image` writes one BGRA frame.
107///
108/// Both ends map the same physical pages, which is the whole point: a full-screen grab then costs no
109/// per-frame copy across the X socket, because the server's blit lands directly in memory the
110/// capture thread already reads. The surface owns the server-side segment id (`shmseg`), the local
111/// mapping (`addr` / `size`), and the geometry (`width` / `height` / `stride`) needed to interpret
112/// the bytes.
113struct ShmSurface {
114 shmseg: u32,
115 addr: *mut u8,
116 size: usize,
117 width: u16,
118 height: u16,
119 stride: usize,
120}
121
122impl ShmSurface {
123 /// Allocate a shm segment of `width*height*4` bytes, attach it both locally and to the X
124 /// server, and hand back a surface both ends can read.
125 ///
126 /// The segment is created with `shmget(IPC_PRIVATE)` and mapped locally via `shmat`, then
127 /// attached on the server (`shm_attach`). That attach is confirmed with a round-trip `check()`
128 /// BEFORE the segment is marked `IPC_RMID`: deleting only after both ends hold a reference means
129 /// the kernel frees the segment once this process and the server have both detached, never while
130 /// either still needs it. Every failure path unwinds the partial state (local detach and/or
131 /// `IPC_RMID`) so a failed allocation leaks neither memory nor a server attachment; a zero-sized
132 /// request is rejected up front.
133 fn create(conn: &RustConnection, width: u16, height: u16) -> Result<Self, String> {
134 let stride = width as usize * 4;
135 let size = stride * height as usize;
136 if size == 0 {
137 return Err("zero-sized capture surface".into());
138 }
139 unsafe {
140 let shmid = libc::shmget(libc::IPC_PRIVATE, size, libc::IPC_CREAT | 0o600);
141 if shmid < 0 {
142 return Err("shmget failed".into());
143 }
144 let addr = libc::shmat(shmid, std::ptr::null(), 0);
145 if addr == (-1isize) as *mut libc::c_void {
146 libc::shmctl(shmid, libc::IPC_RMID, std::ptr::null_mut());
147 return Err("shmat failed".into());
148 }
149 let shmseg = match conn.generate_id() {
150 Ok(id) => id,
151 Err(e) => {
152 libc::shmdt(addr);
153 libc::shmctl(shmid, libc::IPC_RMID, std::ptr::null_mut());
154 return Err(format!("generate_id: {e}"));
155 }
156 };
157 let attach = conn
158 .shm_attach(shmseg, shmid as u32, false)
159 .map_err(|e| format!("shm_attach: {e}"))
160 .and_then(|c| c.check().map_err(|e| format!("shm_attach check: {e}")));
161 libc::shmctl(shmid, libc::IPC_RMID, std::ptr::null_mut());
162 if let Err(e) = attach {
163 libc::shmdt(addr);
164 return Err(e);
165 }
166 Ok(Self {
167 shmseg,
168 addr: addr as *mut u8,
169 size,
170 width,
171 height,
172 stride,
173 })
174 }
175 }
176
177 /// Borrow the mapped segment as a mutable byte slice for the capture blit and overlays.
178 fn as_mut_slice(&mut self) -> &mut [u8] {
179 unsafe { std::slice::from_raw_parts_mut(self.addr, self.size) }
180 }
181
182 /// Borrow the mapped segment read-only, for the stability comparison.
183 fn as_slice(&self) -> &[u8] {
184 unsafe { std::slice::from_raw_parts(self.addr, self.size) }
185 }
186
187 /// Detach the segment from the X server and then locally, clearing the mapped pointer.
188 ///
189 /// This is a manual method rather than a `Drop` impl because releasing the server-side
190 /// attachment is an X protocol request that needs the live connection, which a `Drop` could not
191 /// reach; the owner of the connection must call it before the surface is dropped. The order —
192 /// server `shm_detach` first, then the local `shmdt` — completes the release of the segment that
193 /// was marked `IPC_RMID` at creation, so the kernel reclaims it once neither end holds it.
194 fn destroy(&mut self, conn: &RustConnection) {
195 let _ = conn.shm_detach(self.shmseg);
196 let _ = conn.flush();
197 unsafe {
198 libc::shmdt(self.addr as *mut libc::c_void);
199 }
200 self.addr = std::ptr::null_mut();
201 }
202}
203
204/// Reject a server whose root visual is not 32 bits per pixel.
205///
206/// Every `ShmSurface` is sized at `width * 4` bytes per row and the capture is read as packed
207/// BGRA, so a root of any other depth would be grabbed as garbage rather than failing. Checked
208/// on the initial connection and again on every reconnect, since a restarted server can come
209/// back with a different visual.
210fn require_32bpp(conn: &RustConnection, screen: usize) -> Result<(), String> {
211 let root_depth = conn.setup().roots[screen].root_depth;
212 let bpp = conn
213 .setup()
214 .pixmap_formats
215 .iter()
216 .find(|f| f.depth == root_depth)
217 .map(|f| f.bits_per_pixel)
218 .unwrap_or(32);
219 if bpp != 32 {
220 return Err(format!(
221 "unsupported root depth {root_depth} ({bpp} bpp); only 32-bpp BGRA is supported"
222 ));
223 }
224 Ok(())
225}
226
227/// Clamp a capture offset to the live root so a grab always has at least one root pixel in
228/// range: a past-end offset fails every `shm_get_image` with BadMatch and would burn out the
229/// failure budget with no chance of recovery. The upper clamp also keeps protocol-range roots
230/// (INT16 coordinates) from truncating a wider value through the `as i16` cast.
231fn clamp_offset(x: i32, root: u16) -> i16 {
232 let upper = (root as i32).saturating_sub(1).min(i16::MAX as i32 - 1);
233 x.max(0).min(upper) as i16
234}
235
236/// Resolve the capture dimensions `shm_get_image` will read, bounded so the region can never
237/// run past the live root from the (clamped) capture offset: with auto-adjust (or unset
238/// width/height `<= 0`) the capture tracks the full root minus the offset, otherwise the
239/// requested size is clamped to what is available from it. Saturating subtraction plus a final
240/// `u16` clamp (minimum 2) keep pathological settings from overflowing or collapsing to an
241/// unusable surface. H.264 (`output_mode == 1`) requires even dimensions, so both are then
242/// rounded down to a multiple of two.
243fn resolve_dims(root_w: u16, root_h: u16, s: &RustCaptureSettings) -> (u16, u16) {
244 let cap_x = clamp_offset(s.capture_x, root_w) as i32;
245 let cap_y = clamp_offset(s.capture_y, root_h) as i32;
246 let avail_w = (root_w as i32).saturating_sub(cap_x).max(2);
247 let avail_h = (root_h as i32).saturating_sub(cap_y).max(2);
248 let mut w = if s.auto_adjust_screen_capture_size || s.width <= 0 {
249 avail_w
250 } else {
251 s.width.min(avail_w)
252 };
253 let mut h = if s.auto_adjust_screen_capture_size || s.height <= 0 {
254 avail_h
255 } else {
256 s.height.min(avail_h)
257 };
258 w = w.clamp(2, u16::MAX as i32);
259 h = h.clamp(2, u16::MAX as i32);
260 if s.output_mode == 1 {
261 w &= !1;
262 h &= !1;
263 }
264 (w as u16, h as u16)
265}
266
267/// Rebuild the X channel + capture surfaces after a connection-level failure (Xorg restart,
268/// VT switch), refreshing the offset/dims state against the live root. The pool is drained
269/// first so the encode thread can never be reading a surface as it is destroyed, and its
270/// generation is bumped so the encoder rebuilds against the new layout. Every fallible step
271/// completes before the swap, and the old channel/surfaces are destroyed best-effort after
272/// it (their server is likely gone; the local detaches still run, and the kernel frees
273/// each old segment once nothing maps it).
274#[allow(clippy::too_many_arguments)]
275fn try_rebuild_channel(
276 conn: &mut RustConnection,
277 root: &mut u32,
278 rsettings: &RustCaptureSettings,
279 cap_x: &mut i16,
280 cap_y: &mut i16,
281 cap_w: &mut u16,
282 cap_h: &mut u16,
283 root_w: &mut u16,
284 root_h: &mut u16,
285 pool_n: usize,
286 pool: &FramePool,
287 surfaces: &mut Vec<ShmSurface>,
288 stop: &AtomicBool,
289) -> Result<(), String> {
290 let (new_conn, new_screen) =
291 x11rb::connect(None).map_err(|e| format!("X11 reconnect failed: {e}"))?;
292 require_32bpp(&new_conn, new_screen)?;
293 new_conn
294 .shm_query_version()
295 .map_err(|e| format!("shm_query_version: {e}"))?
296 .reply()
297 .map_err(|e| format!("XShm unavailable on reconnect: {e}"))?;
298 new_conn
299 .xfixes_query_version(5, 0)
300 .map_err(|e| format!("xfixes_query_version: {e}"))?
301 .reply()
302 .map_err(|e| format!("XFixes unavailable on reconnect: {e}"))?;
303 let new_root = new_conn.setup().roots[new_screen].root;
304 let geo = new_conn
305 .get_geometry(new_root)
306 .map_err(|e| format!("get_geometry: {e}"))?
307 .reply()
308 .map_err(|e| format!("get_geometry reply on reconnect: {e}"))?;
309 if !pool.drain_for_resize(pool_n, stop) {
310 return Err("pool drain interrupted by stop during reconnect".to_string());
311 }
312 let (fw, fh) = resolve_dims(geo.width, geo.height, rsettings);
313 let mut fresh: Vec<ShmSurface> = Vec::with_capacity(pool_n);
314 for _ in 0..pool_n {
315 match ShmSurface::create(&new_conn, fw, fh) {
316 Ok(s) => fresh.push(s),
317 Err(e) => {
318 for s in fresh.iter_mut() {
319 s.destroy(&new_conn);
320 }
321 return Err(e);
322 }
323 }
324 }
325 for s in surfaces.iter_mut() {
326 s.destroy(conn);
327 }
328 *conn = new_conn;
329 *root = new_root;
330 *cap_x = clamp_offset(rsettings.capture_x, geo.width);
331 *cap_y = clamp_offset(rsettings.capture_y, geo.height);
332 *cap_w = fw;
333 *cap_h = fh;
334 *root_w = geo.width;
335 *root_h = geo.height;
336 *surfaces = fresh;
337 pool.bump_generation();
338 Ok(())
339}
340
341/// Grab one frame of the capture region into `surface` with a single XShm round-trip.
342///
343/// One `shm_get_image` request is atomic — the server never interleaves another client
344/// inside it — so the grab is a coherent snapshot of whatever the screen held at that
345/// instant. Every grabbed frame is published; whether any of it changed is decided
346/// downstream by the encoder's own per-stripe damage detection.
347fn grab_frame(
348 conn: &RustConnection,
349 root: u32,
350 surface: &ShmSurface,
351 cap_x: i16,
352 cap_y: i16,
353 cap_w: u16,
354 cap_h: u16,
355) -> Result<(), String> {
356 conn.shm_get_image(
357 root,
358 cap_x,
359 cap_y,
360 cap_w,
361 cap_h,
362 !0u32,
363 ImageFormat::Z_PIXMAP.into(),
364 surface.shmseg,
365 0,
366 )
367 .map_err(|e| format!("shm_get_image: {e}"))?
368 .reply()
369 .map_err(|e| format!("shm_get_image reply: {e}"))?;
370 Ok(())
371}
372
373/// Frame-space top-left of the cursor image, given the XFixes hotspot position.
374///
375/// XFixes reports the cursor position at its HOTSPOT; X draws the image with its top-left at
376/// `(pos - hot)`, and the capture origin `(cap_x, cap_y)` is subtracted to move it into frame space.
377/// The result may go negative near the frame edges, which `overlay_cursor` clips per pixel to match
378/// the server's own edge clipping.
379#[inline]
380pub(crate) fn cursor_image_origin(x: i16, y: i16, xhot: u16, yhot: u16, cap_x: i32, cap_y: i32) -> (i32, i32) {
381 (x as i32 - xhot as i32 - cap_x, y as i32 - yhot as i32 - cap_y)
382}
383
384/// Composite the XFixes cursor (ARGB `u32` per pixel, premultiplied by the XFixes
385/// format definition) onto the BGRA frame with its top-left at `(img_x, img_y)`,
386/// blending each pixel through `blend_pixel_premultiplied` with per-pixel bounds
387/// clipping so an image straddling a frame edge writes only its in-frame portion.
388#[allow(clippy::too_many_arguments)]
389pub(crate) fn overlay_cursor(
390 frame: &mut [u8],
391 stride: usize,
392 frame_w: i32,
393 frame_h: i32,
394 cur_w: i32,
395 cur_h: i32,
396 pixels: &[u32],
397 img_x: i32,
398 img_y: i32,
399) {
400 for y in 0..cur_h {
401 let ty = img_y + y;
402 if ty < 0 || ty >= frame_h {
403 continue;
404 }
405 for x in 0..cur_w {
406 let tx = img_x + x;
407 if tx < 0 || tx >= frame_w {
408 continue;
409 }
410 let px = pixels[(y * cur_w + x) as usize];
411 let a = ((px >> 24) & 0xFF) as u8;
412 let r = ((px >> 16) & 0xFF) as u8;
413 let g = ((px >> 8) & 0xFF) as u8;
414 let b = (px & 0xFF) as u8;
415 let off = ty as usize * stride + tx as usize * 4;
416 blend_pixel_premultiplied(&mut frame[off..off + 4], r, g, b, a);
417 }
418 }
419}
420
421/// A captured raw BGRA frame held in a pooled shm surface, ready to encode.
422///
423/// It carries the surface pointer plus geometry so the encode thread reads the pixels directly (no
424/// copy) and can rebuild its pipeline when the capture size changed (auto-adjust). It also carries
425/// the pool's surface `generation`, so a rebuild is still triggered when the surfaces were recreated
426/// at the SAME size (a resize flap) and the new segments happen to reuse the old virtual addresses.
427struct RawFrame {
428 idx: usize,
429 ptr: *mut u8,
430 len: usize,
431 width: u16,
432 height: u16,
433 stride: usize,
434 generation: u64,
435}
436/// `RawFrame` is `Send`: its raw pointer addresses a pooled shm surface that the pool
437/// guarantees is not reused until the encode thread recycles this frame, so the handle is safe to
438/// move across the capture -> encode thread boundary.
439unsafe impl Send for RawFrame {}
440
441/// Mutex-guarded interior of `FramePool`: the free-surface index list and the single
442/// capture -> encode handoff slot, under one lock so moving a surface between them — acquire,
443/// publish, take, recycle — is always a single atomic step.
444struct PoolInner {
445 free: Vec<usize>,
446 slot: Option<RawFrame>,
447}
448
449/// Demand-driven capture -> encode handoff: a bounded single-slot channel over a fixed set of
450/// pooled shm surfaces.
451///
452/// The capture thread writes into a pooled surface and `publish`es it into the single `slot`; the
453/// encode thread `take`s it. Capture stays at most one frame ahead of encode because `acquire` and
454/// `publish` BLOCK (bounded) until the encoder frees a surface / drains the slot, throttling capture
455/// to the encode rate. Since X11 capture is pull-based (no backlog), throttling — rather than
456/// capturing-then-dropping — means a full-resolution shm round-trip is never spent on a frame that
457/// would be discarded, while the next capture still overlaps the current encode (the throughput win).
458/// The encoder therefore only ever sees a contiguous frame stream, keeping the H.264 reference chain
459/// valid.
460///
461/// `generation` is bumped by the capture thread each time the backing shm surfaces are destroyed and
462/// recreated. Published frames carry it so the encode thread rebuilds its pipeline — dropping encoder
463/// state keyed to surface base pointers (e.g. NVENC's pinned-host registrations) — even when a resize
464/// flap lands back on the old dimensions and the recreated segments reuse the old virtual addresses.
465struct FramePool {
466 inner: Mutex<PoolInner>,
467 cv: Condvar,
468 stop: AtomicBool,
469 generation: AtomicU64,
470}
471
472impl FramePool {
473 /// Create a pool with `n` free surfaces, an empty handoff slot, and generation 0.
474 fn new(n: usize) -> Self {
475 Self {
476 inner: Mutex::new(PoolInner { free: (0..n).collect(), slot: None }),
477 cv: Condvar::new(),
478 stop: AtomicBool::new(false),
479 generation: AtomicU64::new(0),
480 }
481 }
482
483 /// Capture: record that the surfaces were recreated by advancing the generation. Only
484 /// called after `drain_for_resize` succeeded, so no frame from the previous generation is still
485 /// in flight.
486 fn bump_generation(&self) {
487 self.generation.fetch_add(1, Ordering::Relaxed);
488 }
489
490 /// Read the current surface generation, stamped onto each published frame.
491 fn generation(&self) -> u64 {
492 self.generation.load(Ordering::Relaxed)
493 }
494
495 /// Capture: claim a free surface to write the next frame into, or `None` on stop.
496 ///
497 /// Blocks (bounded, re-checking `stop` every 20ms) until a surface is free, which throttles
498 /// capture to the encode rate so a full-resolution capture is never spent on a frame that would
499 /// be dropped. The bounded re-check means a stop that races the wait cannot hang the thread.
500 fn acquire(&self, stop: &AtomicBool) -> Option<usize> {
501 let mut g = self.inner.lock().unwrap();
502 loop {
503 if let Some(idx) = g.free.pop() {
504 return Some(idx);
505 }
506 if stop.load(Ordering::Relaxed) {
507 return None;
508 }
509 let (gg, _) = self.cv.wait_timeout(g, Duration::from_millis(20)).unwrap();
510 g = gg;
511 }
512 }
513
514 /// Capture: publish the just-captured frame into the single slot, or `false` (frame
515 /// discarded) on stop.
516 ///
517 /// Blocks (bounded, re-checking `stop` every 20ms) until the encode thread has taken the previous
518 /// frame, so capture stays at most one frame ahead and never drops under normal flow.
519 fn publish(&self, frame: RawFrame, stop: &AtomicBool) -> bool {
520 let mut g = self.inner.lock().unwrap();
521 loop {
522 if g.slot.is_none() {
523 g.slot = Some(frame);
524 drop(g);
525 self.cv.notify_all();
526 return true;
527 }
528 if stop.load(Ordering::Relaxed) {
529 return false;
530 }
531 let (gg, _) = self.cv.wait_timeout(g, Duration::from_millis(20)).unwrap();
532 g = gg;
533 }
534 }
535
536 /// Encode: block until a frame is available (`Some`) or stop is signalled (`None`).
537 ///
538 /// The wait is bounded (re-checking `stop` every 20ms) as defense-in-depth against a lost wakeup,
539 /// so a stop that races the park can never leave the encode thread blocked forever.
540 fn take(&self) -> Option<RawFrame> {
541 let mut g = self.inner.lock().unwrap();
542 loop {
543 if let Some(f) = g.slot.take() {
544 return Some(f);
545 }
546 if self.stop.load(Ordering::Acquire) {
547 return None;
548 }
549 let (gg, _) = self.cv.wait_timeout(g, Duration::from_millis(20)).unwrap();
550 g = gg;
551 }
552 }
553
554 /// Encode: return a surface to the free list after it has been encoded, waking any waiter
555 /// parked in `acquire` / `publish` / `drain_for_resize`.
556 fn recycle(&self, idx: usize) {
557 self.inner.lock().unwrap().free.push(idx);
558 self.cv.notify_all();
559 }
560
561 /// Capture: before recreating surfaces (auto-adjust / region resize), reclaim the pending
562 /// slot and wait until every surface is back in the free list, so no surface is destroyed while
563 /// the encode thread is still reading it.
564 ///
565 /// Reclaiming the slot returns any un-taken frame to the free list; the loop then waits until
566 /// `free.len() == n`, i.e. the encode thread has finished any in-flight frame. The wait is bounded
567 /// and also breaks on stop — pool shutdown OR the external `stop` — so a panicked or dead encode
568 /// thread that never recycles cannot wedge the capture thread here forever; a requested stop
569 /// unblocks it. Returns `true` if it fully drained (safe to recreate surfaces), or `false` if it
570 /// aborted on stop — in which case the caller tears down, which joins the encode thread before
571 /// destroying surfaces, so the resize-safety guarantee still holds.
572 fn drain_for_resize(&self, n: usize, stop: &AtomicBool) -> bool {
573 let mut g = self.inner.lock().unwrap();
574 if let Some(old) = g.slot.take() {
575 g.free.push(old.idx);
576 }
577 while g.free.len() < n {
578 if self.stop.load(Ordering::Acquire) || stop.load(Ordering::Relaxed) {
579 return false;
580 }
581 let (gg, _) = self.cv.wait_timeout(g, Duration::from_millis(20)).unwrap();
582 g = gg;
583 }
584 true
585 }
586
587 /// Signal every waiter to stop and wake them, ending the encode thread's `take` loop.
588 ///
589 /// The inner mutex is acquired BEFORE storing `stop` and notifying, which closes the lost-wakeup
590 /// window: with the lock held, `take` is either still before its `stop` check or already parked on
591 /// the condvar (and will receive the notify), never in the gap between the two. The notify is
592 /// issued after the guard is dropped so the woken thread does not immediately re-block on the lock
593 /// this call holds.
594 fn shutdown(&self) {
595 let g = self.inner.lock().unwrap();
596 self.stop.store(true, Ordering::Release);
597 drop(g);
598 self.cv.notify_all();
599 }
600}
601
602/// Encode thread body: consume captured frames from the pool, keep the [`pipeline::X11Pipeline`] in
603/// step with the capture size and cross-thread controls, encode, recycle, and deliver.
604///
605/// The loop runs until `FramePool::take` returns `None` (stop). For each frame:
606///
607/// 1. **(Re)build the pipeline** when it is missing, the frame size changed, or the surface
608/// generation changed. A generation change alone forces the rebuild path because recreated shm
609/// segments often reuse the old virtual base addresses, so encoder state keyed to base pointers
610/// (NVENC's pinned-host cache) must be dropped even at identical dimensions. An in-place
611/// [`X11Pipeline::reshape`] is preferred — NVENC reconfigures its live session and the striped
612/// software path just re-derives stripe state — so a resize never stalls on a full encoder
613/// re-init; encoders that cannot follow in place (VAAPI) report `false` and the pipeline
614/// is rebuilt. On a rebuild the old pipeline (with its GPU session/surfaces) is dropped BEFORE the
615/// new one is built, so an auto-adjust resize never holds two full encoder allocations at once
616/// (transient 2x GPU memory). The rebuilt settings pull the CURRENT bitrate / VBV / fps from the
617/// controls atomics, carrying live rate changes forward instead of reverting to the capture-start
618/// values.
619/// 2. **Apply cross-thread controls** here, on the thread that owns the pipeline: a pending
620/// `force_idr` requests a keyframe; a `rate_dirty` swap (Acquire, pairing with the setters' Release
621/// stores so the bitrate / VBV / fps payload is never seen half-applied) pushes a live rate change;
622/// a `tunables_dirty` swap applies per-frame tunables into both the local settings copy and the
623/// pipeline.
624/// 3. **Encode and hand off**: read the pooled surface directly through its raw pointer — sound
625/// because the pool guarantees the surface is not reused until it is recycled — run
626/// [`X11Pipeline::process`], recycle the surface BEFORE delivering so a slow consumer never holds a
627/// capture surface, then deliver any encoded stripes through `on_frame`.
628///
629/// The optional Unix-socket recording sink (parity with the Wayland path) is bound ONCE here and
630/// owned outside the pipeline, so pipeline rebuilds on resize keep the socket listener and any
631/// attached recorders alive. It can only carry a single full-frame H.264 stream, so configurations
632/// that cannot produce one (JPEG output, or a striped CPU encoder) are warned about up front.
633fn encode_loop<F>(pool: &FramePool, controls: &Controls, settings: &RustCaptureSettings, on_frame: &mut F)
634where
635 F: FnMut(Vec<EncodedStripe>),
636{
637 let mut psettings = settings.clone();
638 let recording_sink = RecordingSink::try_bind(&settings.recording_socket);
639 if recording_sink.is_some() {
640 if settings.output_mode == 0 {
641 eprintln!("[recording_sink] recording_socket set but output_mode is JPEG; no recordable H.264 stream.");
642 } else if settings.use_cpu && !settings.video_fullframe {
643 eprintln!("[recording_sink] recording_socket set but the CPU encoder is striped; set video_fullframe=true for a recordable stream.");
644 }
645 }
646 let mut pipeline: Option<X11Pipeline> = None;
647 let (mut pw, mut ph) = (0i32, 0i32);
648 let mut pgen = 0u64;
649 let mut last_log_time = Instant::now();
650 let mut frame_count: u64 = 0;
651 let mut stripe_count: u64 = 0;
652
653 while let Some(frame) = pool.take() {
654 let (fw, fh) = (frame.width as i32, frame.height as i32);
655 if pipeline.is_none() || pw != fw || ph != fh || pgen != frame.generation {
656 let size_changed = pw != fw || ph != fh;
657 psettings.width = fw;
658 psettings.height = fh;
659 psettings.video_bitrate_kbps = controls.bitrate_kbps.load(Ordering::Relaxed);
660 psettings.video_vbv_multiplier =
661 controls.vbv_mult_milli.load(Ordering::Relaxed) as f64 / 1000.0;
662 psettings.target_fps =
663 (controls.fps_milli.load(Ordering::Relaxed).max(1) as f64) / 1000.0;
664 let reshaped = pipeline
665 .as_mut()
666 .is_some_and(|pl| pl.reshape(&psettings, size_changed));
667 if !reshaped {
668 drop(pipeline.take());
669 pipeline = Some(X11Pipeline::new(psettings.clone()));
670 if let Some(pl) = &pipeline {
671 let mut log_msg = format!(
672 "[x11] Stream settings active -> Res: {}x{} | FPS: {:.1} | Encoder: {}",
673 psettings.width, psettings.height, psettings.target_fps, pl.encoder_name()
674 );
675 if psettings.output_mode == 1 && pl.encoder_name() == "CPU" {
676 log_msg.push_str(&format!(" ({})", crate::encoders::SOFTWARE_H264_ENCODER));
677 }
678 if psettings.output_mode == 0 {
679 log_msg.push_str(&format!(" | Mode: JPEG | Quality: {}", psettings.jpeg_quality));
680 } else {
681 if psettings.video_cbr_mode {
682 log_msg.push_str(&format!(" | Mode: H264 CBR {}", psettings.video_bitrate_kbps));
683 } else {
684 log_msg.push_str(&format!(" | Mode: H264 | CRF: {}", psettings.video_crf));
685 if psettings.video_bitrate_kbps > 0 {
686 log_msg.push_str(&format!(" | VBV: {} kbps", psettings.video_bitrate_kbps));
687 }
688 }
689 log_msg.push_str(&format!(" | Colorspace: {}", pl.colorspace_desc()));
690 }
691 log_msg.push_str(&format!(
692 " | Damage Thresh: {}f | Damage Dur: {}f",
693 psettings.damage_block_threshold, psettings.damage_block_duration
694 ));
695 println!("{}", log_msg);
696 }
697 }
698 pw = fw;
699 ph = fh;
700 pgen = frame.generation;
701 }
702 let pl = pipeline.as_mut().unwrap();
703
704 // A recorder connecting to the socket sink needs a fresh decode entry point; it is
705 // folded into the same standard request-IDR path as a client-driven force_idr.
706 let sink_idr = recording_sink
707 .as_ref()
708 .map(|s| s.should_force_idr())
709 .unwrap_or(false);
710 if controls.force_idr.swap(false, Ordering::Relaxed) || sink_idr {
711 pl.request_idr();
712 }
713 if controls.rate_dirty.swap(false, Ordering::Acquire) {
714 let b = controls.bitrate_kbps.load(Ordering::Relaxed);
715 let v = controls.vbv_mult_milli.load(Ordering::Relaxed) as f64 / 1000.0;
716 let fps = (controls.fps_milli.load(Ordering::Relaxed).max(1) as f64) / 1000.0;
717 pl.update_rate(b, v, fps);
718 }
719 if controls.tunables_dirty.swap(false, Ordering::Acquire)
720 && let Some(t) = controls.tunables.lock().unwrap().take() {
721 t.apply_to(&mut psettings);
722 pl.update_tunables(&t);
723 }
724
725 let buf = unsafe { std::slice::from_raw_parts(frame.ptr, frame.len) };
726 let stripes = pl.process(buf, frame.stride);
727 pool.recycle(frame.idx);
728 if !stripes.is_empty() {
729 frame_count += 1;
730 stripe_count += stripes.len() as u64;
731 // IDR arming is consumed inside X11Pipeline::process; this tap only fans out.
732 if let Some(ref socket) = recording_sink {
733 socket.write_frame(&stripes, psettings.height);
734 }
735 on_frame(stripes);
736 }
737
738 let now = Instant::now();
739 let elapsed = now.duration_since(last_log_time).as_secs_f64();
740 if elapsed >= 1.0 {
741 if settings.debug_logging {
742 let actual_fps = frame_count as f64 / elapsed;
743 let stripes_per_sec = stripe_count as f64 / elapsed;
744 println!(
745 "[x11] Res: {}x{} Encoder: {} EncFPS: {:.2} EncStripes/s: {:.2}",
746 psettings.width, psettings.height, pl.encoder_name(), actual_fps, stripes_per_sec
747 );
748 }
749 frame_count = 0;
750 stripe_count = 0;
751 last_log_time = now;
752 }
753 }
754}
755
756/// Run the X11 capture pipeline until `stop` is set, splitting capture and encode across two
757/// threads that overlap for throughput.
758///
759/// This (the caller's) thread performs setup and then the capture loop; a spawned encode thread runs
760/// `encode_loop` and invokes `on_frame(stripes)` once per encoded frame. The split lets capture and
761/// encode overlap, and because frames are throttled/dropped as RAW frames before encode, the delivered
762/// H.264 stays a valid contiguous reference chain.
763///
764/// 1. **Setup**: connect to X (a private connection) and require a 32-bpp BGRA root — the Z-pixmap
765/// byte depth must be 4, which modern servers use for depth 24/32 — then negotiate XShm and
766/// XFixes. XFixes is always negotiated (one round-trip) so the cursor overlay can be toggled on
767/// live even when capture started without it. Initial dimensions and origin are resolved from the
768/// settings and the live root geometry, and a `FramePool` of `POOL_N` = 3 shm surfaces is
769/// allocated — the working set (one in-capture, one in-slot, one in-encode) that a one-frame-ahead
770/// demand-driven capture needs, which also keeps the memory cost (`3 * W*H*4`, significant at 4K)
771/// bounded.
772/// 2. **Encode thread**: spawned with a raised scheduling priority; it reports its thread id back
773/// through `encode_tid_tx` so the caller can detect a re-entrant stop issued from inside the
774/// delivery callback.
775/// 3. **Capture loop** (until `stop`): fps is re-read each iteration for live pacing — sleep to the
776/// next frame deadline, or `yield_now` when already behind instead of busy-spinning.
777/// - **Live region change** (`region_dirty`): re-target the grab origin immediately (an x/y pan
778/// needs no surface work); a size change reuses the drain/recreate path below.
779/// - **Auto-adjust**: on a root geometry change, drain in-flight frames then recreate the surfaces
780/// and bump the generation (which the encode thread turns into a reshape or rebuild). The geometry
781/// is re-resolved AFTER the drain, because a slow drain can outlast another geometry change (a
782/// fast flap) and recreating at a stale size would make the next `shm_get_image` exceed the root;
783/// a fully-reverted flap keeps its surfaces as-is. A stop that races the drain breaks out to
784/// teardown.
785/// - **Grab**: acquire a pooled surface (blocks until the encoder frees one), then `shm_get_image`
786/// the region into it synchronously (`reply()` waits).
787/// - **Overlays**: composite the XFixes cursor (drawn at its hotspot-offset origin; live-toggleable)
788/// and the watermark onto the BGRA pixels on the CPU.
789/// - **Publish**: hand the finished frame to the encode thread (blocks until the slot is free;
790/// never drops). A stop observed while waiting in acquire/publish exits the loop.
791/// 4. **Teardown**: stop and JOIN the encode thread BEFORE destroying the shm surfaces it may still be
792/// reading, preserving the resize-safety guarantee.
793///
794/// Blocking; intended to run on a dedicated thread. The X connection and shm surfaces live on this
795/// thread and the encoder lives on the encode thread — nothing X-related crosses the boundary.
796pub fn run_capture<F>(
797 settings: RustCaptureSettings,
798 controls: Arc<Controls>,
799 encode_tid_tx: Sender<thread::ThreadId>,
800 on_frame: F,
801) -> Result<(), String>
802where
803 F: FnMut(Vec<EncodedStripe>) + Send + 'static,
804{
805 let (mut conn, screen_num) =
806 x11rb::connect(None).map_err(|e| format!("X11 connect failed: {e}"))?;
807 let mut root = conn.setup().roots[screen_num].root;
808 require_32bpp(&conn, screen_num)?;
809
810 conn.shm_query_version()
811 .map_err(|e| format!("shm_query_version: {e}"))?
812 .reply()
813 .map_err(|e| format!("XShm unavailable: {e}"))?;
814 conn.xfixes_query_version(5, 0)
815 .map_err(|e| format!("xfixes_query_version: {e}"))?
816 .reply()
817 .map_err(|e| format!("XFixes unavailable: {e}"))?;
818
819 let geo = conn
820 .get_geometry(root)
821 .map_err(|e| format!("get_geometry: {e}"))?
822 .reply()
823 .map_err(|e| format!("get_geometry reply: {e}"))?;
824
825 let mut rsettings = settings.clone();
826 let (mut cap_w, mut cap_h) = resolve_dims(geo.width, geo.height, &rsettings);
827 let mut cap_x = clamp_offset(rsettings.capture_x, geo.width);
828 let mut cap_y = clamp_offset(rsettings.capture_y, geo.height);
829 // Last root size seen. A live pan has to clamp against something even when the geometry
830 // round-trip fails, or the requested origin would be dropped instead of applied.
831 let (mut root_w, mut root_h) = (geo.width, geo.height);
832
833 const POOL_N: usize = 3;
834 let mut surfaces: Vec<ShmSurface> = Vec::with_capacity(POOL_N);
835 for _ in 0..POOL_N {
836 surfaces.push(ShmSurface::create(&conn, cap_w, cap_h)?);
837 }
838 // Consecutive channel rebuilds without one good grab in between: geometry
839 // errors can burn out the grab budget repeatedly under a dead region, so
840 // recovery only gets a bounded number of channel rebuilds.
841 let mut reconnect_streak = 0u32;
842 let pool = Arc::new(FramePool::new(POOL_N));
843
844 let mut watermark = crate::encoders::overlay::OverlayState::default();
845 if !settings.watermark_path.is_empty() {
846 watermark.load_watermark(&settings.watermark_path, 1.0);
847 }
848
849 let enc_pool = pool.clone();
850 let enc_controls = controls.clone();
851 let enc_settings = settings.clone();
852 let encode_panicked = Arc::new(AtomicBool::new(false));
853 let guard_panicked = encode_panicked.clone();
854 let encode_thread = thread::spawn(move || {
855 crate::boost_thread_priority(-10);
856 let _ = encode_tid_tx.send(thread::current().id());
857 let mut on_frame = on_frame;
858 // An encoder panic must never wedge the capture: without the pool shutdown
859 // the capture thread would block in publish forever while is_capturing still
860 // reports true on this thread's handle. Shutting the pool unblocks it through
861 // the same path a clean stop takes, so the panic is also recorded — otherwise
862 // the capture would return Ok and Python could not tell the two apart.
863 let guard_pool = enc_pool.clone();
864 let guard_controls = enc_controls.clone();
865 let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
866 encode_loop(&enc_pool, &enc_controls, &enc_settings, &mut on_frame);
867 }));
868 if result.is_err() {
869 eprintln!("[pixelflux x11] encode thread panicked; shutting the pool to fail the capture");
870 guard_panicked.store(true, Ordering::Release);
871 guard_pool.shutdown();
872 guard_controls.stop.store(true, Ordering::Relaxed);
873 }
874 });
875
876 let mut next_frame = Instant::now();
877 // Frames between root-geometry polls (auto-adjust); ~0.5s at 60fps.
878 const GEOMETRY_POLL_FRAMES: i32 = 30;
879 let mut geometry_check = 0i32;
880 let mut grab_failures = 0u32;
881
882 let result = (|| -> Result<(), String> {
883 while !controls.stop.load(Ordering::Relaxed) {
884 let fps = (controls.fps_milli.load(Ordering::Relaxed).max(1) as f64) / 1000.0;
885 let frame_dur = Duration::from_secs_f64(1.0 / fps.max(1.0));
886 let now = Instant::now();
887 if now < next_frame {
888 std::thread::sleep(next_frame - now);
889 } else {
890 std::thread::yield_now();
891 }
892 next_frame += frame_dur;
893 let now = Instant::now();
894 if next_frame < now {
895 next_frame = now;
896 }
897 if controls.stop.load(Ordering::Relaxed) {
898 break;
899 }
900
901 if controls.region_dirty.swap(false, Ordering::Acquire) {
902 let (nx, ny, nw, nh) = *controls.region.lock().unwrap();
903 rsettings.capture_x = nx;
904 rsettings.capture_y = ny;
905 rsettings.width = nw;
906 rsettings.height = nh;
907 // An explicit live size pins the region: auto-adjust (from the start
908 // settings) would glue resolve_dims back to the ROOT size, undoing the
909 // re-target on the next geometry poll. w/h <= 0 keep following the root.
910 rsettings.auto_adjust_screen_capture_size = nw <= 0 || nh <= 0;
911 cap_x = clamp_offset(nx, root_w);
912 cap_y = clamp_offset(ny, root_h);
913 if let Some(g) = conn.get_geometry(root).ok().and_then(|c| c.reply().ok()) {
914 root_w = g.width;
915 root_h = g.height;
916 cap_x = clamp_offset(nx, root_w);
917 cap_y = clamp_offset(ny, root_h);
918 let (fw, fh) = resolve_dims(g.width, g.height, &rsettings);
919 if fw != cap_w || fh != cap_h {
920 if !pool.drain_for_resize(POOL_N, &controls.stop) {
921 break;
922 }
923 for s in surfaces.iter_mut() {
924 s.destroy(&conn);
925 }
926 surfaces.clear();
927 cap_w = fw;
928 cap_h = fh;
929 for _ in 0..POOL_N {
930 surfaces.push(ShmSurface::create(&conn, cap_w, cap_h)?);
931 }
932 pool.bump_generation();
933 }
934 }
935 }
936
937 // Root geometry is polled on a cadence, not per frame: the reply costs a
938 // round-trip that would otherwise precede every grab, and external size
939 // changes are rare (live resizes arrive through region_dirty instead).
940 // The gate reads the live copy, so a region re-targeted to the root edge
941 // starts following it from that point on. Grab-failure recovery enters
942 // regardless of auto_adjust: a fixed-size region has no other re-clamp
943 // path after an external root shrink, and without one the capture dies at
944 // the failure cap instead of recovering.
945 geometry_check = geometry_check.saturating_sub(1);
946 if (rsettings.auto_adjust_screen_capture_size || grab_failures > 0)
947 && geometry_check <= 0
948 {
949 geometry_check = GEOMETRY_POLL_FRAMES;
950 if let Some(g) = conn.get_geometry(root).ok().and_then(|c| c.reply().ok()) {
951 // A root shrink can strand even a previously-valid offset past
952 // the new edge; re-clamp it against the live root.
953 root_w = g.width;
954 root_h = g.height;
955 cap_x = clamp_offset(rsettings.capture_x, root_w);
956 cap_y = clamp_offset(rsettings.capture_y, root_h);
957 let (nw, nh) = resolve_dims(g.width, g.height, &rsettings);
958 if nw != cap_w || nh != cap_h {
959 if !pool.drain_for_resize(POOL_N, &controls.stop) {
960 break;
961 }
962 let (fw, fh) = conn
963 .get_geometry(root)
964 .ok()
965 .and_then(|c| c.reply().ok())
966 .map(|g| resolve_dims(g.width, g.height, &rsettings))
967 .unwrap_or((nw, nh));
968 if fw != cap_w || fh != cap_h {
969 for s in surfaces.iter_mut() {
970 s.destroy(&conn);
971 }
972 surfaces.clear();
973 cap_w = fw;
974 cap_h = fh;
975 for _ in 0..POOL_N {
976 surfaces.push(ShmSurface::create(&conn, cap_w, cap_h)?);
977 }
978 pool.bump_generation();
979 }
980 }
981 }
982 }
983
984 let idx = match pool.acquire(&controls.stop) {
985 Some(i) => i,
986 None => break,
987 };
988 let grab_result = {
989 let surface = &mut surfaces[idx];
990 grab_frame(&conn, root, surface, cap_x, cap_y, cap_w, cap_h)
991 };
992 if let Err(e) = grab_result {
993 // Most likely an external root resize between geometry polls made
994 // the grab run past the root edge: return the surface, re-poll
995 // geometry immediately, and only give up if it never recovers.
996 pool.recycle(idx);
997 grab_failures += 1;
998 geometry_check = 0;
999 if grab_failures > 120 {
1000 // A burn-out usually means the X connection itself is gone
1001 // (Xorg restart, VT switch): every request fails until the
1002 // server is back. Rebuild the channel a few times before
1003 // declaring the capture dead — a recovered server resumes the
1004 // stream where a dead thread would leave a black screen until
1005 // the Python watchdog restarts everything.
1006 let mut recovered = false;
1007 for _attempt in 0..5 {
1008 if controls.stop.load(Ordering::Relaxed) {
1009 break;
1010 }
1011 std::thread::sleep(Duration::from_secs(1));
1012 match try_rebuild_channel(
1013 &mut conn, &mut root, &rsettings,
1014 &mut cap_x, &mut cap_y, &mut cap_w, &mut cap_h,
1015 &mut root_w, &mut root_h,
1016 POOL_N, &pool, &mut surfaces, &controls.stop,
1017 ) {
1018 Ok(()) => { recovered = true; break; }
1019 Err(re) => {
1020 eprintln!("[pixelflux x11] reconnect attempt failed: {re}");
1021 }
1022 }
1023 }
1024 if !recovered {
1025 return Err(format!("capture could not recover: {e}"));
1026 }
1027 grab_failures = 0;
1028 geometry_check = 0;
1029 reconnect_streak += 1;
1030 if reconnect_streak >= 4 {
1031 return Err("capture recovered its channel but the region never grabbed again".to_string());
1032 }
1033 }
1034 continue;
1035 }
1036 grab_failures = 0;
1037 reconnect_streak = 0;
1038 let surface = &mut surfaces[idx];
1039
1040 let frame_w = cap_w as i32;
1041 let frame_h = cap_h as i32;
1042 let stride = surface.stride;
1043 let buf = surface.as_mut_slice();
1044
1045 if controls.capture_cursor.load(Ordering::Relaxed)
1046 && let Some(c) = conn
1047 .xfixes_get_cursor_image()
1048 .ok()
1049 .and_then(|c| c.reply().ok())
1050 && c.width > 0 && c.height > 0 {
1051 // Translate from root coordinates using the LIVE grab origin
1052 // (cap_x/cap_y follow region_dirty pans and clamp negatives
1053 // exactly like the grab itself), never the immutable startup
1054 // settings — those go stale on the first live region move.
1055 let (img_x, img_y) = cursor_image_origin(
1056 c.x,
1057 c.y,
1058 c.xhot,
1059 c.yhot,
1060 cap_x as i32,
1061 cap_y as i32,
1062 );
1063 overlay_cursor(
1064 buf,
1065 stride,
1066 frame_w,
1067 frame_h,
1068 c.width as i32,
1069 c.height as i32,
1070 &c.cursor_image,
1071 img_x,
1072 img_y,
1073 );
1074 }
1075
1076 if watermark.is_active() {
1077 watermark.update_position(frame_w, frame_h, settings.watermark_location_enum);
1078 watermark.blend_bgra(buf, stride, frame_w, frame_h);
1079 }
1080
1081 let published = pool.publish(
1082 RawFrame {
1083 idx,
1084 ptr: surface.addr,
1085 len: surface.size,
1086 width: cap_w,
1087 height: cap_h,
1088 stride,
1089 generation: pool.generation(),
1090 },
1091 &controls.stop,
1092 );
1093 if !published {
1094 break;
1095 }
1096 }
1097 Ok(())
1098 })();
1099
1100 pool.shutdown();
1101 let _ = encode_thread.join();
1102 for s in surfaces.iter_mut() {
1103 s.destroy(&conn);
1104 }
1105 match result {
1106 // A real capture error is the more specific diagnosis, so it outranks the panic flag.
1107 Err(e) => Err(e),
1108 Ok(()) if encode_panicked.load(Ordering::Acquire) => Err("encode thread panicked".into()),
1109 Ok(()) => Ok(()),
1110 }
1111}
1112
1113#[cfg(test)]
1114mod pool_tests {
1115 use super::*;
1116
1117 fn dummy(idx: usize) -> RawFrame {
1118 RawFrame {
1119 idx,
1120 ptr: std::ptr::null_mut(),
1121 len: 0,
1122 width: 0,
1123 height: 0,
1124 stride: 0,
1125 generation: 0,
1126 }
1127 }
1128
1129 /// A full acquire -> publish -> take -> recycle round-trip returns the same surface, and
1130 /// afterwards all three pool surfaces are acquirable again and distinct.
1131 #[test]
1132 fn roundtrip_then_recycle_returns_all_surfaces() {
1133 let p = FramePool::new(3);
1134 let stop = AtomicBool::new(false);
1135 let a = p.acquire(&stop).unwrap();
1136 assert!(p.publish(dummy(a), &stop));
1137 let f = p.take().unwrap();
1138 assert_eq!(f.idx, a);
1139 p.recycle(f.idx);
1140 let (x, y, z) = (
1141 p.acquire(&stop).unwrap(),
1142 p.acquire(&stop).unwrap(),
1143 p.acquire(&stop).unwrap(),
1144 );
1145 assert!(x != y && y != z && x != z);
1146 }
1147
1148 /// With every surface held and `stop` set, `acquire` returns `None` after its bounded wait
1149 /// rather than blocking forever.
1150 #[test]
1151 fn acquire_returns_none_when_exhausted_and_stopped() {
1152 let p = FramePool::new(2);
1153 let stop = AtomicBool::new(false);
1154 let _a = p.acquire(&stop).unwrap();
1155 let _b = p.acquire(&stop).unwrap();
1156 stop.store(true, Ordering::Relaxed);
1157 assert!(p.acquire(&stop).is_none());
1158 }
1159
1160 /// With the handoff slot already occupied and `stop` set, `publish` returns `false` (frame
1161 /// discarded) instead of blocking.
1162 #[test]
1163 fn publish_returns_false_when_slot_full_and_stopped() {
1164 let p = FramePool::new(3);
1165 let stop = AtomicBool::new(false);
1166 let a = p.acquire(&stop).unwrap();
1167 assert!(p.publish(dummy(a), &stop));
1168 let b = p.acquire(&stop).unwrap();
1169 stop.store(true, Ordering::Relaxed);
1170 assert!(!p.publish(dummy(b), &stop));
1171 }
1172
1173 /// `drain_for_resize` blocks until every held surface has been recycled (`free == n`), then
1174 /// reports a full drain and leaves the pool whole; a helper thread recycles the three held surfaces
1175 /// shortly after the drain begins.
1176 #[test]
1177 fn drain_for_resize_waits_until_all_free() {
1178 let p = Arc::new(FramePool::new(3));
1179 let stop = AtomicBool::new(false);
1180 let held = [
1181 p.acquire(&stop).unwrap(),
1182 p.acquire(&stop).unwrap(),
1183 p.acquire(&stop).unwrap(),
1184 ];
1185 let p2 = p.clone();
1186 let t = thread::spawn(move || {
1187 thread::sleep(Duration::from_millis(30));
1188 for idx in held {
1189 p2.recycle(idx);
1190 }
1191 });
1192 assert!(p.drain_for_resize(3, &stop));
1193 t.join().unwrap();
1194 assert!(p.acquire(&stop).is_some());
1195 }
1196
1197 /// A surface is held and never recycled (as a dead encode thread would leave it): setting
1198 /// `stop` unblocks the bounded drain, which reports it did NOT fully drain instead of hanging.
1199 #[test]
1200 fn drain_for_resize_aborts_on_stop() {
1201 let p = FramePool::new(3);
1202 let stop = AtomicBool::new(false);
1203 let _held = p.acquire(&stop).unwrap();
1204 stop.store(true, Ordering::Relaxed);
1205 assert!(!p.drain_for_resize(3, &stop));
1206 }
1207
1208 /// A W1 -> W2 -> W1 resize flap where the encoder never takes the W2 frame:
1209 /// `drain_for_resize` reclaims the un-taken frame from the slot, the surfaces recreate twice, and
1210 /// the next taken frame lands back on the ORIGINAL dimensions — so the bumped generation is the
1211 /// only rebuild signal that invalidates encoder state keyed to the reused surface addresses.
1212 #[test]
1213 fn resize_flap_reclaim_changes_generation_at_identical_dims() {
1214 let p = FramePool::new(3);
1215 let stop = AtomicBool::new(false);
1216 let a = p.acquire(&stop).unwrap();
1217 assert!(p.publish(
1218 RawFrame { generation: p.generation(), width: 1920, height: 1080, ..dummy(a) },
1219 &stop
1220 ));
1221 let f = p.take().unwrap();
1222 let (last_gen, last_dims) = (f.generation, (f.width, f.height));
1223 p.recycle(f.idx);
1224 assert!(p.drain_for_resize(3, &stop));
1225 p.bump_generation();
1226 let b = p.acquire(&stop).unwrap();
1227 assert!(p.publish(
1228 RawFrame { generation: p.generation(), width: 2560, height: 1600, ..dummy(b) },
1229 &stop
1230 ));
1231 assert!(p.drain_for_resize(3, &stop));
1232 p.bump_generation();
1233 let c = p.acquire(&stop).unwrap();
1234 assert!(p.publish(
1235 RawFrame { generation: p.generation(), width: 1920, height: 1080, ..dummy(c) },
1236 &stop
1237 ));
1238 let g = p.take().unwrap();
1239 assert_eq!((g.width, g.height), last_dims, "flap lands on identical dimensions");
1240 assert_eq!(g.generation, 2);
1241 assert_ne!(g.generation, last_gen, "generation is the only rebuild signal");
1242 }
1243
1244 /// Recreating the surfaces bumps the generation, and frames published afterwards carry the
1245 /// new value — the signal the encode thread uses to trigger a rebuild at identical dimensions.
1246 #[test]
1247 fn generation_bumps_on_recreate_and_rides_published_frames() {
1248 let p = FramePool::new(3);
1249 let stop = AtomicBool::new(false);
1250 assert_eq!(p.generation(), 0);
1251 let a = p.acquire(&stop).unwrap();
1252 assert!(p.publish(RawFrame { generation: p.generation(), ..dummy(a) }, &stop));
1253 let f = p.take().unwrap();
1254 assert_eq!(f.generation, 0);
1255 p.recycle(f.idx);
1256 p.bump_generation();
1257 let b = p.acquire(&stop).unwrap();
1258 assert!(p.publish(RawFrame { generation: p.generation(), ..dummy(b) }, &stop));
1259 assert_eq!(p.take().unwrap().generation, 1);
1260 }
1261}
1262
1263#[cfg(test)]
1264mod cursor_tests {
1265 use super::*;
1266
1267 /// `cursor_image_origin` subtracts both the hotspot and the capture-region offset from the
1268 /// reported pointer position, and goes negative when the hotspot sits near the frame origin (the
1269 /// result is clipped when drawn).
1270 #[test]
1271 fn origin_subtracts_hotspot_and_capture_offset() {
1272 assert_eq!(cursor_image_origin(100, 80, 4, 6, 0, 0), (96, 74));
1273 assert_eq!(cursor_image_origin(100, 80, 4, 6, 10, 20), (86, 54));
1274 assert_eq!(cursor_image_origin(1, 1, 8, 8, 0, 0), (-7, -7));
1275 }
1276
1277 /// `overlay_cursor` blits at the hotspot-offset origin, not the raw pointer position: a
1278 /// 2x2 cursor with hotspot (1,1) at pointer (4,4) lands at (3,3)..(4,4), leaving the un-offset
1279 /// (hotspot) corner untouched.
1280 #[test]
1281 fn overlay_blits_at_hotspot_offset_origin() {
1282 let stride = 8 * 4;
1283 let mut frame = vec![0u8; stride * 8];
1284 let pixels = [0xFFFF_FFFFu32; 4];
1285 let (ox, oy) = cursor_image_origin(4, 4, 1, 1, 0, 0);
1286 overlay_cursor(&mut frame, stride, 8, 8, 2, 2, &pixels, ox, oy);
1287 let px = |x: usize, y: usize| frame[y * stride + x * 4];
1288 assert_eq!(px(3, 3), 255);
1289 assert_eq!(px(4, 4), 255);
1290 assert_eq!(px(2, 2), 0);
1291 assert_eq!(px(5, 5), 0, "no pixel at the un-offset (hotspot) corner");
1292 }
1293
1294 #[test]
1295 fn offset_clamps_into_root() {
1296 assert_eq!(clamp_offset(100, 1920), 100);
1297 assert_eq!(clamp_offset(-50, 1920), 0);
1298 // Past-end offsets keep one root pixel instead of failing forever.
1299 assert_eq!(clamp_offset(100000, 1920), 1919);
1300 // A protocol-max root still maps into i16 X coordinates.
1301 assert_eq!(clamp_offset(40000, u16::MAX), 32766);
1302 }
1303
1304 #[test]
1305 fn resolve_dims_survives_past_end_offset() {
1306 let s = RustCaptureSettings {
1307 capture_x: 100000,
1308 capture_y: 100000,
1309 width: 1920,
1310 height: 1080,
1311 output_mode: 1,
1312 ..Default::default()
1313 };
1314 let (w, h) = resolve_dims(1920, 1080, &s);
1315 assert!(w >= 2 && h >= 2, "a past-end offset now degrades to a small grab, not death");
1316 assert_eq!(w % 2, 0);
1317 assert_eq!(h % 2, 0);
1318 }
1319
1320 /// `overlay_cursor` treats XFixes pixels as premultiplied: half-alpha at half
1321 /// intensity stays at that intensity over black (the straight-alpha formula
1322 /// would multiply alpha twice and darken it).
1323 #[test]
1324 fn overlay_blends_premultiplied_pixels() {
1325 let stride = 4 * 4;
1326 let mut frame = vec![0u8; stride * 4];
1327 // a=128, g=128 (premultiplied half-intensity), b=64.
1328 let pixels = [0x8000_8040u32; 4];
1329 overlay_cursor(&mut frame, stride, 4, 4, 2, 2, &pixels, 0, 0);
1330 assert_eq!(frame[0], 64, "blue channel: dst = 64 over black");
1331 assert_eq!(frame[1], 128, "premultiplied half-alpha stays 128, not darkened to 64");
1332 }
1333
1334 /// `overlay_cursor` clips a negative origin at the frame edge: with the origin at (-1,-1)
1335 /// only the in-frame quadrant is written, landing the cursor's bottom-right pixel at (0,0).
1336 #[test]
1337 fn overlay_clips_negative_origin_at_frame_edge() {
1338 let stride = 4 * 4;
1339 let mut frame = vec![0u8; stride * 4];
1340 let pixels = [0xFFFF_FFFFu32; 4];
1341 let (ox, oy) = cursor_image_origin(0, 0, 1, 1, 0, 0);
1342 overlay_cursor(&mut frame, stride, 4, 4, 2, 2, &pixels, ox, oy);
1343 assert_eq!(frame[0], 255);
1344 assert!(frame[4..].iter().all(|&b| b == 0), "no writes outside (0,0)");
1345 }
1346}