Skip to main content

pixelflux/
pipeline.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//! Frame-processing policy shared by the two capture backends. It lives in its own module for one
8//! reason: the Wayland path (dmabuf, compositor damage) and the X11 path (host-ARGB, stripe-hash
9//! damage) capture pixels in completely different ways, but a viewer must never be able to tell
10//! which one produced a frame — a paint-over refresh or a recovery keyframe has to behave
11//! identically either way. Keeping the decision logic here, source-agnostic, is what guarantees it.
12
13use crate::encoders::nvenc::NvencEncoder;
14use crate::encoders::software::{encode_cpu, EncodedStripe, StripeState};
15use crate::encoders::vaapi::VaapiEncoder;
16use crate::RustCaptureSettings;
17use std::sync::Arc;
18
19/// Outcome of the full-frame H.264 send decision produced by `decide_hw_fullframe`.
20pub struct HwFrameDecision {
21    pub send: bool,
22    pub force_idr: bool,
23    pub target_qp: u32,
24}
25
26/// Whether a scheduled keyframe is due this tick.
27///
28/// The default (`keyframe_interval_s <= 0`) is an infinite GOP with no scheduled IDRs. A positive
29/// interval buys a fixed ~N-second recovery cadence for consumers that cannot request one on demand.
30///
31/// # Arguments
32///
33/// * `settings` - Capture settings; reads `keyframe_interval_s` and `target_fps`.
34/// * `frame_counter` - Current frame number (wrapping `u16`).
35///
36/// # Returns
37///
38/// `true` if a periodic IDR should be forced on this frame.
39pub fn periodic_idr_due(settings: &RustCaptureSettings, frame_counter: u16) -> bool {
40    let secs = settings.keyframe_interval_s;
41    if secs <= 0.0 {
42        return false;
43    }
44    let safe_fps = settings.target_fps.max(1.0);
45    let interval = ((safe_fps * secs).round() as u64).max(1);
46    (frame_counter as u64).is_multiple_of(interval)
47}
48
49/// The send / quality / keyframe policy every full-frame H.264 encoder obeys.
50///
51/// A static screen costs almost nothing to stream; a client that just joined or reset can always
52/// recover a clean picture. The GOP is left infinite and an IDR is forced only when a consumer
53/// genuinely needs a fresh decode entry point. Every forced IDR is followed by a short "recovery
54/// burst" that keeps streaming until rate control converges. Both hardware encoders (NVENC /
55/// VAAPI) share this one function so they cannot drift apart; the software path applies the same
56/// policy per stripe inside `encode_cpu`.
57///
58/// Priority order: (1) recovery burst in progress, (2) always-on streaming/animated modes,
59/// (3) motion detected, (4) recovery keyframe on static screen, (5) paint-over refresh.
60///
61/// # Arguments
62///
63/// * `st` - Per-stripe mutable state carrying paint-over and burst bookkeeping.
64/// * `settings` - Capture settings; reads CRF, paint-over CRF, burst frames, trigger frames,
65///   streaming mode, and keyframe interval.
66/// * `frame_counter` - Current frame number (wrapping `u16`).
67/// * `is_dirty` - Motion signal from the backend (compositor damage or stripe-hash change).
68/// * `is_animated` - Forces a send for animated overlays.
69/// * `requested_idr` - On-demand IDR request (client join / reset / recording cadence).
70///
71/// # Returns
72///
73/// [`HwFrameDecision`] with `send` (whether to encode this frame), `force_idr` (force a
74/// keyframe), and `target_qp` (quality target for rate control).
75pub fn decide_hw_fullframe(
76    st: &mut StripeState,
77    settings: &RustCaptureSettings,
78    frame_counter: u16,
79    is_dirty: bool,
80    is_animated: bool,
81    requested_idr: bool,
82) -> HwFrameDecision {
83    let normal_qp = settings.video_crf as u32;
84    let paint_qp = settings.video_paintover_crf as u32;
85    let trigger_frames = settings.paint_over_trigger_frames;
86    let use_paint_over = settings.use_paint_over_quality;
87    let burst = settings.video_paintover_burst_frames;
88    let streaming = settings.video_streaming_mode;
89    let burst_qp = if use_paint_over && paint_qp < normal_qp { paint_qp } else { normal_qp };
90
91    let mut send_frame = false;
92    let mut force_idr = false;
93    let mut target_qp = normal_qp;
94
95    if st.h264_burst_frames_remaining > 0 {
96        send_frame = true;
97        target_qp = burst_qp;
98        st.h264_burst_frames_remaining -= 1;
99
100        if is_dirty {
101            st.h264_burst_frames_remaining = 0;
102            st.paint_over_sent = false;
103            target_qp = normal_qp;
104        }
105    }
106
107    if !send_frame && (streaming || is_animated) {
108        send_frame = true;
109    }
110
111    let recovery_idr = requested_idr || periodic_idr_due(settings, frame_counter);
112
113    if is_dirty {
114        send_frame = true;
115        force_idr = recovery_idr;
116        st.no_motion_frame_count = 0;
117        st.paint_over_sent = false;
118        st.h264_burst_frames_remaining = 0;
119        target_qp = normal_qp;
120    } else if recovery_idr {
121        send_frame = true;
122        force_idr = true;
123        if st.h264_burst_frames_remaining <= 0 {
124            target_qp = normal_qp;
125            if burst > 0 {
126                st.paint_over_sent = true;
127                st.h264_burst_frames_remaining = burst;
128            }
129        }
130    } else if !send_frame {
131        st.no_motion_frame_count += 1;
132
133        if use_paint_over
134            && st.no_motion_frame_count >= trigger_frames
135            && !st.paint_over_sent
136            && paint_qp < normal_qp
137        {
138            send_frame = true;
139            st.paint_over_sent = true;
140            target_qp = paint_qp;
141            st.h264_burst_frames_remaining = burst - 1;
142        }
143    }
144
145    HwFrameDecision { send: send_frame, force_idr, target_qp }
146}
147
148/// Hardware encoder bound to the X11 (host-ARGB) pipeline. The software path (JPEG, and H.264
149/// through the build's software encoder — `SOFTWARE_H264_ENCODER`) needs no persistent encoder
150/// object here: `encode_cpu` owns its per-stripe state, so it is `None`.
151#[allow(clippy::large_enum_variant)]
152enum X11Encoder {
153    None,
154    Nvenc(NvencEncoder),
155    Vaapi(VaapiEncoder),
156}
157
158/// Choose the full-frame encoder for the X11 host-ARGB path, following the settings and the
159/// effective encode device. Used both at construction and when a live session has to be rebuilt
160/// after a streak of encode failures.
161fn select_encoder(settings: &RustCaptureSettings) -> X11Encoder {
162    if settings.output_mode == 1 && !settings.use_cpu && settings.encode_node_index != -1 {
163        let encode_driver = crate::get_gpu_driver(settings.encode_node_index.max(0));
164        println!(
165            "[x11] Encode Node Index: {} | Driver: {}",
166            settings.encode_node_index.max(0), encode_driver
167        );
168        if !crate::driver_selects_nvenc(&encode_driver) {
169            println!("[x11] Initializing Unified VAAPI Encoder...");
170            match VaapiEncoder::new_host(settings) {
171                Ok(e) => {
172                    println!(
173                        "[x11] VAAPI Encoder initialized successfully ({}).",
174                        if e.is_fullcolor() { "4:4:4" } else { "4:2:0" }
175                    );
176                    return X11Encoder::Vaapi(e);
177                }
178                Err(err) => {
179                    eprintln!("[x11] Failed to init VAAPI: {err}. Falling back to CPU ({}).", crate::encoders::SOFTWARE_H264_ENCODER);
180                    return X11Encoder::None;
181                }
182            }
183        }
184        println!("[x11] Nvidia Encoder detected. Initializing NVENC...");
185        return match NvencEncoder::new(settings, std::ptr::null()) {
186            Ok(e) => {
187                println!("[x11] NVENC Encoder initialized successfully.");
188                X11Encoder::Nvenc(e)
189            }
190            Err(err) => {
191                eprintln!("[x11] Failed to init NVENC: {err}. Falling back to CPU ({}).", crate::encoders::SOFTWARE_H264_ENCODER);
192                X11Encoder::None
193            }
194        };
195    }
196    if settings.output_mode == 1 {
197        println!(
198            "[x11] No GPU Encoder available -> Using CPU Software Encoding ({}).",
199            crate::encoders::SOFTWARE_H264_ENCODER
200        );
201    }
202    X11Encoder::None
203}
204
205/// Everything the X11 host-ARGB path has to remember between frames.
206///
207/// Unlike the Wayland backend, X11 capture has no compositor to report what changed, so this
208/// context exists to hold the state that stands in for that missing damage signal: the per-stripe
209/// hashes and the persistent encoder session that let `process()` discover damage by comparing
210/// content frame-to-frame. Hardware full-frame H.264 runs through `decide_hw_fullframe`; the
211/// software path (JPEG, striped or full-frame software H.264) runs through `encode_cpu` with
212/// `hash_damage=true`.
213///
214/// Recording fan-out (socket sink and MP4 recorder) is handled at the delivery layer; a
215/// consumer needing a keyframe goes through [`X11Pipeline::request_idr`] like everyone else.
216pub struct X11Pipeline {
217    settings: RustCaptureSettings,
218    stripes: Vec<StripeState>,
219    /// Smoothed number of stripes carrying the encode budget (see `stripe_rate_control`).
220    stripes_carrying: f32,
221    hw: X11Encoder,
222    hw_state: StripeState,
223    frame_counter: u16,
224    pending_force_idr: bool,
225    /// Consecutive hardware encode failures and whether this pipeline already spent its one
226    /// rebuild; together they drive `recover_hw`.
227    hw_error_streak: u32,
228    hw_rebuilt: bool,
229}
230
231impl X11Pipeline {
232    /// Build the context, choosing the full-frame encoder for the X11 host-ARGB path.
233    ///
234    /// # Arguments
235    ///
236    /// * `settings` - Capture configuration. The `output_mode`, `use_cpu`, and
237    ///   `encode_node_index` fields drive encoder selection.
238    ///
239    /// # Encoder selection
240    ///
241    /// 1. **NVENC** — on an NVIDIA driver (or no detectable GPU, since the attempt is cheap).
242    /// 2. **VA-API** — on any other GPU driver. A 4:4:4 request is carried into the attempt, so a
243    ///    device that cannot encode 4:4:4 fails here and falls through rather than being ruled out
244    ///    in advance.
245    /// 3. **Software** — `X11Encoder::None`: the striped (or, with `video_fullframe`, full-frame)
246    ///    software path inside `encode_cpu`, encoding with the build's software H.264 encoder
247    ///    (`SOFTWARE_H264_ENCODER`). This is also where a 4:4:4 request lands when no hardware
248    ///    encoder can carry it; libx264 carries it, OpenH264 encodes it 4:2:0.
249    pub fn new(settings: RustCaptureSettings) -> Self {
250        let hw = select_encoder(&settings);
251        Self {
252            settings,
253            stripes: Vec::new(),
254            stripes_carrying: 1.0,
255            hw,
256            hw_state: StripeState::default(),
257            frame_counter: 0,
258            pending_force_idr: false,
259            hw_error_streak: 0,
260            hw_rebuilt: false,
261        }
262    }
263
264    /// React to a streak of hardware encode failures: rebuild the session once with the startup
265    /// selection, and demote to the software encoder when a fresh session fails the same way.
266    /// A session whose encodes keep failing still constructs, so the rebuild only counts as
267    /// recovery until an encode succeeds; otherwise the stream would rebuild in a loop and never
268    /// demote. Streaming nothing forever is not an option.
269    ///
270    /// The demote is the last rung: the software path has no encode-error streak of its own, so
271    /// once the pipeline lands there it is never re-entered.
272    fn recover_hw(&mut self) {
273        self.hw_error_streak = 0;
274        if self.hw_rebuilt {
275            eprintln!(
276                "[x11] HW encoder unrecoverable; demoting to software encoding ({}).",
277                crate::encoders::SOFTWARE_H264_ENCODER
278            );
279            // The broken session is released before its replacement is built: these failures
280            // are usually device memory pressure, and holding both at once is what would make
281            // the replacement fail too.
282            self.hw = X11Encoder::None;
283        } else {
284            eprintln!("[x11] rebuilding HW encoder after repeated encode errors.");
285            self.hw = X11Encoder::None;
286            self.hw = select_encoder(&self.settings);
287            self.hw_rebuilt = true;
288        }
289        self.hw_state = StripeState::default();
290        self.stripes.clear();
291        self.pending_force_idr = true;
292    }
293
294    /// Request an on-demand keyframe on the next processed frame.
295    pub fn request_idr(&mut self) {
296        self.pending_force_idr = true;
297    }
298
299    /// Return a human-readable encoder type string for logging.
300    pub fn encoder_name(&self) -> &str {
301        match &self.hw {
302            X11Encoder::Nvenc(_) => "NVENC",
303            X11Encoder::Vaapi(_) => "VAAPI",
304            X11Encoder::None => "CPU",
305        }
306    }
307
308    /// The `Colorspace:` field for this pipeline's stream log, describing what its encoder settled
309    /// on: VA-API only reaches 4:4:4 when the driver and FFmpeg build carry it, and the software
310    /// encoder only when the build's one does (`SOFTWARE_H264_FULLCOLOR`).
311    pub fn colorspace_desc(&self) -> &'static str {
312        let fullcolor = match &self.hw {
313            X11Encoder::Vaapi(enc) => enc.is_fullcolor(),
314            X11Encoder::Nvenc(_) => self.settings.video_fullcolor,
315            X11Encoder::None => {
316                self.settings.video_fullcolor && crate::encoders::SOFTWARE_H264_FULLCOLOR
317            }
318        };
319        crate::encoders::colorspace_desc(fullcolor, matches!(self.hw, X11Encoder::None))
320    }
321
322    /// Adapt the live pipeline to recreated capture surfaces without rebuilding it.
323    ///
324    /// # Arguments
325    ///
326    /// * `settings` - New geometry plus current live rates.
327    /// * `size_changed` - Whether the capture dimensions changed.
328    ///
329    /// # Returns
330    ///
331    /// `true` if the pipeline was successfully adapted in place; `false` when the active encoder
332    /// cannot follow (VAAPI on resize) and the caller must rebuild.
333    pub fn reshape(&mut self, settings: &RustCaptureSettings, size_changed: bool) -> bool {
334        if !size_changed {
335            if let X11Encoder::Nvenc(enc) = &mut self.hw {
336                enc.release_pinned_hosts();
337            }
338            self.settings = settings.clone();
339            return true;
340        }
341        match &mut self.hw {
342            X11Encoder::Nvenc(enc) => {
343                if let Err(e) = enc.reconfigure_resolution(settings) {
344                    eprintln!("[x11] NVENC in-place resize unavailable ({e}); rebuilding");
345                    return false;
346                }
347            }
348            X11Encoder::None => {}
349            _ => return false,
350        }
351        self.settings = settings.clone();
352        self.stripes.clear();
353        self.hw_state = StripeState::default();
354        true
355    }
356
357    /// Apply a runtime rate-control / framerate change: the CBR target bitrate + VBV (kbps /
358    /// kb; ignored unless CBR is active) and the target fps. NVENC reconfigures its live session
359    /// immediately; VAAPI re-opens its codec context to apply the new rate; the software path picks
360    /// the new values up on the next `process()` (encode_cpu reads the updated settings and
361    /// reconfigures each stripe's encoder).
362    pub fn update_rate(&mut self, bitrate_kbps: i32, vbv_multiplier: f64, fps: f64) {
363        self.settings.video_bitrate_kbps = bitrate_kbps;
364        self.settings.video_vbv_multiplier = vbv_multiplier;
365        if fps > 0.0 {
366            self.settings.target_fps = fps;
367        }
368        let rate_error = match &mut self.hw {
369            X11Encoder::Nvenc(enc) => {
370                enc.reconfigure_rate(&self.settings);
371                None
372            }
373            X11Encoder::Vaapi(enc) => enc.reconfigure_rate(&self.settings).err(),
374            X11Encoder::None => None,
375        };
376        if let Some(e) = rate_error {
377            // The failed re-open left the VA-API session without a codec context, so it goes
378            // through the rebuild-or-demote ladder instead of being encoded into.
379            eprintln!("[x11] VAAPI rate reconfigure failed: {e}");
380            self.recover_hw();
381        }
382    }
383
384    /// Apply live per-frame tunables (quality, paint-over, streaming mode, keyframe
385    /// cadence); every encoder re-reads them from the settings on the next process().
386    pub fn update_tunables(&mut self, t: &crate::LiveTunables) {
387        t.apply_to(&mut self.settings);
388    }
389
390    /// Encode one host-ARGB frame and return the encoded stripes.
391    ///
392    /// # Arguments
393    ///
394    /// * `argb` - Packed BGRA pixel buffer (B,G,R,A byte order, `stride` bytes per row).
395    /// * `stride` - Bytes per row (must equal `width * 4` for the software path).
396    ///
397    /// # Returns
398    ///
399    /// Vec of [`EncodedStripe`] — empty when nothing changed.
400    pub fn process(&mut self, argb: &[u8], stride: usize) -> Vec<EncodedStripe> {
401        let width = self.settings.width;
402        let height = self.settings.height;
403        let requested = self.pending_force_idr;
404        let threshold = self.settings.damage_block_threshold;
405        let duration = self.settings.damage_block_duration as i32;
406
407        let out = if !matches!(self.hw, X11Encoder::None) {
408            let is_dirty = if self.settings.video_streaming_mode {
409                false
410            } else {
411                self.hw_state.content_dirty(argb, threshold, duration)
412            };
413            let d = decide_hw_fullframe(
414                &mut self.hw_state,
415                &self.settings,
416                self.frame_counter,
417                is_dirty,
418                false,
419                requested,
420            );
421            if d.send {
422                let fc = self.frame_counter as u64;
423                let force_idr = d.force_idr;
424                let res = match &mut self.hw {
425                    X11Encoder::Nvenc(enc) => {
426                        enc.encode_cpu_argb(argb, stride, fc, d.target_qp, force_idr)
427                    }
428                    X11Encoder::Vaapi(enc) => {
429                        enc.encode_host_argb(argb, stride, fc, d.target_qp, force_idr)
430                    }
431                    X11Encoder::None => unreachable!(),
432                };
433                match res {
434                    Ok(data) if !data.is_empty() => {
435                        self.hw_error_streak = 0;
436                        self.hw_rebuilt = false;
437                        vec![EncodedStripe {
438                            data: Arc::new(data),
439                            data_type: 2,
440                            stripe_y_start: 0,
441                            stripe_height: height,
442                            frame_id: self.frame_counter as i32,
443                        }]
444                    }
445                    Ok(_) => {
446                        self.hw_error_streak = 0;
447                        self.hw_rebuilt = false;
448                        Vec::new()
449                    }
450                    Err(e) => {
451                        // One line per recovery window: a session failing at frame rate would
452                        // otherwise write a line per frame for the life of the capture.
453                        if self.hw_error_streak % crate::HW_ERROR_RECOVERY_THRESHOLD == 0 {
454                            eprintln!("[x11] HW encode error: {e}");
455                        }
456                        self.hw_error_streak = self.hw_error_streak.saturating_add(1);
457                        if self.hw_error_streak >= crate::HW_ERROR_RECOVERY_THRESHOLD {
458                            self.recover_hw();
459                        }
460                        Vec::new()
461                    }
462                }
463            } else {
464                Vec::new()
465            }
466        } else {
467            debug_assert_eq!(
468                stride,
469                width as usize * 4,
470                "software encode path assumes tightly-packed rows (stride == width*4)"
471            );
472            let force_idr_all = requested
473                || (self.settings.output_mode == 1
474                    && periodic_idr_due(&self.settings, self.frame_counter));
475            encode_cpu(
476                &mut self.stripes,
477                &mut self.stripes_carrying,
478                argb,
479                width,
480                height,
481                &[],
482                &self.settings,
483                self.frame_counter,
484                false,
485                true,
486                force_idr_all,
487            )
488        };
489
490        // An unserved request stays armed: on an infinite GOP an IDR lost to an encode
491        // error or skip would never self-heal, leaving a joining consumer with an
492        // undecodable stream. A rebuilt or demoted encoder arms one the same way, which
493        // is what the second read of the flag picks up.
494        self.pending_force_idr = (requested || self.pending_force_idr) && out.is_empty();
495        self.frame_counter = self.frame_counter.wrapping_add(1);
496        out
497    }
498}
499
500#[cfg(test)]
501mod tests {
502    use super::*;
503
504    /// Software JPEG path emits on change and stays silent while static: the first frame
505    /// sends every stripe (all dirty vs init), an identical static frame sends nothing, and a
506    /// frame with changed top rows re-sends the dirty stripes. `use_cpu` forces the software path
507    /// and paint-over is disabled to keep the static-frame assertions clean.
508    #[test]
509    fn x11_software_emits_on_change_and_stays_quiet_when_static() {
510        let s = RustCaptureSettings {
511            width: 128,
512            height: 128,
513            output_mode: 0,
514            use_cpu: true,
515            jpeg_quality: 60,
516            use_paint_over_quality: false,
517            ..Default::default()
518        };
519        let mut p = X11Pipeline::new(s);
520        let stride = 128 * 4;
521        let frame_a = vec![10u8; stride * 128];
522        let mut frame_b = frame_a.clone();
523        for px in frame_b.iter_mut().take(stride * 40) {
524            *px = 200;
525        }
526        let n1 = p.process(&frame_a, stride).len();
527        let n2 = p.process(&frame_a, stride).len();
528        let n3 = p.process(&frame_b, stride).len();
529        assert!(n1 > 0, "first frame should emit (all stripes dirty vs init)");
530        assert_eq!(n2, 0, "identical static frame should emit nothing");
531        assert!(n3 > 0, "changed frame should emit dirty stripes");
532    }
533
534    /// Software H.264 (the build's encoder), paint-over off, non-streaming: a requested IDR on
535    /// a static screen is followed by a short recovery burst so rate control can refine the
536    /// keyframe, instead of the stream going silent and stranding an unrefined keyframe. The
537    /// stream goes quiet again once the burst ends.
538    #[test]
539    fn x11_software_h264_streams_recovery_burst_after_requested_idr() {
540        let s = RustCaptureSettings {
541            width: 128,
542            height: 128,
543            output_mode: 1,
544            use_cpu: true,
545            video_crf: 25,
546            video_paintover_burst_frames: 5,
547            use_paint_over_quality: false,
548            video_streaming_mode: false,
549            target_fps: 60.0,
550            ..Default::default()
551        };
552        let mut p = X11Pipeline::new(s);
553        let stride = 128 * 4;
554        let frame = vec![10u8; stride * 128];
555        assert!(!p.process(&frame, stride).is_empty(), "first frame emits");
556        for _ in 0..4 {
557            let _ = p.process(&frame, stride);
558        }
559        assert!(p.process(&frame, stride).is_empty(), "static screen is quiet before the request");
560        p.request_idr();
561        assert!(!p.process(&frame, stride).is_empty(), "requested IDR emits on a static screen");
562        for i in 0..5 {
563            assert!(!p.process(&frame, stride).is_empty(), "recovery burst frame {i} streams while static");
564        }
565        assert!(p.process(&frame, stride).is_empty(), "stream goes quiet again after the recovery burst");
566    }
567
568    /// The software path carries a 4:4:4 request exactly when the build's encoder does —
569    /// libx264 at full range, so its log line says so; OpenH264 never, reporting the 4:2:0 it
570    /// encodes — and without the request it reports 4:2:0. This is the same string the Wayland log
571    /// builds from the same shared helper, so an identical session reads identically on both
572    /// backends.
573    #[test]
574    fn x11_colorspace_desc_reports_what_the_software_encoder_carries() {
575        let carries_444 = crate::encoders::SOFTWARE_H264_FULLCOLOR;
576        assert_eq!(carries_444, crate::encoders::SOFTWARE_H264_ENCODER == "x264");
577        let i444 = if carries_444 { "I444 (Full Range)" } else { "I420 (Limited Range)" };
578        for (fullcolor, expected) in [(true, i444), (false, "I420 (Limited Range)")] {
579            let p = X11Pipeline::new(RustCaptureSettings {
580                width: 64,
581                height: 64,
582                output_mode: 1,
583                use_cpu: true,
584                video_fullcolor: fullcolor,
585                ..Default::default()
586            });
587            assert_eq!(p.encoder_name(), "CPU");
588            assert_eq!(p.colorspace_desc(), expected);
589            assert_eq!(
590                p.colorspace_desc(),
591                crate::encoders::colorspace_desc(fullcolor && carries_444, true),
592                "X11 and Wayland must describe the same session identically"
593            );
594        }
595    }
596
597    fn settings() -> RustCaptureSettings {
598        RustCaptureSettings {
599            video_crf: 25,
600            video_paintover_crf: 18,
601            paint_over_trigger_frames: 3,
602            use_paint_over_quality: true,
603            video_paintover_burst_frames: 5,
604            video_streaming_mode: false,
605            target_fps: 60.0,
606            ..Default::default()
607        }
608    }
609
610    /// With no motion and no request, nothing is emitted at any frame-counter position —
611    /// the GOP is infinite, so there is no scheduled IDR to break the silence.
612    #[test]
613    fn static_frames_stay_silent_without_request() {
614        let mut s = settings();
615        s.use_paint_over_quality = false;
616        let mut st = StripeState::default();
617        for fc in [0u16, 1, 120, 240] {
618            let d = decide_hw_fullframe(&mut st, &s, fc, false, false, false);
619            assert!(!d.send && !d.force_idr, "frame {fc} should stay idle");
620        }
621    }
622
623    /// A requested IDR on a static screen (client resume / join) emits a base-QP keyframe
624    /// and arms a recovery burst; subsequent static frames stream burst frames at the paint QP
625    /// with no new keyframe, and real motion aborts the burst and reverts to the base QP.
626    #[test]
627    fn requested_idr_forces_send_even_on_static() {
628        let s = settings();
629        let mut st = StripeState::default();
630        let d = decide_hw_fullframe(&mut st, &s, 5, false, false, true);
631        assert!(d.send && d.force_idr);
632        assert_eq!(d.target_qp, 25);
633        assert_eq!(st.h264_burst_frames_remaining, 5);
634        let d = decide_hw_fullframe(&mut st, &s, 6, false, false, false);
635        assert!(d.send && !d.force_idr);
636        assert_eq!(d.target_qp, 18);
637        assert_eq!(st.h264_burst_frames_remaining, 4);
638        let d = decide_hw_fullframe(&mut st, &s, 7, true, false, false);
639        assert!(d.send && !d.force_idr);
640        assert_eq!(d.target_qp, 25);
641        assert_eq!(st.h264_burst_frames_remaining, 0);
642    }
643
644    /// With paint-over off, a forced keyframe still needs following frames so CBR rate
645    /// control can refine the static image (streaming mode would mask this by always sending);
646    /// the recovery burst supplies them at the base QP.
647    #[test]
648    fn requested_idr_recovers_even_without_paint_over() {
649        let mut s = settings();
650        s.use_paint_over_quality = false;
651        let mut st = StripeState::default();
652        let d = decide_hw_fullframe(&mut st, &s, 5, false, false, true);
653        assert!(d.send && d.force_idr);
654        assert_eq!(st.h264_burst_frames_remaining, 5, "recovery burst armed without paint-over");
655        let d = decide_hw_fullframe(&mut st, &s, 6, false, false, false);
656        assert!(d.send && !d.force_idr);
657        assert_eq!(d.target_qp, 25);
658    }
659
660    #[test]
661    fn configured_interval_restores_scheduled_keyframes() {
662        let mut s = settings();
663        s.keyframe_interval_s = 2.0;
664        assert!(periodic_idr_due(&s, 0));
665        assert!(!periodic_idr_due(&s, 1));
666        assert!(periodic_idr_due(&s, 120));
667        let mut st = StripeState::default();
668        let d = decide_hw_fullframe(&mut st, &s, 120, false, false, false);
669        assert!(d.send && d.force_idr, "interval keyframe fires on a static screen");
670        s.keyframe_interval_s = 0.0;
671        assert!(!periodic_idr_due(&s, 0) && !periodic_idr_due(&s, 120));
672    }
673
674    /// After `paint_over_trigger_frames` idle frames, paint-over fires as a refining
675    /// P-frame at the paint QP (no IDR spike) and arms a burst; the next frame continues the burst
676    /// at the paint QP, still without a forced IDR.
677    #[test]
678    fn paint_over_fires_after_trigger_then_bursts() {
679        let s = settings();
680        let mut st = StripeState::default();
681        for fc in 1..=2 {
682            let d = decide_hw_fullframe(&mut st, &s, fc, false, false, false);
683            assert!(!d.send, "frame {fc} should stay idle");
684        }
685        let d = decide_hw_fullframe(&mut st, &s, 3, false, false, false);
686        assert!(d.send && !d.force_idr);
687        assert_eq!(d.target_qp, 18);
688        assert_eq!(st.h264_burst_frames_remaining, 4);
689        let d = decide_hw_fullframe(&mut st, &s, 4, false, false, false);
690        assert!(d.send && !d.force_idr);
691        assert_eq!(d.target_qp, 18);
692        assert_eq!(st.h264_burst_frames_remaining, 3);
693    }
694
695    #[test]
696    fn motion_resets_paintover_and_uses_normal_qp() {
697        let s = settings();
698        let mut st = StripeState {
699            paint_over_sent: true,
700            h264_burst_frames_remaining: 2,
701            no_motion_frame_count: 9,
702            ..Default::default()
703        };
704        let d = decide_hw_fullframe(&mut st, &s, 7, true, false, false);
705        assert!(d.send);
706        assert_eq!(d.target_qp, 25);
707        assert!(!st.paint_over_sent);
708        assert_eq!(st.h264_burst_frames_remaining, 0);
709        assert_eq!(st.no_motion_frame_count, 0);
710    }
711}
712
713#[cfg(test)]
714mod vbv_tests {
715    /// VBV sizing policy: an infinite GOP uses 1.5 frames of headroom, scheduled keyframes
716    /// relax to 3 frames, and an explicit multiplier overrides both and rescales with bitrate.
717    #[test]
718    fn vbv_policy() {
719        use crate::encoders::vbv_bits;
720        let frame = 4_000_000f64 / 60.0;
721        assert_eq!(vbv_bits(4_000_000, 60.0, 0.0, 0.0), (frame * 1.5).round() as u32);
722        assert_eq!(vbv_bits(4_000_000, 60.0, 2.0, 0.0), (frame * 3.0).round() as u32);
723        assert_eq!(vbv_bits(4_000_000, 60.0, 2.0, 1.0), frame.round() as u32);
724        assert_eq!(vbv_bits(8_000_000, 60.0, 0.0, 1.0), (2.0 * frame).round() as u32);
725    }
726}