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}