Skip to main content

screen_app/recp/
crossfade.rs

1//! M-RECP.5 / AUT-266 — Hot-swap crossfade state machine.
2//!
3//! Models a 150-ms cross-fade between two gst → wisp pipelines while
4//! the user swaps cameras. The state machine is pure — the GPU /
5//! wisp / gst lifecycle wiring lives in the M-CAM.3 follow-up.
6
7use std::time::Duration;
8
9/// Default crossfade duration.
10pub const CROSSFADE_DURATION: Duration = Duration::from_millis(150);
11
12/// Cross-fade lifecycle state.
13#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
14pub enum CrossfadeState {
15    /// No swap in progress.
16    #[default]
17    Steady,
18    /// New camera's pipeline is ramping in over the current one. The
19    /// `progress` value `[0, 1]` is the new camera's alpha.
20    InProgress {
21        /// 0–255 alpha for the incoming camera (255 = fully visible).
22        progress: u8,
23    },
24    /// Crossfade completed — drop old pipeline + sprite slot.
25    Settling,
26}
27
28impl CrossfadeState {
29    /// Begin a crossfade. If already in progress, the current target
30    /// is replaced with the new target (rapid third-camera click
31    /// case from the ticket spec).
32    #[must_use]
33    pub fn begin(self) -> Self {
34        Self::InProgress { progress: 0 }
35    }
36
37    /// Advance the crossfade by `elapsed` since the previous tick.
38    /// Returns `Settling` when progress reaches 1.0.
39    #[must_use]
40    pub fn tick(self, elapsed: Duration) -> Self {
41        match self {
42            Self::InProgress { progress } => {
43                let frac = elapsed.as_secs_f64() / CROSSFADE_DURATION.as_secs_f64();
44                #[allow(
45                    clippy::cast_possible_truncation,
46                    clippy::cast_sign_loss,
47                    reason = "progress is bounded [0, 255]; cast can't overflow after clamp"
48                )]
49                let delta = (frac * 255.0).round() as i32;
50                let next = i32::from(progress).saturating_add(delta);
51                if next >= 255 {
52                    Self::Settling
53                } else {
54                    #[allow(
55                        clippy::cast_possible_truncation,
56                        clippy::cast_sign_loss,
57                        reason = "next is bounded < 255 from the branch above"
58                    )]
59                    Self::InProgress {
60                        progress: next as u8,
61                    }
62                }
63            }
64            // Cancelled / steady → no-op.
65            other => other,
66        }
67    }
68
69    /// Mark settling complete — return to steady state.
70    #[must_use]
71    pub fn settled(self) -> Self {
72        match self {
73            Self::Settling => Self::Steady,
74            other => other,
75        }
76    }
77
78    /// `true` once the crossfade has reached its target alpha.
79    #[must_use]
80    pub fn is_settling(self) -> bool {
81        matches!(self, Self::Settling)
82    }
83
84    /// Current alpha for the incoming camera (0 in Steady, 255 in
85    /// Settling). Caller passes this into the wisp scene as the
86    /// secondary sprite's alpha multiplier.
87    #[must_use]
88    pub fn incoming_alpha(self) -> u8 {
89        match self {
90            Self::Steady => 0,
91            Self::InProgress { progress } => progress,
92            Self::Settling => 255,
93        }
94    }
95}
96
97#[cfg(test)]
98mod tests {
99    use super::*;
100
101    #[test]
102    fn default_is_steady() {
103        assert_eq!(CrossfadeState::default(), CrossfadeState::Steady);
104    }
105
106    #[test]
107    fn begin_starts_at_zero() {
108        let s = CrossfadeState::default().begin();
109        assert_eq!(s, CrossfadeState::InProgress { progress: 0 });
110        assert_eq!(s.incoming_alpha(), 0);
111    }
112
113    #[test]
114    fn tick_advances_proportionally() {
115        let s = CrossfadeState::default().begin();
116        // Half the duration → ~128 alpha.
117        let s = s.tick(CROSSFADE_DURATION / 2);
118        if let CrossfadeState::InProgress { progress } = s {
119            assert!((120..=135).contains(&progress), "progress = {progress}");
120        } else {
121            panic!("expected InProgress, got {s:?}");
122        }
123    }
124
125    #[test]
126    fn full_duration_reaches_settling() {
127        let s = CrossfadeState::default().begin();
128        let s = s.tick(CROSSFADE_DURATION);
129        assert!(s.is_settling());
130        assert_eq!(s.incoming_alpha(), 255);
131    }
132
133    #[test]
134    fn settled_returns_to_steady() {
135        let s = CrossfadeState::Settling.settled();
136        assert_eq!(s, CrossfadeState::Steady);
137    }
138
139    #[test]
140    fn third_camera_click_mid_crossfade_resets_progress() {
141        let s = CrossfadeState::InProgress { progress: 128 }.begin();
142        // Re-begin during in-progress resets to 0 (camera target
143        // changed).
144        assert_eq!(s, CrossfadeState::InProgress { progress: 0 });
145    }
146}