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