1use serde::{Deserialize, Serialize};
19
20use crate::segment::Frame;
21use crate::style::AutoZoomConfig;
22use crate::zoom::{EditEase, ZoomId, ZoomMode, ZoomSegment};
23
24#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)]
31pub struct ClickEvent {
32 pub frame: Frame,
34 pub x: f32,
36 pub y: f32,
38}
39
40impl ClickEvent {
41 #[must_use]
43 pub fn new(frame: Frame, x: f32, y: f32) -> Self {
44 Self { frame, x, y }
45 }
46}
47
48#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)]
56pub struct CursorSample {
57 pub frame: Frame,
59 pub x: f32,
61 pub y: f32,
63}
64
65impl CursorSample {
66 #[must_use]
68 pub fn new(frame: Frame, x: f32, y: f32) -> Self {
69 Self { frame, x, y }
70 }
71}
72
73#[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; let lead_in = fps_f * 3 / 10; let min_len = fps_f / 2; 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 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 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 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#[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 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#[must_use]
202pub fn cursor_is_static(track: &[CursorSample], frame: Frame, fps: u32) -> bool {
203 let window = (u64::from(fps.max(1)) * 2) / 5; 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 dx.mul_add(dx, dy * dy) < 0.004 * 0.004
211 }
212 _ => false,
213 }
214}
215
216#[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() }
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 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 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); assert_eq!(z[0].end, 156); 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 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); }
309
310 #[test]
311 fn clicks_are_sorted_before_clustering() {
312 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 let (x, y) = cursor_at(&track, 10, 0).unwrap();
338 assert!((x - 0.6).abs() < 1e-6 && (y - 0.7).abs() < 1e-6);
339 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 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 assert!(!cursor_is_static(&[], 100, 30));
373 let parked = [CursorSample::new(0, 0.5, 0.5)];
376 assert!(cursor_is_static(&parked, 100, 30));
377 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 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 let r = ripples_at(&clicks, 10, 12);
396 assert_eq!(r.len(), 1);
397 assert!((r[0].2 - 0.0).abs() < 1e-6);
398 assert!((ripples_at(&clicks, 16, 12)[0].2 - 0.5).abs() < 1e-6);
400 assert!(ripples_at(&clicks, 22, 12).is_empty());
402 assert!(ripples_at(&clicks, 5, 12).is_empty());
404 assert!(ripples_at(&clicks, 10, 0).is_empty());
406 }
407}