Skip to main content

edit/
zoom.rs

1//! Zoom regions — cinematic punch-ins on the project timeline.
2//!
3//! A [`ZoomSegment`] is a value type: a window of project frames, a zoom
4//! `amount`, a target ([`ZoomMode`]), and an easing curve. At the render
5//! boundary (ED.16) each compiles to a keyframed `wisp` transform
6//! (scale + translate) applied to the screen sprite. Keeping the model
7//! `wisp`-free here means the zoom math is unit-testable without a GPU.
8
9use serde::{Deserialize, Serialize};
10
11use crate::segment::Frame;
12
13/// Stable identifier for a [`ZoomSegment`] within a project, so edit
14/// operations (move / remove) can address one without relying on its
15/// index, which shifts as zooms are added and removed.
16#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)]
17pub struct ZoomId(pub u32);
18
19/// Easing curve for a zoom's in/out animation. A deliberately small,
20/// serde-friendly subset of the full `wisp_animation::Ease` set (which
21/// includes a non-serializable function-pointer variant). The renderer
22/// maps these to `wisp_animation::Ease` at ED.13/ED.16.
23///
24/// The default, [`EditEase::InOutCubic`], is the "Easy Ease" equivalent —
25/// smooth acceleration in and deceleration out, which covers the vast
26/// majority of zoom feels before any manual curve editing.
27#[derive(Clone, Copy, Debug, PartialEq, Eq, Default, Serialize, Deserialize)]
28#[serde(rename_all = "snake_case")]
29pub enum EditEase {
30    /// Constant rate — mechanical, rarely wanted for a zoom.
31    Linear,
32    /// Accelerate in.
33    InCubic,
34    /// Decelerate out.
35    OutCubic,
36    /// Smooth in and out — the "Easy Ease" default.
37    #[default]
38    InOutCubic,
39    /// Gentle sinusoidal in and out.
40    InOutSine,
41}
42
43impl EditEase {
44    /// Evaluate the curve at normalized time `t`, returning eased progress.
45    /// `t` is clamped to `0.0..=1.0`; every curve maps `0.0 → 0.0` and
46    /// `1.0 → 1.0`. The zoom engine (ED.16) feeds this the ramp fraction.
47    #[must_use]
48    pub fn eval(self, t: f64) -> f64 {
49        let t = t.clamp(0.0, 1.0);
50        match self {
51            Self::Linear => t,
52            Self::InCubic => t * t * t,
53            Self::OutCubic => {
54                let u = 1.0 - t;
55                1.0 - u * u * u
56            }
57            Self::InOutCubic => {
58                if t < 0.5 {
59                    4.0 * t * t * t
60                } else {
61                    let u = -2.0 * t + 2.0;
62                    1.0 - (u * u * u) / 2.0
63                }
64            }
65            Self::InOutSine => -((std::f64::consts::PI * t).cos() - 1.0) / 2.0,
66        }
67    }
68}
69
70/// Where a zoom targets.
71#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)]
72#[serde(tag = "kind", rename_all = "snake_case")]
73pub enum ZoomMode {
74    /// Target is chosen automatically (e.g. from cursor/click telemetry,
75    /// ED.17) or follows the cursor while held.
76    Auto,
77    /// Target a fixed point, normalized to `[0, 1]` in the composed
78    /// frame: `(0, 0)` is top-left, `(1, 1)` bottom-right.
79    Manual {
80        /// Horizontal target, `0.0..=1.0`.
81        x: f32,
82        /// Vertical target, `0.0..=1.0`.
83        y: f32,
84    },
85}
86
87impl Default for ZoomMode {
88    fn default() -> Self {
89        // Centre-targeted manual zoom is the most predictable default for
90        // a freshly added region; Auto is opt-in (telemetry-driven).
91        Self::Manual { x: 0.5, y: 0.5 }
92    }
93}
94
95/// A cinematic punch-in over `[start, end)` project frames.
96#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)]
97pub struct ZoomSegment {
98    /// Stable identifier within the project.
99    pub id: ZoomId,
100    /// First project frame the zoom affects (inclusive).
101    pub start: Frame,
102    /// One past the last project frame the zoom affects (exclusive).
103    pub end: Frame,
104    /// Zoom factor at the hold. `1.0` = no zoom, `1.6` = 1.6× punch-in.
105    /// A zoom out is unusual for a screen recording, so the zoom engine
106    /// ([`crate::zoom_anim::zoom_at`]) clamps the produced scale to
107    /// `>= 1.0` — an `amount < 1.0` reads as no zoom rather than a shrink.
108    pub amount: f64,
109    /// What the zoom targets.
110    #[serde(default)]
111    pub mode: ZoomMode,
112    /// Easing of the in/out animation.
113    #[serde(default)]
114    pub ease: EditEase,
115}
116
117impl ZoomSegment {
118    /// A manual, centre-targeted zoom of `amount` over `[start, end)`
119    /// with the default Easy-Ease curve.
120    #[must_use]
121    pub fn manual(id: ZoomId, start: Frame, end: Frame, amount: f64) -> Self {
122        Self {
123            id,
124            start,
125            end,
126            amount,
127            mode: ZoomMode::default(),
128            ease: EditEase::default(),
129        }
130    }
131
132    /// Length of the zoom window in project frames (saturating at 0).
133    #[must_use]
134    pub fn len(self) -> Frame {
135        self.end.saturating_sub(self.start)
136    }
137
138    /// Whether the zoom window is empty (`end <= start`).
139    #[must_use]
140    pub fn is_empty(self) -> bool {
141        self.end <= self.start
142    }
143
144    /// Whether `project_frame` falls within this zoom's window.
145    #[must_use]
146    pub fn contains(self, project_frame: Frame) -> bool {
147        project_frame >= self.start && project_frame < self.end
148    }
149}
150
151#[cfg(test)]
152mod tests {
153    use super::*;
154
155    #[test]
156    fn default_ease_is_easy_ease() {
157        assert_eq!(EditEase::default(), EditEase::InOutCubic);
158    }
159
160    #[test]
161    fn default_mode_is_centre_manual() {
162        assert_eq!(ZoomMode::default(), ZoomMode::Manual { x: 0.5, y: 0.5 });
163    }
164
165    #[test]
166    fn manual_builder_sets_window_and_amount() {
167        let z = ZoomSegment::manual(ZoomId(7), 30, 90, 1.6);
168        assert_eq!(z.id, ZoomId(7));
169        assert_eq!(z.len(), 60);
170        assert!(!z.is_empty());
171        assert!((z.amount - 1.6).abs() < 1e-9);
172        assert!(z.contains(30));
173        assert!(z.contains(89));
174        assert!(!z.contains(90));
175        assert!(!z.contains(29));
176    }
177
178    #[test]
179    fn empty_window_reports_empty() {
180        let z = ZoomSegment::manual(ZoomId(0), 50, 50, 2.0);
181        assert!(z.is_empty());
182        assert_eq!(z.len(), 0);
183        assert!(!z.contains(50));
184    }
185
186    #[test]
187    fn ease_endpoints_are_zero_and_one() {
188        for ease in [
189            EditEase::Linear,
190            EditEase::InCubic,
191            EditEase::OutCubic,
192            EditEase::InOutCubic,
193            EditEase::InOutSine,
194        ] {
195            assert!(ease.eval(0.0).abs() < 1e-9, "{ease:?} f(0)=0");
196            assert!((ease.eval(1.0) - 1.0).abs() < 1e-9, "{ease:?} f(1)=1");
197            // Clamped outside the unit interval.
198            assert!(ease.eval(-1.0).abs() < 1e-9);
199            assert!((ease.eval(2.0) - 1.0).abs() < 1e-9);
200        }
201    }
202
203    #[test]
204    fn ease_is_monotonic_nondecreasing() {
205        for ease in [EditEase::Linear, EditEase::InOutCubic, EditEase::InOutSine] {
206            let mut prev = ease.eval(0.0);
207            for i in 1..=20 {
208                let t = f64::from(i) / 20.0;
209                let v = ease.eval(t);
210                assert!(v + 1e-9 >= prev, "{ease:?} non-decreasing at t={t}");
211                prev = v;
212            }
213        }
214    }
215
216    #[test]
217    fn inout_curves_cross_half_at_midpoint() {
218        assert!((EditEase::InOutCubic.eval(0.5) - 0.5).abs() < 1e-9);
219        assert!((EditEase::InOutSine.eval(0.5) - 0.5).abs() < 1e-9);
220        assert!((EditEase::Linear.eval(0.5) - 0.5).abs() < 1e-9);
221    }
222}