Skip to main content

edit/
telemetry.rs

1//! Click telemetry → auto-zoom regions (ED.17 / M-EDIT).
2//!
3//! Screen recorders earn their "cinematic" feel by punching in where the
4//! user is *working* — and the user tells you where that is every time they
5//! click. This module turns a recorded click log into
6//! [`ZoomSegment`]s: clicks close together in time
7//! form one cluster, and each cluster becomes a zoom that opens just before
8//! the first click, holds through the last, and targets the cluster's
9//! centroid. The result is an ordinary, fully-editable list of zooms (the
10//! user can nudge, delete, or retune any of them) — auto-zoom is a *starting
11//! point*, not a lock-in.
12//!
13//! It is pure arithmetic over a click list, so it is exhaustively testable
14//! without a recorder. The OS-level capture that *produces* the click log
15//! (a per-platform surface, macOS first) is a separate follow-up; this is
16//! the generator that consumes it.
17
18use serde::{Deserialize, Serialize};
19
20use crate::segment::Frame;
21use crate::style::AutoZoomConfig;
22use crate::zoom::{EditEase, ZoomId, ZoomMode, ZoomSegment};
23
24/// A click captured during recording: a project frame plus a normalized
25/// position in the composed frame (`(0, 0)` top-left, `(1, 1)` bottom-right).
26///
27/// `Serialize`/`Deserialize` so a captured click log persists in the project
28/// document — it feeds both auto-zoom (ED.17) and the cursor click-ripple
29/// overlay (ED.19).
30#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)]
31pub struct ClickEvent {
32    /// Project frame the click occurred at.
33    pub frame: Frame,
34    /// Horizontal position, `0.0..=1.0`.
35    pub x: f32,
36    /// Vertical position, `0.0..=1.0`.
37    pub y: f32,
38}
39
40impl ClickEvent {
41    /// A click at `frame` and normalized `(x, y)`.
42    #[must_use]
43    pub fn new(frame: Frame, x: f32, y: f32) -> Self {
44        Self { frame, x, y }
45    }
46}
47
48/// A cursor position sample at a project frame, normalized to the composed
49/// frame (`(0, 0)` top-left, `(1, 1)` bottom-right — the same convention as
50/// [`ClickEvent`]).
51///
52/// Captured by the macOS event tap during recording (ED.17, the `app` crate)
53/// and consumed by the cursor overlay renderer (ED.19). The `frame` is a
54/// **project** frame, so the overlay syncs to the playhead 1:1.
55#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)]
56pub struct CursorSample {
57    /// Project frame this position was sampled at.
58    pub frame: Frame,
59    /// Horizontal position, `0.0..=1.0`.
60    pub x: f32,
61    /// Vertical position, `0.0..=1.0`.
62    pub y: f32,
63}
64
65impl CursorSample {
66    /// A cursor sample at `frame` and normalized `(x, y)`.
67    #[must_use]
68    pub fn new(frame: Frame, x: f32, y: f32) -> Self {
69        Self { frame, x, y }
70    }
71}
72
73/// Generate auto-zoom regions from a click log.
74///
75/// Clicks within ~1 s of each other form one cluster; each cluster becomes
76/// a zoom that opens ~0.3 s before the first click, holds `cfg.hold_time_ms`
77/// after the last, and targets the cluster's centroid at `cfg.max_zoom`.
78/// Sub-half-second windows are dropped, and adjacent windows are clamped so
79/// they never overlap. Returns an empty list if detection is disabled or
80/// there are no clicks. The regions are concrete `Manual`-targeted zooms so
81/// they punch into the click immediately under the [zoom
82/// engine](crate::zoom_anim); the user edits them like any other zoom.
83#[must_use]
84pub fn auto_zoom_segments(
85    clicks: &[ClickEvent],
86    fps: u32,
87    cfg: &AutoZoomConfig,
88) -> Vec<ZoomSegment> {
89    if !cfg.detect_from_cursor || clicks.is_empty() {
90        return Vec::new();
91    }
92    let fps_f = u64::from(fps.max(1));
93    let hold = (fps_f * u64::from(cfg.hold_time_ms) / 1000).max(1);
94    let merge_gap = fps_f; // ~1 s: clicks within a second cluster together
95    let lead_in = fps_f * 3 / 10; // ~0.3 s ramp-in before the first click
96    let min_len = fps_f / 2; // drop windows shorter than ~0.5 s
97
98    // Cluster the clicks by time gap (sorted by frame).
99    let mut sorted = clicks.to_vec();
100    sorted.sort_by_key(|c| c.frame);
101    let mut clusters: Vec<Vec<ClickEvent>> = Vec::new();
102    let mut prev_frame: Option<Frame> = None;
103    for c in sorted {
104        let new_cluster = prev_frame.is_none_or(|pf| c.frame.saturating_sub(pf) > merge_gap);
105        if new_cluster {
106            clusters.push(Vec::new());
107        }
108        clusters
109            .last_mut()
110            .expect("a cluster was just pushed when needed")
111            .push(c);
112        prev_frame = Some(c.frame);
113    }
114
115    // One zoom per cluster.
116    let mut out: Vec<ZoomSegment> = Vec::new();
117    for (i, cluster) in clusters.iter().enumerate() {
118        let first = cluster.first().expect("non-empty cluster").frame;
119        let last = cluster.last().expect("non-empty cluster").frame;
120        let start = first.saturating_sub(lead_in);
121        let end = last + hold;
122        if end.saturating_sub(start) < min_len {
123            continue;
124        }
125        // Accumulate the count as f32 inside the fold so the centroid
126        // divisor needs no integer cast (and no overflow cap on huge
127        // clusters). `n >= 1.0` because the cluster is non-empty.
128        let (sx, sy, n) = cluster
129            .iter()
130            .fold((0.0f32, 0.0f32, 0.0f32), |(ax, ay, an), c| {
131                (ax + c.x, ay + c.y, an + 1.0)
132            });
133        out.push(ZoomSegment {
134            id: ZoomId(u32::try_from(i).unwrap_or(u32::MAX)),
135            start,
136            end,
137            amount: cfg.max_zoom,
138            mode: ZoomMode::Manual {
139                x: (sx / n).clamp(0.0, 1.0),
140                y: (sy / n).clamp(0.0, 1.0),
141            },
142            ease: EditEase::default(),
143        });
144    }
145
146    // Clamp adjacent windows so they never overlap (the later zoom wins its
147    // own span; the earlier one ends where the next begins).
148    for i in 1..out.len() {
149        let next_start = out[i].start;
150        if out[i - 1].end > next_start {
151            out[i - 1].end = next_start;
152        }
153    }
154    out.retain(|z| z.end > z.start);
155    out
156}
157
158/// The smoothed cursor position at project `frame`, for the ED.19 overlay.
159///
160/// `track` is assumed sorted by frame (the capture produces it in frame
161/// order). Returns `None` for an empty track; clamps to the first sample for
162/// frames before the track starts. `smoothing` (`0..=100`) is an exponential
163/// moving average over the samples up to `frame` — `0` is the raw latest
164/// sample, higher values lag more (taming jitter, the way a fluid head tames
165/// handheld). Pure arithmetic, exhaustively testable.
166#[must_use]
167#[allow(
168    clippy::cast_precision_loss,
169    reason = "`smoothing` is clamped to 0..=100 so the u32→f32 is exact"
170)]
171pub fn cursor_at(track: &[CursorSample], frame: Frame, smoothing: u32) -> Option<(f32, f32)> {
172    let first = track.first()?;
173    // alpha: 1.0 (no smoothing) → 0.08 (max lag).
174    let s = (smoothing.min(100) as f32) / 100.0;
175    let alpha = 1.0 - 0.92 * s;
176    let mut pos = (first.x, first.y);
177    let mut started = false;
178    for sample in track.iter().take_while(|s| s.frame <= frame) {
179        if started {
180            pos.0 += alpha * (sample.x - pos.0);
181            pos.1 += alpha * (sample.y - pos.1);
182        } else {
183            pos = (sample.x, sample.y);
184            started = true;
185        }
186    }
187    Some(pos)
188}
189
190/// Whether the cursor is effectively stationary at project `frame` — the
191/// basis for [`CursorConfig::hide_static`](crate::style::CursorConfig).
192///
193/// A pointer that hasn't moved for a beat is visual noise the viewer's eye has
194/// already filed away; cinematic recorders fade it out until it moves again.
195/// This returns `true` when the raw cursor has drifted less than ~0.4 % of the
196/// frame (Euclidean) over the trailing ~0.4 s window ending at `frame` — which
197/// includes the case where no new sample landed in the window at all. Returns
198/// `false` only for an empty track (there is no position to settle on). Pure
199/// arithmetic, so the hide-while-static behaviour is exhaustively testable
200/// without a renderer.
201#[must_use]
202pub fn cursor_is_static(track: &[CursorSample], frame: Frame, fps: u32) -> bool {
203    let window = (u64::from(fps.max(1)) * 2) / 5; // ~0.4 s lookback
204    let past_frame = frame.saturating_sub(window);
205    match (cursor_at(track, frame, 0), cursor_at(track, past_frame, 0)) {
206        (Some((nx, ny)), Some((px, py))) => {
207            let (dx, dy) = (nx - px, ny - py);
208            // Squared-distance vs squared-threshold avoids a sqrt (and the
209            // clippy float lint that comes with it).
210            dx.mul_add(dx, dy * dy) < 0.004 * 0.004
211        }
212        _ => false,
213    }
214}
215
216/// Active click ripples at project `frame`, for the ED.19 overlay.
217///
218/// Each entry is `(x, y, age)` where `age` ramps `0.0` (at the click) →
219/// `1.0` (at the end of the `ripple_frames` window); the renderer expands +
220/// fades a ring across that age. Clicks outside the window are skipped.
221/// Returns empty when `ripple_frames == 0`. Pure.
222#[must_use]
223#[allow(
224    clippy::cast_precision_loss,
225    reason = "the age numerator is < ripple_frames (a small per-window frame count) so the u64→f32 is exact"
226)]
227pub fn ripples_at(clicks: &[ClickEvent], frame: Frame, ripple_frames: u32) -> Vec<(f32, f32, f32)> {
228    if ripple_frames == 0 {
229        return Vec::new();
230    }
231    let span = u64::from(ripple_frames);
232    clicks
233        .iter()
234        .filter(|c| frame >= c.frame && frame - c.frame < span)
235        .map(|c| {
236            let age = (frame - c.frame) as f32 / span as f32;
237            (c.x, c.y, age)
238        })
239        .collect()
240}
241
242#[cfg(test)]
243mod tests {
244    use super::*;
245
246    fn cfg() -> AutoZoomConfig {
247        AutoZoomConfig::default() // detect=true, hold 1200 ms, max_zoom 2.4
248    }
249
250    #[test]
251    fn no_clicks_or_disabled_yields_nothing() {
252        assert!(auto_zoom_segments(&[], 30, &cfg()).is_empty());
253        let mut off = cfg();
254        off.detect_from_cursor = false;
255        assert!(auto_zoom_segments(&[ClickEvent::new(100, 0.5, 0.5)], 30, &off).is_empty());
256    }
257
258    #[test]
259    fn single_click_makes_one_centred_zoom() {
260        // fps 30: hold=36, lead=9. Click at 100 → [91, 136), 2.4×, at (0.3,0.7).
261        let z = auto_zoom_segments(&[ClickEvent::new(100, 0.3, 0.7)], 30, &cfg());
262        assert_eq!(z.len(), 1);
263        assert_eq!(z[0].start, 91);
264        assert_eq!(z[0].end, 136);
265        assert!((z[0].amount - 2.4).abs() < 1e-9);
266        match z[0].mode {
267            ZoomMode::Manual { x, y } => {
268                assert!((x - 0.3).abs() < 1e-6 && (y - 0.7).abs() < 1e-6);
269            }
270            ZoomMode::Auto => panic!("auto-zoom should target the click"),
271        }
272    }
273
274    #[test]
275    fn nearby_clicks_merge_into_one_cluster_at_centroid() {
276        // 100 and 120 are within merge_gap (30) → one zoom; centroid x = 0.4.
277        let z = auto_zoom_segments(
278            &[
279                ClickEvent::new(100, 0.2, 0.5),
280                ClickEvent::new(120, 0.6, 0.5),
281            ],
282            30,
283            &cfg(),
284        );
285        assert_eq!(z.len(), 1);
286        assert_eq!(z[0].start, 91); // 100 - 9
287        assert_eq!(z[0].end, 156); // 120 + 36
288        if let ZoomMode::Manual { x, .. } = z[0].mode {
289            assert!((x - 0.4).abs() < 1e-6, "centroid of 0.2 and 0.6");
290        }
291    }
292
293    #[test]
294    fn distant_clicks_make_separate_non_overlapping_zooms() {
295        // 100 and 140: gap 40 > merge_gap 30 → two clusters. A's window
296        // [91,136) overlaps B's [131,176) → A clamped to end at 131.
297        let z = auto_zoom_segments(
298            &[
299                ClickEvent::new(100, 0.2, 0.2),
300                ClickEvent::new(140, 0.8, 0.8),
301            ],
302            30,
303            &cfg(),
304        );
305        assert_eq!(z.len(), 2);
306        assert!(z[0].end <= z[1].start, "windows must not overlap");
307        assert_eq!(z[1].start, 131); // 140 - 9
308    }
309
310    #[test]
311    fn clicks_are_sorted_before_clustering() {
312        // Out-of-order input clusters the same as sorted input.
313        let z = auto_zoom_segments(
314            &[
315                ClickEvent::new(140, 0.8, 0.8),
316                ClickEvent::new(100, 0.2, 0.2),
317            ],
318            30,
319            &cfg(),
320        );
321        assert_eq!(z.len(), 2);
322        assert!(z[0].start < z[1].start);
323    }
324
325    #[test]
326    fn cursor_at_empty_track_is_none() {
327        assert!(cursor_at(&[], 10, 0).is_none());
328    }
329
330    #[test]
331    fn cursor_at_no_smoothing_is_the_latest_sample() {
332        let track = [
333            CursorSample::new(0, 0.1, 0.2),
334            CursorSample::new(10, 0.6, 0.7),
335        ];
336        // At frame 10 with smoothing 0 → the raw latest sample.
337        let (x, y) = cursor_at(&track, 10, 0).unwrap();
338        assert!((x - 0.6).abs() < 1e-6 && (y - 0.7).abs() < 1e-6);
339        // At frame 5, the latest sample at/before is frame 0.
340        let (x, y) = cursor_at(&track, 5, 0).unwrap();
341        assert!((x - 0.1).abs() < 1e-6 && (y - 0.2).abs() < 1e-6);
342    }
343
344    #[test]
345    fn cursor_at_before_track_clamps_to_first() {
346        let track = [CursorSample::new(10, 0.3, 0.4)];
347        let (x, y) = cursor_at(&track, 0, 50).unwrap();
348        assert!((x - 0.3).abs() < 1e-6 && (y - 0.4).abs() < 1e-6);
349    }
350
351    #[test]
352    fn cursor_at_smoothing_lags_behind_a_jump() {
353        // Position jumps 0 → 1 at frame 1; heavy smoothing must land between
354        // (lagging the jump), never overshoot past the raw target.
355        let track = [
356            CursorSample::new(0, 0.0, 0.0),
357            CursorSample::new(1, 1.0, 1.0),
358        ];
359        let (raw, _) = cursor_at(&track, 1, 0).unwrap();
360        assert!((raw - 1.0).abs() < 1e-6, "no smoothing reaches the jump");
361        let (lag, _) = cursor_at(&track, 1, 100).unwrap();
362        assert!(
363            lag > 0.0 && lag < 1.0,
364            "max smoothing lags between (got {lag})"
365        );
366    }
367
368    #[test]
369    fn cursor_is_static_detects_a_settled_pointer() {
370        // fps 30 → ~0.4 s window = 12 frames.
371        // Empty track: never static (nothing to settle on).
372        assert!(!cursor_is_static(&[], 100, 30));
373        // A pointer that hasn't emitted a new sample for the whole window is
374        // static (both ends resolve to the same trailing sample).
375        let parked = [CursorSample::new(0, 0.5, 0.5)];
376        assert!(cursor_is_static(&parked, 100, 30));
377        // A pointer mid-sweep (0 → 1 across the window) is NOT static.
378        let moving = [
379            CursorSample::new(88, 0.0, 0.0),
380            CursorSample::new(100, 1.0, 1.0),
381        ];
382        assert!(!cursor_is_static(&moving, 100, 30));
383        // A sub-threshold jitter (< 0.4 %) still reads as static.
384        let jitter = [
385            CursorSample::new(88, 0.500, 0.500),
386            CursorSample::new(100, 0.502, 0.501),
387        ];
388        assert!(cursor_is_static(&jitter, 100, 30));
389    }
390
391    #[test]
392    fn ripples_at_ramps_age_across_the_window() {
393        let clicks = [ClickEvent::new(10, 0.5, 0.5)];
394        // At the click: age 0.
395        let r = ripples_at(&clicks, 10, 12);
396        assert_eq!(r.len(), 1);
397        assert!((r[0].2 - 0.0).abs() < 1e-6);
398        // Halfway: age 0.5.
399        assert!((ripples_at(&clicks, 16, 12)[0].2 - 0.5).abs() < 1e-6);
400        // Past the window: gone.
401        assert!(ripples_at(&clicks, 22, 12).is_empty());
402        // Before the click: gone.
403        assert!(ripples_at(&clicks, 5, 12).is_empty());
404        // Zero window: never any ripples.
405        assert!(ripples_at(&clicks, 10, 0).is_empty());
406    }
407}