Skip to main content

pixelflux/
report.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//! What a capture streams and how it got there, for the caller to show its user: the capture
8//! path and whether it is zero-copy, the encoder and whether it is hardware, the GPU behind
9//! it, and the reason a faster path was declined. The same facts the tagged log lines state,
10//! kept as values so nobody has to read a log to learn them.
11//!
12//! A capture's decisions are spread over three backends and the shared encoder ladder, each
13//! made on the thread that owns the capture being built, so the report a decision lands in is
14//! the one bound to the deciding thread ([`enter`]) rather than an argument threaded through
15//! every site. A decision made with no report bound, as in a test or a probe, records nothing.
16//!
17//! Counters are relaxed atomics tallied once per delivered frame and read only when the
18//! caller asks, so a capture nobody inspects pays for a few additions and nothing else. The
19//! extremes of a window (the fastest and slowest frame from capture to the end of its encode,
20//! and the largest frame) are kept the same way and swapped back on each read, so a reader
21//! that asks once a second learns that second's.
22
23use std::cell::RefCell;
24use std::collections::HashMap;
25use std::sync::atomic::{AtomicU64, Ordering};
26use std::sync::{Arc, Mutex, OnceLock};
27
28use crate::RustCaptureSettings;
29use crate::encoders::software::EncodedStripe;
30
31/// The description half of a report. `capture_reason` says why the capture is not zero-copy
32/// and `encoder_reason` why the encoder is not hardware; both are empty where there is
33/// nothing to explain. `zero_copy_available` says whether the display server offered this
34/// session's encoder a zero-copy path at all, so a readback where it did falls short of one
35/// and a readback where it did not is how the host is built.
36#[derive(Clone, Debug, Default, PartialEq)]
37pub struct StreamInfo {
38    pub backend: &'static str,
39    pub capture: &'static str,
40    pub zero_copy: bool,
41    pub zero_copy_available: bool,
42    pub capture_reason: String,
43    pub encoder: String,
44    pub hardware: bool,
45    pub encoder_reason: String,
46    pub codec: &'static str,
47    pub fullcolor: bool,
48    pub bit_depth: u32,
49    pub full_range: bool,
50    pub stripes: usize,
51    pub gpu: String,
52    pub driver: String,
53    pub encode_node: i32,
54}
55
56/// Cumulative counters of a capture since it started.
57#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
58pub struct StreamTotals {
59    pub frames: u64,
60    pub bytes: u64,
61    pub encode_ns: u64,
62    pub pipeline_ns: u64,
63}
64
65/// The extremes of the frames delivered since the previous read: the least and the most time
66/// from capture to the end of the encode, zero where no frame was timed, and the largest frame.
67#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
68pub struct StreamPeaks {
69    pub pipeline_min_ns: u64,
70    pub pipeline_max_ns: u64,
71    pub frame_max_bytes: u64,
72}
73
74/// One capture's description and counters.
75pub struct StreamReport {
76    info: Mutex<StreamInfo>,
77    frames: AtomicU64,
78    bytes: AtomicU64,
79    encode_ns: AtomicU64,
80    pipeline_ns: AtomicU64,
81    /// `u64::MAX` while the window holds no timed frame.
82    pipeline_min_ns: AtomicU64,
83    pipeline_max_ns: AtomicU64,
84    frame_max_bytes: AtomicU64,
85}
86
87impl Default for StreamReport {
88    fn default() -> Self {
89        Self {
90            info: Mutex::default(),
91            frames: AtomicU64::new(0),
92            bytes: AtomicU64::new(0),
93            encode_ns: AtomicU64::new(0),
94            pipeline_ns: AtomicU64::new(0),
95            pipeline_min_ns: AtomicU64::new(u64::MAX),
96            pipeline_max_ns: AtomicU64::new(0),
97            frame_max_bytes: AtomicU64::new(0),
98        }
99    }
100}
101
102impl StreamReport {
103    pub fn new(backend: &'static str) -> Arc<Self> {
104        let report = Self::default();
105        report.info.lock().unwrap().backend = backend;
106        Arc::new(report)
107    }
108
109    pub fn info(&self) -> StreamInfo {
110        self.info.lock().unwrap().clone()
111    }
112
113    pub fn totals(&self) -> StreamTotals {
114        StreamTotals {
115            frames: self.frames.load(Ordering::Relaxed),
116            bytes: self.bytes.load(Ordering::Relaxed),
117            encode_ns: self.encode_ns.load(Ordering::Relaxed),
118            pipeline_ns: self.pipeline_ns.load(Ordering::Relaxed),
119        }
120    }
121
122    /// The extremes since the previous call, which starts the next window.
123    pub fn take_peaks(&self) -> StreamPeaks {
124        let min = self.pipeline_min_ns.swap(u64::MAX, Ordering::Relaxed);
125        StreamPeaks {
126            pipeline_min_ns: if min == u64::MAX { 0 } else { min },
127            pipeline_max_ns: self.pipeline_max_ns.swap(0, Ordering::Relaxed),
128            frame_max_bytes: self.frame_max_bytes.swap(0, Ordering::Relaxed),
129        }
130    }
131
132    /// Count one delivered frame: its bytes over every stripe, the time its encode took, and
133    /// the time from capture to the end of the encode.
134    pub fn tally(&self, stripes: &[EncodedStripe]) {
135        let Some(first) = stripes.first() else { return };
136        let bytes: usize = stripes.iter().map(|s| s.data.len()).sum();
137        let timing = first.timing;
138        self.frames.fetch_add(1, Ordering::Relaxed);
139        self.bytes.fetch_add(bytes as u64, Ordering::Relaxed);
140        self.frame_max_bytes
141            .fetch_max(bytes as u64, Ordering::Relaxed);
142        if timing.encode_end_ns > 0 {
143            let encode = (timing.encode_end_ns - timing.encode_start_ns).max(0) as u64;
144            let pipeline = (timing.encode_end_ns - timing.capture_ns).max(0) as u64;
145            self.encode_ns.fetch_add(encode, Ordering::Relaxed);
146            self.pipeline_ns.fetch_add(pipeline, Ordering::Relaxed);
147            self.pipeline_min_ns.fetch_min(pipeline, Ordering::Relaxed);
148            self.pipeline_max_ns.fetch_max(pipeline, Ordering::Relaxed);
149        }
150    }
151
152    fn update(&self, change: impl FnOnce(&mut StreamInfo)) {
153        change(&mut self.info.lock().unwrap());
154    }
155}
156
157thread_local! {
158    static CURRENT: RefCell<Option<Arc<StreamReport>>> = const { RefCell::new(None) };
159}
160
161/// Restores the report that was bound before [`enter`].
162pub struct Scope(Option<Arc<StreamReport>>);
163
164impl Drop for Scope {
165    fn drop(&mut self) {
166        CURRENT.with(|current| *current.borrow_mut() = self.0.take());
167    }
168}
169
170/// Bind `report` to this thread until the scope drops: every decision recorded meanwhile
171/// lands in it. The Wayland event loop serves every display, so it binds per capture start
172/// and per recovery; a capture's own threads bind once.
173pub fn enter(report: &Arc<StreamReport>) -> Scope {
174    Scope(CURRENT.with(|current| current.borrow_mut().replace(report.clone())))
175}
176
177fn record(change: impl FnOnce(&mut StreamInfo)) {
178    CURRENT.with(|current| {
179        if let Some(report) = current.borrow().as_ref() {
180            report.update(change);
181        }
182    });
183}
184
185/// The capture path taken. A zero-copy path needs no reason, so the ones collected from the
186/// paths declined ahead of it are dropped.
187pub fn capture(path: &'static str, zero_copy: bool) {
188    record(|info| {
189        info.capture = path;
190        info.zero_copy = zero_copy;
191        if zero_copy {
192            info.zero_copy_available = true;
193            info.capture_reason.clear();
194        }
195    });
196}
197
198/// Whether the display server offers this session's encoder a zero-copy path, recorded where
199/// a backend finds the server's side of it there (NvFBC on an X server the NVIDIA driver
200/// drives, DRI3 on one drawing on the encode node's GPU, a compositor rendering on that GPU)
201/// and where it finds it gone (a host compositor handing out software frames only).
202pub fn zero_copy_available(available: bool) {
203    record(|info| info.zero_copy_available = available);
204}
205
206/// Why a zero-copy path was not taken. A capture that declines several keeps each reason.
207pub fn capture_declined(path: &str, reason: &str) {
208    record(|info| {
209        if !info.capture_reason.is_empty() {
210            info.capture_reason.push_str("; ");
211        }
212        info.capture_reason.push_str(&format!("{path}: {reason}"));
213    });
214}
215
216/// Why the capture is not zero-copy, replacing whatever was collected.
217pub fn capture_reason(reason: &str) {
218    record(|info| info.capture_reason = reason.to_string());
219}
220
221/// The hardware session the ladder opened, and the device it runs on.
222pub fn hardware_encoder(gpu: &str, driver: &str, node: i32) {
223    record(|info| {
224        info.gpu = gpu.to_string();
225        info.driver = driver.to_string();
226        info.encode_node = node;
227        info.encoder_reason.clear();
228    });
229}
230
231/// Why the session does not encode in hardware.
232pub fn encoder_reason(reason: &str) {
233    record(|info| info.encoder_reason = reason.to_string());
234}
235
236/// What the stream settled on, from the same inputs as the `Stream settings active` line.
237/// `backend` is the full-frame session as `(name, hardware)`, `None` the striped software path.
238pub fn stream(
239    settings: &RustCaptureSettings,
240    stripes: usize,
241    backend: Option<(&str, bool)>,
242    fullcolor: bool,
243    full_range: bool,
244) {
245    record(|info| {
246        let (name, hardware) = match backend {
247            Some((name, hardware)) => (name, hardware),
248            None if settings.codec.is_video() => {
249                (crate::encoders::software_library(settings.codec), false)
250            }
251            None => ("turbojpeg", false),
252        };
253        info.encoder = name.to_string();
254        info.hardware = hardware;
255        if !hardware {
256            info.gpu.clear();
257            info.driver.clear();
258            info.encode_node = -1;
259        }
260        info.codec = settings.codec.name();
261        info.fullcolor = fullcolor;
262        info.bit_depth = 8;
263        info.full_range = full_range;
264        info.stripes = stripes;
265    });
266}
267
268/// The bits per sample the session's encoder runs at, where that is not the 8 `stream` records.
269pub fn bit_depth(depth: u32) {
270    record(|info| info.bit_depth = depth);
271}
272
273/// How the Wayland compositor renders, which every capture of it shares: `gl` on a render
274/// node with the renderer's own name for the GPU, or `pixman` in software with the reason.
275#[derive(Clone, Debug, Default)]
276pub struct Renderer {
277    pub kind: &'static str,
278    pub node: String,
279    pub gpu: String,
280    pub reason: String,
281}
282
283static RENDERER: OnceLock<Mutex<Renderer>> = OnceLock::new();
284
285pub fn set_renderer(renderer: Renderer) {
286    *RENDERER.get_or_init(Default::default).lock().unwrap() = renderer;
287}
288
289pub fn renderer() -> Renderer {
290    RENDERER
291        .get_or_init(Default::default)
292        .lock()
293        .unwrap()
294        .clone()
295}
296
297static WAYLAND: OnceLock<Mutex<HashMap<u32, Arc<StreamReport>>>> = OnceLock::new();
298
299/// The report of each Wayland display's capture, read by `ScreenCapture::stream_info`
300/// without a round trip to the compositor thread.
301pub fn wayland_reports() -> &'static Mutex<HashMap<u32, Arc<StreamReport>>> {
302    WAYLAND.get_or_init(Default::default)
303}
304
305#[cfg(test)]
306mod tests {
307    use super::*;
308
309    /// A decision lands in the report bound to the thread and nowhere without one, and a
310    /// zero-copy path drops the reasons collected ahead of it.
311    #[test]
312    fn decisions_land_in_the_bound_report() {
313        capture_declined("NvFBC", "unbound");
314        let report = StreamReport::new("x11");
315        {
316            let _scope = enter(&report);
317            capture_declined("NvFBC", "the codec is JPEG");
318            capture_declined("DRI3", "no DRI3");
319            capture("XShm", false);
320        }
321        capture_declined("NvFBC", "unbound");
322        let info = report.info();
323        assert_eq!(info.backend, "x11");
324        assert_eq!(info.capture, "XShm");
325        assert_eq!(
326            info.capture_reason,
327            "NvFBC: the codec is JPEG; DRI3: no DRI3"
328        );
329        assert!(!info.zero_copy_available);
330        {
331            let _scope = enter(&report);
332            capture("DRI3", true);
333        }
334        assert!(report.info().zero_copy);
335        assert!(report.info().zero_copy_available);
336        assert!(report.info().capture_reason.is_empty());
337    }
338
339    /// A readback where the server offered a zero-copy path says so, and one where it offered
340    /// none says that instead, whatever reasons the declined paths left.
341    #[test]
342    fn a_readback_says_whether_a_zero_copy_path_was_offered() {
343        let offered = StreamReport::new("x11");
344        {
345            let _scope = enter(&offered);
346            capture_declined("NvFBC", "the X server offers no NV-GLX");
347            zero_copy_available(true);
348            capture_declined("DRI3", "the encoder could not read the server's buffer");
349            capture("XShm", false);
350        }
351        assert!(!offered.info().zero_copy);
352        assert!(offered.info().zero_copy_available);
353        let host = StreamReport::new("wayland");
354        {
355            let _scope = enter(&host);
356            capture("dmabuf", true);
357            capture("readback", false);
358            zero_copy_available(false);
359            capture_reason("the host compositor delivers software frames only");
360        }
361        assert!(!host.info().zero_copy);
362        assert!(!host.info().zero_copy_available);
363    }
364
365    /// A read takes the window's extremes and starts the next one: the fastest and slowest
366    /// frame from capture to encoded, and the largest, over every stripe of it; a window with
367    /// no timed frame reads zero for both times.
368    #[test]
369    fn peaks_cover_one_window_and_restart_on_read() {
370        use crate::encoders::codec::Codec;
371        use crate::encoders::reference::Reference;
372        use crate::encoders::software::FrameTiming;
373        let stripe = |bytes: usize, capture: i64, end: i64| EncodedStripe {
374            data: Arc::new(vec![0; bytes]),
375            codec: Codec::H264,
376            stripe_y_start: 0,
377            stripe_height: 16,
378            frame_id: 0,
379            timing: FrameTiming {
380                capture_ns: capture,
381                encode_start_ns: end - 1_000_000,
382                encode_end_ns: end,
383            },
384            reference: Reference::Untracked,
385        };
386        let report = StreamReport::new("x11");
387        report.tally(&[stripe(1000, 10_000_000, 14_000_000)]);
388        report.tally(&[
389            stripe(700, 20_000_000, 29_000_000),
390            stripe(800, 20_000_000, 29_000_000),
391        ]);
392        report.tally(&[stripe(300, 30_000_000, 36_000_000)]);
393        let peaks = report.take_peaks();
394        assert_eq!(
395            peaks,
396            StreamPeaks {
397                pipeline_min_ns: 4_000_000,
398                pipeline_max_ns: 9_000_000,
399                frame_max_bytes: 1500,
400            }
401        );
402        assert_eq!(report.totals().pipeline_ns, 19_000_000);
403        assert_eq!(report.take_peaks(), StreamPeaks::default());
404        report.tally(&[stripe(200, 0, 0)]);
405        assert_eq!(
406            report.take_peaks(),
407            StreamPeaks {
408                frame_max_bytes: 200,
409                ..StreamPeaks::default()
410            }
411        );
412    }
413
414    /// Scopes nest, so a capture start on a thread already serving another restores it.
415    #[test]
416    fn scopes_restore_the_outer_report() {
417        let outer = StreamReport::new("wayland");
418        let inner = StreamReport::new("wayland");
419        let _outer_scope = enter(&outer);
420        {
421            let _inner_scope = enter(&inner);
422            encoder_reason("inner");
423        }
424        encoder_reason("outer");
425        assert_eq!(inner.info().encoder_reason, "inner");
426        assert_eq!(outer.info().encoder_reason, "outer");
427    }
428}