Skip to main content

edit/
zoom_anim.rs

1//! The zoom engine — a [`ZoomSegment`] as a pure function of the frame.
2//!
3//! On an animation stand, the rostrum camera pushes in by riding a screw
4//! drive toward the artwork — a slow, eased move from wide to tight and
5//! back. This module is that move in software: given a zoom region and a
6//! project frame, it returns the [`ZoomTransform`] (a scale about a focal
7//! point) the renderer applies to the composed frame. It is the heart of
8//! the editor's signature look, and — because it is GPU-free arithmetic —
9//! it is exhaustively unit-testable. Preview and export call the *same*
10//! function, so what you scrub is what you ship.
11//!
12//! The profile is three phases over a zoom's `[start, end)` window: an
13//! eased **ramp-in** from no-zoom to full `amount`, a **hold** at full,
14//! and a symmetric eased **ramp-out** back to no-zoom. The ramp length is
15//! clamped to half the window so the two ramps never overlap — a short
16//! zoom degrades gracefully to a triangle (push-in straight into
17//! push-out, no hold) instead of fighting itself.
18
19use crate::project::EditProject;
20use crate::segment::Frame;
21use crate::zoom::{ZoomMode, ZoomSegment};
22
23/// A scale-about-a-focal-point transform for one composed frame.
24///
25/// The renderer scales the framed screen by `scale` (`>= 1.0`) about the
26/// normalized focal point `(center_x, center_y)` in `[0, 1]`. At identity
27/// (`scale == 1.0`) the focal point is irrelevant — no push-in is applied.
28#[derive(Clone, Copy, Debug, PartialEq)]
29pub struct ZoomTransform {
30    /// Zoom factor, `>= 1.0`. `1.0` is no zoom.
31    pub scale: f64,
32    /// Horizontal focal point, `0.0..=1.0` (`0` left, `1` right).
33    pub center_x: f64,
34    /// Vertical focal point, `0.0..=1.0` (`0` top, `1` bottom).
35    pub center_y: f64,
36}
37
38impl ZoomTransform {
39    /// The no-zoom transform — full frame, centred.
40    #[must_use]
41    pub fn identity() -> Self {
42        Self {
43            scale: 1.0,
44            center_x: 0.5,
45            center_y: 0.5,
46        }
47    }
48
49    /// Whether this transform is (approximately) no-zoom.
50    #[must_use]
51    pub fn is_identity(self) -> bool {
52        (self.scale - 1.0).abs() < 1e-6
53    }
54}
55
56impl Default for ZoomTransform {
57    fn default() -> Self {
58        Self::identity()
59    }
60}
61
62/// A sensible ramp length for `fps`: about 0.6 s of ease-in / ease-out —
63/// the cinematic-but-snappy feel screen recorders converge on — floored
64/// at a single frame so even a 1 fps project still animates.
65#[must_use]
66pub fn default_ramp_frames(fps: u32) -> Frame {
67    (Frame::from(fps) * 6 / 10).max(1)
68}
69
70/// `num / den` as `f64`, via a lossless `u32` hop (frame counts are tiny;
71/// `den` is always `> 0` at the call sites below).
72fn frac(num: Frame, den: Frame) -> f64 {
73    let n = u32::try_from(num).unwrap_or(u32::MAX);
74    let d = u32::try_from(den).unwrap_or(u32::MAX);
75    f64::from(n) / f64::from(d)
76}
77
78/// The zoom transform at project `frame` for one segment.
79///
80/// Ramps identity → `seg.amount` → identity over `[start, end)` using the
81/// segment's ease. `ramp_frames` is the desired in/out ramp; it is clamped
82/// to half the window so the ramps never overlap. Outside the window (or
83/// for an empty window) the result is [`ZoomTransform::identity`].
84#[must_use]
85pub fn zoom_at(seg: &ZoomSegment, frame: Frame, ramp_frames: Frame) -> ZoomTransform {
86    if seg.is_empty() || !seg.contains(frame) {
87        return ZoomTransform::identity();
88    }
89    let (center_x, center_y) = match seg.mode {
90        ZoomMode::Manual { x, y } => (f64::from(x), f64::from(y)),
91        // Auto targets become concrete points via click telemetry (ED.17);
92        // until resolved, an Auto zoom punches into the frame centre.
93        ZoomMode::Auto => (0.5, 0.5),
94    };
95    let len = seg.len();
96    let ramp = ramp_frames.min(len / 2);
97    let local = frame - seg.start;
98    let progress = if ramp == 0 {
99        1.0
100    } else if local < ramp {
101        seg.ease.eval(frac(local, ramp))
102    } else if local >= len - ramp {
103        seg.ease.eval(frac(len - local, ramp))
104    } else {
105        1.0
106    };
107    // Clamp to `>= 1.0`: a zoom is a push-in, never a shrink. An
108    // `amount < 1.0` (only reachable via a raw `AddZoom` / deserialized
109    // project) reads as no zoom rather than an undocumented zoom-out.
110    let scale = (1.0 + (seg.amount - 1.0) * progress).max(1.0);
111    ZoomTransform {
112        scale,
113        center_x,
114        center_y,
115    }
116}
117
118/// The active zoom transform at project `frame` across the whole project:
119/// the transform of the first zoom region whose window contains `frame`,
120/// or [`ZoomTransform::identity`] when none do. The ramp is derived from
121/// the project's frame rate.
122#[must_use]
123pub fn active_zoom_at(project: &EditProject, frame: Frame) -> ZoomTransform {
124    let ramp = default_ramp_frames(project.project_fps);
125    project
126        .zooms
127        .iter()
128        .find(|z| z.contains(frame))
129        .map_or_else(ZoomTransform::identity, |z| zoom_at(z, frame, ramp))
130}
131
132/// A dopesheet keyframe for a zoom: a project frame and the scale at it.
133#[derive(Clone, Copy, Debug, PartialEq)]
134pub struct ZoomKeyframe {
135    /// Project frame of the keyframe.
136    pub frame: Frame,
137    /// Scale at the keyframe (`1.0` = no zoom; `amount` across the hold).
138    pub scale: f64,
139}
140
141/// The keyframes of a zoom's scale curve, for the dopesheet (ED.13):
142/// identity at both edges, full `amount` across the hold, eased between.
143/// Four keyframes when there's a hold; three (a triangle peak) when the
144/// ramps fill the window. This is the dopesheet's view of [`zoom_at`] —
145/// pass the same `ramp_frames`.
146#[must_use]
147pub fn zoom_keyframes(seg: &ZoomSegment, ramp_frames: Frame) -> Vec<ZoomKeyframe> {
148    if seg.is_empty() {
149        return Vec::new();
150    }
151    let ramp = ramp_frames.min(seg.len() / 2);
152    // Match `zoom_at`'s clamp so the dopesheet curve never shows a
153    // zoom-out for a sub-1.0 amount.
154    let amount = seg.amount.max(1.0);
155    let in_peak = seg.start + ramp;
156    let out_peak = seg.end.saturating_sub(ramp);
157    let mut kfs = vec![ZoomKeyframe {
158        frame: seg.start,
159        scale: 1.0,
160    }];
161    kfs.push(ZoomKeyframe {
162        frame: in_peak,
163        scale: amount,
164    });
165    if out_peak > in_peak {
166        kfs.push(ZoomKeyframe {
167            frame: out_peak,
168            scale: amount,
169        });
170    }
171    kfs.push(ZoomKeyframe {
172        frame: seg.end,
173        scale: 1.0,
174    });
175    kfs
176}
177
178#[cfg(test)]
179mod tests {
180    use super::*;
181    use crate::clip::ClipRef;
182    use crate::zoom::{ZoomId, ZoomMode, ZoomSegment};
183    use std::path::PathBuf;
184
185    /// A 100-frame, 2× centre zoom over `[100, 200)`.
186    fn seg() -> ZoomSegment {
187        ZoomSegment::manual(ZoomId(1), 100, 200, 2.0)
188    }
189
190    #[test]
191    fn identity_outside_window() {
192        let z = seg();
193        assert!(zoom_at(&z, 99, 10).is_identity());
194        assert!(zoom_at(&z, 200, 10).is_identity());
195        assert_eq!(zoom_at(&z, 50, 10), ZoomTransform::identity());
196    }
197
198    #[test]
199    fn full_amount_during_hold() {
200        // len 100, ramp 10 → hold is [110, 190).
201        let t = zoom_at(&seg(), 150, 10);
202        assert!((t.scale - 2.0).abs() < 1e-9, "hold = full amount");
203        assert!((t.center_x - 0.5).abs() < 1e-9);
204        assert!((t.center_y - 0.5).abs() < 1e-9);
205    }
206
207    #[test]
208    fn ramp_in_is_monotonic_and_bounded() {
209        let z = seg();
210        let mut prev = 0.0;
211        for f in 100..110 {
212            let s = zoom_at(&z, f, 10).scale;
213            assert!((1.0 - 1e-9..=2.0 + 1e-9).contains(&s));
214            assert!(s + 1e-9 >= prev, "ramp-in non-decreasing at {f}");
215            prev = s;
216        }
217        // The first frame is identity (ease(0) = 0).
218        assert!((zoom_at(&z, 100, 10).scale - 1.0).abs() < 1e-9);
219    }
220
221    #[test]
222    fn ramp_out_returns_toward_identity() {
223        let z = seg();
224        let full = zoom_at(&z, 190, 10).scale; // start of ramp-out: still full
225        let later = zoom_at(&z, 199, 10).scale; // deep in ramp-out: less
226        assert!((full - 2.0).abs() < 1e-9);
227        assert!(later < full, "ramp-out decreasing");
228        assert!(later >= 1.0 - 1e-9);
229    }
230
231    #[test]
232    fn ramp_clamped_to_half_window() {
233        // len 10, requested ramp 100 → clamped to 5, no overlap (triangle).
234        let z = ZoomSegment::manual(ZoomId(2), 0, 10, 3.0);
235        let mid = zoom_at(&z, 5, 100).scale;
236        assert!(mid > 1.0 && mid <= 3.0 + 1e-9);
237        assert!((zoom_at(&z, 0, 100).scale - 1.0).abs() < 1e-9);
238    }
239
240    #[test]
241    fn manual_target_sets_center() {
242        let mut z = seg();
243        z.mode = ZoomMode::Manual { x: 0.2, y: 0.8 };
244        let t = zoom_at(&z, 150, 10);
245        assert!((t.center_x - 0.2).abs() < 1e-6);
246        assert!((t.center_y - 0.8).abs() < 1e-6);
247    }
248
249    #[test]
250    fn auto_falls_back_to_centre() {
251        let mut z = seg();
252        z.mode = ZoomMode::Auto;
253        let t = zoom_at(&z, 150, 10);
254        assert!((t.center_x - 0.5).abs() < 1e-9);
255        assert!((t.center_y - 0.5).abs() < 1e-9);
256    }
257
258    #[test]
259    fn default_ramp_is_about_point_six_seconds() {
260        assert_eq!(default_ramp_frames(30), 18);
261        assert_eq!(default_ramp_frames(60), 36);
262        assert_eq!(default_ramp_frames(1), 1); // floored at one frame
263    }
264
265    #[test]
266    fn active_zoom_picks_containing_segment() {
267        let mut p = EditProject::from_recording(ClipRef::new(
268            PathBuf::from("/tmp/a.mp4"),
269            1920,
270            1080,
271            30,
272            600,
273        ));
274        p.zooms = vec![ZoomSegment::manual(ZoomId(1), 100, 200, 2.0)];
275        assert!(active_zoom_at(&p, 50).is_identity());
276        assert!(!active_zoom_at(&p, 150).is_identity());
277        // The hold reaches full amount.
278        assert!((active_zoom_at(&p, 150).scale - 2.0).abs() < 1e-9);
279    }
280
281    #[test]
282    fn keyframes_bracket_the_hold() {
283        let kfs = zoom_keyframes(&seg(), 10); // [100,200), 2.0×, ramp 10
284        assert_eq!(kfs.len(), 4);
285        assert_eq!(kfs[0].frame, 100);
286        assert!((kfs[0].scale - 1.0).abs() < 1e-9);
287        assert_eq!(kfs[1].frame, 110);
288        assert!((kfs[1].scale - 2.0).abs() < 1e-9);
289        assert_eq!(kfs[2].frame, 190);
290        assert!((kfs[2].scale - 2.0).abs() < 1e-9);
291        assert_eq!(kfs[3].frame, 200);
292        assert!((kfs[3].scale - 1.0).abs() < 1e-9);
293    }
294
295    #[test]
296    fn sub_one_amount_never_zooms_out() {
297        // A zoom is a push-in; an amount < 1.0 (reachable only via a raw
298        // AddZoom / deserialized project) must clamp to no-zoom, never a
299        // shrink — in both the animation and the dopesheet view.
300        let z = ZoomSegment::manual(ZoomId(4), 0, 100, 0.5);
301        for f in 0..100 {
302            assert!(
303                zoom_at(&z, f, 10).scale >= 1.0 - 1e-9,
304                "scale never below 1.0 at frame {f}"
305            );
306        }
307        for kf in zoom_keyframes(&z, 10) {
308            assert!(kf.scale >= 1.0 - 1e-9, "keyframe scale never below 1.0");
309        }
310    }
311
312    #[test]
313    fn keyframes_collapse_to_a_triangle_with_no_hold() {
314        // ramp clamps to len/2 (= 5) → in_peak == out_peak → 3 keyframes.
315        let z = ZoomSegment::manual(ZoomId(3), 0, 10, 3.0);
316        let kfs = zoom_keyframes(&z, 100);
317        assert_eq!(kfs.len(), 3);
318        assert_eq!(kfs[1].frame, 5);
319        assert!((kfs[1].scale - 3.0).abs() < 1e-9);
320    }
321}