diff --git a/crates/compositor/src/cursor.rs b/crates/compositor/src/cursor.rs index 5ce803f38..b34a37a08 100644 --- a/crates/compositor/src/cursor.rs +++ b/crates/compositor/src/cursor.rs @@ -12,6 +12,11 @@ const AUTO_FOLLOW_MAX_FACTOR: f32 = 0.25; const AUTO_FOLLOW_RAMP_DISTANCE: f32 = 0.15; const AUTO_FOLLOW_REFERENCE_MS: f32 = 1000.0 / 40.0; +/// Part de la vue zoomée, par axe, où le pointeur bouge sans que la vue le suive : sa moitié +/// centrale (`follow_in_view`). Au-delà, la vue le rattrape juste assez pour le ramener au bord de +/// cette zone. Sans elle, la vue suivait chaque petit geste et le cadrage paraissait nerveux. +const FOLLOW_DEAD_ZONE: f32 = 0.5; + // Convergence du curseur dessiné sur le point cliqué (`pinned_at`). La tenue couvre le contact du // curseur modélisé (27 à 74 ms après le clic), plus une image à 24 i/s. const PIN_APPROACH_S: f32 = 0.25; @@ -43,8 +48,16 @@ pub struct CursorTrack { /// `CURSOR_SAMPLE_INTERVAL_MS` côté Electron), donc n'enregistrer que les /// transitions garde cette liste minuscule et rend `type_at` trivial. types: Vec<(f32, String)>, + /// Les vues du suivi à zone morte déjà rejouées, par `(t0, fin, échelle)` : une par région auto + /// (`follow_in_view`). Partagé entre clones, qui ont les mêmes échantillons. + view_cache: + std::sync::Arc>)>>>, } +type ViewKey = (u32, u32, u32); +/// Assez pour toutes les régions auto d'un clip ; au-delà, la plus ancienne est rejouée. +const VIEW_CACHE_LEN: usize = 32; + /// Interpolation linéaire dans une liste `(t, x, y)` triée ; saturation aux bornes. fn sample_at(samples: &[(f32, f32, f32)], t: f32) -> Option<(f32, f32)> { if samples.is_empty() { @@ -62,7 +75,11 @@ fn sample_at(samples: &[(f32, f32, f32)], t: f32) -> Option<(f32, f32)> { let i = samples.partition_point(|s| s.0 <= t); let a = samples[i - 1]; let b = samples[i]; - let f = if b.0 > a.0 { (t - a.0) / (b.0 - a.0) } else { 0.0 }; + let f = if b.0 > a.0 { + (t - a.0) / (b.0 - a.0) + } else { + 0.0 + }; Some((a.1 + (b.1 - a.1) * f, a.2 + (b.2 - a.2) * f)) } @@ -92,27 +109,46 @@ fn smooth_follow_samples(samples: &[(f32, f32, f32)]) -> Vec<(f32, f32, f32)> { prev = Some((t, px, py)); continue; } - let (dx, dy) = (x - px, y - py); - let distance = (dx * dx + dy * dy).sqrt(); - let ramp = (distance / AUTO_FOLLOW_RAMP_DISTANCE).min(1.0); - let base = AUTO_FOLLOW_MIN_FACTOR + (AUTO_FOLLOW_MAX_FACTOR - AUTO_FOLLOW_MIN_FACTOR) * ramp; - let factor = 1.0 - (1.0 - base).powf(dt_ms / AUTO_FOLLOW_REFERENCE_MS); - let (nx, ny) = (px + dx * factor, py + dy * factor); + let (nx, ny) = follow_step((px, py), (x, y), dt_ms); smoothed.push((t, nx, ny)); prev = Some((t, nx, ny)); } smoothed } +/// Un pas du lissage de suivi : `from` avance vers `to` d'un facteur qui croît avec la distance +/// (loin = rattrape vite, près = décélère), corrigé en temps sur `dt_ms`. +fn follow_step(from: (f32, f32), to: (f32, f32), dt_ms: f32) -> (f32, f32) { + let (dx, dy) = (to.0 - from.0, to.1 - from.1); + let distance = (dx * dx + dy * dy).sqrt(); + let ramp = (distance / AUTO_FOLLOW_RAMP_DISTANCE).min(1.0); + let base = AUTO_FOLLOW_MIN_FACTOR + (AUTO_FOLLOW_MAX_FACTOR - AUTO_FOLLOW_MIN_FACTOR) * ramp; + let factor = 1.0 - (1.0 - base).powf(dt_ms / AUTO_FOLLOW_REFERENCE_MS); + (from.0 + dx * factor, from.1 + dy * factor) +} + impl CursorTrack { /// Seul point de construction : garantit que `follow_samples` est toujours dérivé des /// échantillons courants. Une piste re-lissée (`smoothed`) recalcule donc aussi son suivi, /// pour que la caméra suive la trajectoire que l'utilisateur voit réellement. - pub(crate) fn new(samples: Vec<(f32, f32, f32)>, clicks: Vec, types: Vec<(f32, String)>) -> CursorTrack { + pub(crate) fn new( + samples: Vec<(f32, f32, f32)>, + clicks: Vec, + types: Vec<(f32, String)>, + ) -> CursorTrack { let follow_samples = smooth_follow_samples(&samples); - let click_points = - clicks.iter().map(|&tc| sample_at(&samples, tc).unwrap_or((0.0, 0.0))).collect(); - CursorTrack { samples, follow_samples, clicks, click_points, types } + let click_points = clicks + .iter() + .map(|&tc| sample_at(&samples, tc).unwrap_or((0.0, 0.0))) + .collect(); + CursorTrack { + samples, + follow_samples, + clicks, + click_points, + types, + view_cache: Default::default(), + } } /// État du curseur au temps `t` : la dernière transition à `t` ou avant. `None` avant la @@ -155,7 +191,10 @@ impl CursorTrack { // (`pointer`/`text`) : un thème restait collé après le retour à la // flèche. Null / absence = reset vers `arrow`. let ct = match s.get("cursorType") { - Some(v) => v.as_str().filter(|label| !label.is_empty()).unwrap_or("arrow"), + Some(v) => v + .as_str() + .filter(|label| !label.is_empty()) + .unwrap_or("arrow"), None => "arrow", }; if types.last().map(|(_, prev)| prev.as_str()) != Some(ct) { @@ -175,6 +214,97 @@ impl CursorTrack { sample_at(&self.follow_samples, t) } + /// Le centre de la vue d'un zoom auto d'échelle `scale` à `t`, parti de `follow_at(t0)` en + /// `t0` : `follow_at` avec une zone morte. La vue ne bouge pas tant que le pointeur reste dans + /// la part `FOLLOW_DEAD_ZONE` de sa largeur autour de son centre ; il en sort, elle le + /// rattrape avec le même lissage, juste assez pour le ramener au bord de la zone. Bornée à ce + /// qu'une vue de `1/scale` peut atteindre, pour que la zone se mesure contre la vue affichée. + /// + /// Rejouée depuis `t0` sur les échantillons BRUTS, puis mise en cache jusqu'à `t_end` : une + /// fonction pure de `(t0, t, t_end)`, donc la même en preview et à l'export, après un seek + /// comme en lecture. Le cache ne garde rien après la fenêtre de la région. + pub fn follow_in_view(&self, t0: f32, t: f32, t_end: f32, scale: f32) -> Option<(f32, f32)> { + let t_end = t_end.max(t0); + let t = t.clamp(t0, t_end); + let states = self.view_states(t0, t_end, scale)?; + let half = 0.5 / scale.max(1.0); + let reach = |v: f32| v.clamp(half, 1.0 - half); + // Après le dernier échantillon, la vue continue de rattraper le pointeur resté immobile + // jusqu'au bord de la zone, au lieu de se figer à mi-course. + let &(at, vx, vy) = states.last()?; + if t > at { + let (_, x, y) = *self.samples.last()?; + let dead = half * FOLLOW_DEAD_ZONE; + let pull = |cursor: f32, view: f32| cursor - (cursor - view).clamp(-dead, dead); + let (nx, ny) = follow_step((vx, vy), (pull(x, vx), pull(y, vy)), (t - at) * 1000.0); + return Some((reach(nx), reach(ny))); + } + if states.len() == 1 { + return Some((vx, vy)); + } + // Entre deux états, linéaire, comme `follow_at`. + let i = states.partition_point(|s| s.0 < t).max(1); + let (a, b) = (states[i - 1], states[i]); + let f = if b.0 > a.0 { + ((t - a.0) / (b.0 - a.0)).clamp(0.0, 1.0) + } else { + 1.0 + }; + Some((a.1 + (b.1 - a.1) * f, a.2 + (b.2 - a.2) * f)) + } + + /// Les états `(t, x, y)` de `t0` à la fin de la fenêtre d'une région. Le dernier point est + /// interpolé à `t_end` si le curseur a encore des échantillons après cette fenêtre. + fn view_states( + &self, + t0: f32, + t_end: f32, + scale: f32, + ) -> Option>> { + let t_end = t_end.max(t0); + let key = (t0.to_bits(), t_end.to_bits(), scale.to_bits()); + if let Some((_, v)) = self.view_cache.lock().ok()?.iter().find(|(k, _)| *k == key) { + return Some(v.clone()); + } + let half = 0.5 / scale.max(1.0); + let dead = half * FOLLOW_DEAD_ZONE; + let reach = |v: f32| v.clamp(half, 1.0 - half); + // La cible : le point qui ramène le pointeur au bord de la zone, la vue elle-même dedans. + let pull = |cursor: f32, view: f32| cursor - (cursor - view).clamp(-dead, dead); + let (x0, y0) = self.follow_at(t0)?; + let mut view = (reach(x0), reach(y0)); + let mut states = vec![(t0, view.0, view.1)]; + let first = self.samples.partition_point(|s| s.0 <= t0); + let end = self.samples.partition_point(|s| s.0 <= t_end); + for &(ts, x, y) in &self.samples[first..end] { + let at = states.last().map_or(t0, |s| s.0); + let (nx, ny) = + follow_step(view, (pull(x, view.0), pull(y, view.1)), (ts - at) * 1000.0); + view = (reach(nx), reach(ny)); + states.push((ts, view.0, view.1)); + } + let at = states.last().map_or(t0, |s| s.0); + if at < t_end { + if let Some(&(ts, x, y)) = self.samples.get(end) { + let (nx, ny) = + follow_step(view, (pull(x, view.0), pull(y, view.1)), (ts - at) * 1000.0); + let f = ((t_end - at) / (ts - at)).clamp(0.0, 1.0); + states.push(( + t_end, + view.0 + (nx - view.0) * f, + view.1 + (ny - view.1) * f, + )); + } + } + let states = std::sync::Arc::new(states); + let mut cache = self.view_cache.lock().ok()?; + if cache.len() >= VIEW_CACHE_LEN { + cache.remove(0); + } + cache.push((key, states.clone())); + Some(states) + } + /// Position (cx, cy) au temps `t` (interpolation linéaire), ou None si hors piste. Brute, sauf /// sur une piste `smoothed()`. pub fn at(&self, t: f32) -> Option<(f32, f32)> { @@ -192,7 +322,9 @@ impl CursorTrack { /// l'emporte. Continue et de pente continue en `t` ; hors de ces fenêtres, `at(t)` à l'identique. pub fn pinned_at(&self, t: f32) -> Option<(f32, f32)> { let (mut x, mut y) = self.at(t)?; - let lo = self.clicks.partition_point(|&tc| tc <= t - PIN_HOLD_S - PIN_RELEASE_S); + let lo = self + .clicks + .partition_point(|&tc| tc <= t - PIN_HOLD_S - PIN_RELEASE_S); let hi = self.clicks.partition_point(|&tc| tc < t + PIN_APPROACH_S); for i in lo..hi.max(lo) { let d = t - self.clicks[i]; @@ -202,16 +334,27 @@ impl CursorTrack { 1.0 - smoothstep01((d - PIN_HOLD_S) / PIN_RELEASE_S) }; let (cx, cy) = self.click_points[i]; - (x, y) = if w >= 1.0 { (cx, cy) } else { (x + (cx - x) * w, y + (cy - y) * w) }; + (x, y) = if w >= 1.0 { + (cx, cy) + } else { + (x + (cx - x) * w, y + (cy - y) * w) + }; } Some((x, y)) } /// Les clics de `(lo, hi]` avec leur position brute, triés. - pub fn clicks_with_points(&self, lo: f32, hi: f32) -> impl Iterator + '_ { + pub fn clicks_with_points( + &self, + lo: f32, + hi: f32, + ) -> impl Iterator + '_ { let a = self.clicks.partition_point(|&tc| tc <= lo); let b = self.clicks.partition_point(|&tc| tc <= hi).max(a); - self.clicks[a..b].iter().copied().zip(self.click_points[a..b].iter().copied()) + self.clicks[a..b] + .iter() + .copied() + .zip(self.click_points[a..b].iter().copied()) } /// Facteur d'échelle « click bounce ». Cette fonction est la SEULE référence de la courbe : @@ -225,7 +368,9 @@ impl CursorTrack { pub fn bounce(&self, t: f32) -> f32 { const ANIM_S: f32 = 0.26; // 260 ms const PRESS_FRAC: f32 = 0.38; - let Some(tc) = self.last_click_at(t) else { return 1.0 }; + let Some(tc) = self.last_click_at(t) else { + return 1.0; + }; let elapsed = (t - tc) / ANIM_S; if elapsed >= 1.0 { return 1.0; @@ -234,7 +379,8 @@ impl CursorTrack { let press = (elapsed / PRESS_FRAC * std::f32::consts::PI).sin(); 1.0 - press * 0.24 } else { - let rebound = ((elapsed - PRESS_FRAC) / (1.0 - PRESS_FRAC) * std::f32::consts::PI).sin(); + let rebound = + ((elapsed - PRESS_FRAC) / (1.0 - PRESS_FRAC) * std::f32::consts::PI).sin(); 1.0 + rebound * 0.16 } } @@ -265,7 +411,11 @@ impl CursorTrack { /// bruts (le bounce est temporel, pas positionnel — ne doit pas suivre le lissage). pub fn smoothed(&self, factor: f32) -> CursorTrack { if self.samples.len() < 2 || factor <= 0.0 { - return CursorTrack::new(self.samples.clone(), self.clicks.clone(), self.types.clone()); + return CursorTrack::new( + self.samples.clone(), + self.clicks.clone(), + self.types.clone(), + ); } const STEP_S: f32 = 1.0 / 240.0; let start = self.samples[0].0; @@ -276,7 +426,11 @@ impl CursorTrack { let mut raw_x = Vec::with_capacity(n); let mut raw_y = Vec::with_capacity(n); for i in 0..n { - let t = if i == n - 1 { end } else { start + i as f32 * STEP_S }; + let t = if i == n - 1 { + end + } else { + start + i as f32 * STEP_S + }; let (cx, cy) = self.at(t).unwrap_or((0.0, 0.0)); times.push(t); raw_x.push(cx); @@ -285,7 +439,12 @@ impl CursorTrack { let (stiffness, damping, mass) = cursor_spring_config(factor); let xs = spring_smooth(&raw_x, stiffness, damping, mass, STEP_S); let ys = spring_smooth(&raw_y, stiffness, damping, mass, STEP_S); - let samples = times.into_iter().zip(xs).zip(ys).map(|((t, x), y)| (t, x, y)).collect(); + let samples = times + .into_iter() + .zip(xs) + .zip(ys) + .map(|((t, x), y)| (t, x, y)) + .collect(); // Comme les clics, les changements d'état gardent leurs instants bruts : le lissage // déplace la trajectoire, pas la chronologie de ce que faisait l'utilisateur. Les points // cliqués restent ceux de la piste brute. @@ -323,7 +482,11 @@ impl CursorTrack { let idx = self.samples.partition_point(|s| s.0 <= t); let start_t = self.samples[0].0; - let mut arrival_t = if idx > 0 { self.samples[idx - 1].0 } else { start_t }; + let mut arrival_t = if idx > 0 { + self.samples[idx - 1].0 + } else { + start_t + }; if idx > 0 { let mut i = idx - 1; @@ -375,7 +538,13 @@ impl CursorTrack { /// Ressort-amortisseur, intégration semi-implicite (symplectique) d'Euler — stable pour ces /// raideurs à la grille 240 Hz (port direct de `springSmooth` en TS). -fn spring_smooth(targets: &[f32], stiffness: f32, damping: f32, mass: f32, step_s: f32) -> Vec { +fn spring_smooth( + targets: &[f32], + stiffness: f32, + damping: f32, + mass: f32, + step_s: f32, +) -> Vec { let mut out = vec![0.0f32; targets.len()]; if targets.is_empty() { return out; @@ -412,6 +581,132 @@ fn cursor_spring_config(smoothing_factor: f32) -> (f32, f32, f32) { mod tests { use super::*; + /// Zone morte du suivi auto : un pointeur qui tremble au centre de la vue ne la bouge pas ; + /// un pointeur qui part loin est rattrapé, jusqu'au bord de la zone et pas au-delà. + /// Les vues d'une région sont rejouées une fois puis relues : même réponse dans n'importe quel + /// ordre, et la même qu'une piste neuve qui les rejoue. + #[test] + fn the_cached_follow_view_answers_like_a_fresh_replay() { + let make = || { + CursorTrack::new( + (0..=150) + .map(|i| { + let t = i as f32 / 30.0; + (t, 0.5 + 0.3 * (t * 1.3).sin(), 0.5 + 0.2 * (t * 0.7).cos()) + }) + .collect(), + vec![], + vec![], + ) + }; + let (cached, times) = (make(), [4.9f32, 0.2, 3.3, 0.0, 2.71, 4.0]); + for t in times { + let _ = cached.follow_in_view(0.4, t, 5.0, 2.0); + } + for t in times { + assert_eq!( + cached.follow_in_view(0.4, t, 5.0, 2.0), + make().follow_in_view(0.4, t, 5.0, 2.0), + "t {t}" + ); + } + // Une autre région (autre départ, autre échelle) a ses propres vues. + assert_ne!( + cached.follow_in_view(1.0, 3.0, 5.0, 3.0), + cached.follow_in_view(0.4, 3.0, 5.0, 2.0) + ); + } + + #[test] + fn the_follow_view_cache_stops_at_its_region_end() { + let track = CursorTrack::new( + (0..=300) + .map(|i| { + let t = i as f32 / 30.0; + (t, 0.5 + 0.2 * (t * 0.7).sin(), 0.5) + }) + .collect(), + vec![], + vec![], + ); + + let at_end = track.follow_in_view(0.0, 2.0, 2.0, 2.0).unwrap(); + assert_eq!(track.follow_in_view(0.0, 8.0, 2.0, 2.0), Some(at_end)); + + let cache = track.view_cache.lock().unwrap(); + let states = &cache[0].1; + assert!(states.iter().all(|(t, _, _)| *t <= 2.0)); + assert_eq!(states.last().unwrap().0, 2.0); + } + + /// Après le dernier échantillon, la vue continue de rattraper un pointeur immobile hors de la + /// zone morte, jusqu'à son bord, au lieu de se figer. + #[test] + fn the_follow_view_keeps_settling_after_the_last_sample() { + // Le pointeur saute à 0,8 au dernier échantillon (2 s) ; bord de la zone à ×2 : 0,675. + let track = CursorTrack::new( + (0..=60) + .map(|i| (i as f32 / 30.0, if i == 60 { 0.8 } else { 0.5 }, 0.5)) + .collect(), + vec![], + vec![], + ); + let at = |t: f32| track.follow_in_view(0.0, t, 9.0, 2.0).unwrap().0; + assert!( + at(2.0) < at(2.5) && at(2.5) < at(4.0), + "{} {} {}", + at(2.0), + at(2.5), + at(4.0) + ); + assert!((at(8.0) - 0.675).abs() < 1e-3, "{}", at(8.0)); + } + + #[test] + fn the_auto_follow_holds_still_inside_its_dead_zone() { + // ×2 : vue de 0,5, zone morte de ±0,125 autour de son centre. + let track = |f: fn(f32) -> f32| { + CursorTrack::new( + (0..=300) + .map(|i| { + let t = i as f32 / 30.0; + (t, f(t), 0.5) + }) + .collect(), + vec![], + vec![], + ) + }; + let jitter = track(|t| 0.5 + 0.08 * (t * 7.0).sin()); + for i in 0..=90 { + let (x, y) = jitter + .follow_in_view(0.0, i as f32 / 10.0, 9.0, 2.0) + .unwrap(); + assert!( + (x - 0.5).abs() < 1e-6 && (y - 0.5).abs() < 1e-6, + "t {} : {x}", + i as f32 / 10.0 + ); + } + // Sans zone morte, le suivi lissé, lui, tremble. + let swing = (0..=90) + .map(|i| jitter.follow_at(i as f32 / 10.0).unwrap().0) + .fold(0.0f32, |m, x| m.max((x - 0.5).abs())); + assert!(swing > 0.02, "{swing}"); + + // Un saut à 0,8 à 2 s : la vue rattrape jusqu'à laisser le pointeur au bord de la zone. + let jump = track(|t| if t < 2.0 { 0.5 } else { 0.8 }); + let settled = jump.follow_in_view(0.0, 9.0, 9.0, 2.0).unwrap().0; + assert!((settled - (0.8 - 0.125)).abs() < 1e-3, "{settled}"); + let early = jump.follow_in_view(0.0, 2.1, 9.0, 2.0).unwrap().0; + assert!( + early > 0.5 && early < settled, + "rattrapage progressif : {early}" + ); + // Une pure fonction de (t0, t, t_end) : l'ordre des appels ne change rien. + assert_eq!(jump.follow_in_view(0.0, 2.1, 9.0, 2.0).unwrap().0, early); + } + /// L'état du curseur est une fonction en escalier : il tient jusqu'à la transition /// suivante, il n'est pas interpolé, et avant la première il n'y en a pas. #[test] @@ -419,14 +714,30 @@ mod tests { let track = CursorTrack::new( vec![(0.0, 0.0, 0.0), (2.0, 1.0, 1.0)], vec![], - vec![(0.5, "arrow".into()), (1.0, "text".into()), (1.5, "pointer".into())], + vec![ + (0.5, "arrow".into()), + (1.0, "text".into()), + (1.5, "pointer".into()), + ], ); assert_eq!(track.type_at(0.0), None, "avant la première transition"); - assert_eq!(track.type_at(0.5), Some("arrow"), "à l'instant même de la transition"); - assert_eq!(track.type_at(0.9), Some("arrow"), "tient jusqu'à la suivante"); + assert_eq!( + track.type_at(0.5), + Some("arrow"), + "à l'instant même de la transition" + ); + assert_eq!( + track.type_at(0.9), + Some("arrow"), + "tient jusqu'à la suivante" + ); assert_eq!(track.type_at(1.2), Some("text")); - assert_eq!(track.type_at(99.0), Some("pointer"), "la dernière tient jusqu'à la fin"); + assert_eq!( + track.type_at(99.0), + Some("pointer"), + "la dernière tient jusqu'à la fin" + ); } /// Le lissage déplace la trajectoire, pas la chronologie : les états doivent survivre @@ -516,8 +827,14 @@ mod tests { } // Continue : pas de saut d'une milliseconde à l'autre. for k in 0..2000 { - let (a, b) = (track.pinned_at(k as f32 * 0.001).unwrap(), track.pinned_at((k + 1) as f32 * 0.001).unwrap()); - assert!((a.0 - b.0).abs() < 4e-3 && (a.1 - b.1).abs() < 4e-3, "saut en {k} ms : {a:?} -> {b:?}"); + let (a, b) = ( + track.pinned_at(k as f32 * 0.001).unwrap(), + track.pinned_at((k + 1) as f32 * 0.001).unwrap(), + ); + assert!( + (a.0 - b.0).abs() < 4e-3 && (a.1 - b.1).abs() < 4e-3, + "saut en {k} ms : {a:?} -> {b:?}" + ); } println!("au clic, piste en {lag:?}, point cliqué {clicked:?}"); } @@ -544,9 +861,21 @@ mod tests { assert_eq!(b(1.0), 1.0, "fraction 0"); assert!(near(b(1.0 + DT), 1.0, 2e-3)); let press_end = 1.0 + 0.38 * 0.26; - assert!(near(b(press_end - DT), 1.0, 2e-3), "fraction 0.38⁻ : {}", b(press_end - DT)); - assert!(near(b(press_end + DT), 1.0, 2e-3), "fraction 0.38⁺ : {}", b(press_end + DT)); - assert!(near(b(1.26 - DT), 1.0, 2e-3), "fraction 1⁻ : {}", b(1.26 - DT)); + assert!( + near(b(press_end - DT), 1.0, 2e-3), + "fraction 0.38⁻ : {}", + b(press_end - DT) + ); + assert!( + near(b(press_end + DT), 1.0, 2e-3), + "fraction 0.38⁺ : {}", + b(press_end + DT) + ); + assert!( + near(b(1.26 - DT), 1.0, 2e-3), + "fraction 1⁻ : {}", + b(1.26 - DT) + ); // Creux de la pression (fraction 0.19 → +49.4 ms), pic du rebond (0.69 → +179.4 ms). assert!(near(b(1.0494), 0.76, 1e-4), "creux : {}", b(1.0494)); @@ -558,18 +887,26 @@ mod tests { // serait encore dans son rebond (≈1.0037), le second l'écrase à 1.0 pile. let double = CursorTrack::new(vec![], vec![1.0, 1.1], vec![]); assert_eq!(double.bounce(1.1), 1.0, "redémarrage à la fraction 0"); - assert!(near(double.bounce(1.1494), 0.76, 1e-4), "nouveau creux : {}", double.bounce(1.1494)); - assert!(near(double.bounce(1.2794), 1.16, 1e-4), "nouveau pic : {}", double.bounce(1.2794)); - assert_eq!(double.bounce(1.3601), 1.0, "neutre après la fenêtre du second clic"); + assert!( + near(double.bounce(1.1494), 0.76, 1e-4), + "nouveau creux : {}", + double.bounce(1.1494) + ); + assert!( + near(double.bounce(1.2794), 1.16, 1e-4), + "nouveau pic : {}", + double.bounce(1.2794) + ); + assert_eq!( + double.bounce(1.3601), + 1.0, + "neutre après la fenêtre du second clic" + ); } #[test] fn auto_hide_disabled_always_full_opacity() { - let track = CursorTrack::new( - vec![(0.0, 0.5, 0.5), (10.0, 0.5, 0.5)], - vec![], - vec![], - ); + let track = CursorTrack::new(vec![(0.0, 0.5, 0.5), (10.0, 0.5, 0.5)], vec![], vec![]); assert_eq!(track.opacity_at(0.0, false), 1.0); assert_eq!(track.opacity_at(5.0, false), 1.0); assert_eq!(track.opacity_at(10.0, false), 1.0); @@ -579,7 +916,13 @@ mod tests { fn auto_hide_fades_out_after_idle_timeout() { // Le curseur est stationnaire à (0.5, 0.5) de 0 à 5s. let track = CursorTrack::new( - vec![(0.0, 0.5, 0.5), (1.0, 0.5, 0.5), (2.0, 0.5, 0.5), (3.0, 0.5, 0.5), (4.0, 0.5, 0.5)], + vec![ + (0.0, 0.5, 0.5), + (1.0, 0.5, 0.5), + (2.0, 0.5, 0.5), + (3.0, 0.5, 0.5), + (4.0, 0.5, 0.5), + ], vec![], vec![], ); @@ -590,7 +933,10 @@ mod tests { // Entre 1.5s et 1.8s, estompage linéaire let op_mid = track.opacity_at(1.65, true); - assert!((op_mid - 0.5).abs() < 0.05, "mi-parcours d'estompage: {op_mid}"); + assert!( + (op_mid - 0.5).abs() < 0.05, + "mi-parcours d'estompage: {op_mid}" + ); // À 1.8s et au-delà, opacité 0.0 assert_eq!(track.opacity_at(1.8, true), 0.0); diff --git a/crates/compositor/src/frame_geometry.rs b/crates/compositor/src/frame_geometry.rs index 1688397ae..17f7385de 100644 --- a/crates/compositor/src/frame_geometry.rs +++ b/crates/compositor/src/frame_geometry.rs @@ -2477,9 +2477,8 @@ pub fn plan_frame(input: &FrameGeometryInput) -> FrameGeometry { let mut zoom_aim = [0.5f32; 2]; let mut zoom_orbit = [0.5f32; 2]; // Curseur masqué → pas de piste pour ce qui anime le plan (parallaxe, impact, caméra - // `follow-cursor`) : l'export ne charge la piste que si le curseur est affiché - // (`timeline_walk`), la preview toujours. Sans cette porte, la preview pencherait un - // plan que l'export laisse immobile. + // `follow-cursor`) : un plan ne bouge que sous un pointeur qu'on voit. Preview et export + // chargent la piste dans tous les cas (le focus auto la suit), cette porte fait le reste. let parallax_track = cursor_for_zoom.filter(|_| scene.is_some_and(|s| s.cursor.show)); let active_crop = scene.and_then(|scene| { scene.crop_by_clip.get(scene.active_clip_index).copied().flatten() @@ -2569,21 +2568,25 @@ pub fn plan_frame(input: &FrameGeometryInput) -> FrameGeometry { let (brx, bry) = (dst[0] + dst[2], dst[1] + dst[3]); [brx - nw, bry - nh, nw, nh] }; - // Variantes ancrées au CENTRE (au lieu du coin bas-droite) de `dst`, pour le cas où - // `dst` vient de `app_webcam_rect` : ce rect est déjà la position que l'utilisateur a - // choisie/déplacée (résolue côté app via `computeCompositeLayout`, même convention - // centre-fraction que `cx`/`cy` dans `compositeLayout.ts`) — l'ancrer au coin bas-droite - // comme le fait `fit_cam_aspect` (pensé pour le placement par DÉFAUT, ancré à ce coin - // avec une marge fixe) réancre silencieusement la webcam glissée n'importe où d'autre à - // ce coin, ignorant la position réelle choisie par l'utilisateur — le bug rapporté - // (webcam glissée au coin bas-gauche, DOM/JSON envoyé au natif confirmant une position - // flush, mais rendu natif visiblement décalé). Le centre est le point fixe qui a un sens - // pour un rect DÉJÀ positionné par l'app ; le coin bas-droite n'a de sens que pour le - // placement par défaut, qui grandit depuis ce coin faute de position explicite. - let scale_center = |dst: [f32; 4], s: f32| -> [f32; 4] { - let (cx, cy) = (dst[0] + dst[2] * 0.5, dst[1] + dst[3] * 0.5); + // Variante ancrée à l'ANCRE de la caméra (au lieu du coin bas-droite) de `dst`, pour le cas + // où `dst` vient de `app_webcam_rect` : ce rect est déjà la position que l'utilisateur a + // choisie (résolue côté app via `computeCompositeLayout`) — l'ancrer au coin bas-droite + // comme le fait `fit_cam_aspect` (pensé pour le placement par DÉFAUT) réancrerait la + // webcam posée n'importe où d'autre à ce coin (bug rapporté : webcam au coin bas-gauche, + // rendu natif décalé). + // + // L'app pose la caméra à marge constante de son ancre (un coin ou un milieu de bord) : + // `x = marge + fx·(W − 2·marge − w)`, donc le point de la boîte à la fraction `fx` vaut + // `marge + fx·(W − 2·marge)` quelle que soit sa largeur. Rétrécir autour de ce point-là, + // c'est exactement la mise en page de l'app à la taille réduite : une caméra de coin garde + // sa marge aux deux bords, une caméra de milieu de bord reste centrée sur lui. Rétrécir + // vers son propre centre l'arrachait de son coin pendant chaque zoom. Sans ancre (vieux + // payload), le centre. + let anchor = scene.map(|s| s.layout.webcam_anchor_fractions()).unwrap_or([0.5, 0.5]); + let scale_anchored = |dst: [f32; 4], s: f32| -> [f32; 4] { + let (ax, ay) = (dst[0] + dst[2] * anchor[0], dst[1] + dst[3] * anchor[1]); let (nw, nh) = (dst[2] * s, dst[3] * s); - [cx - nw * 0.5, cy - nh * 0.5, nw, nh] + [ax - nw * anchor[0], ay - nh * anchor[1], nw, nh] }; // Le ratio de sortie réel (peut différer du canvas interne 16:9 fixe) et le facteur // d'étirement non uniforme que `blit_resized` appliquera en fin de pipeline — nécessaires @@ -2754,17 +2757,17 @@ pub fn plan_frame(input: &FrameGeometryInput) -> FrameGeometry { // (`OUT_W`×`OUT_H`), une référence différente du vrai output dès que la sortie n'est pas // 16:9 (rapport utilisateur : webcam glissée au coin bas-gauche en 9:16, JSON envoyé au // natif confirmant une position flush, mais rendu native visiblement décalé ET trop - // petit). On garde seulement `scale_center` (zoom réactif, préserve position+aspect) puis - // on pré-compense par `inverse_undistort` pour annuler le `undistort()` générique + // petit). On garde seulement `scale_anchored` (zoom réactif, préserve l'ancre et l'aspect) + // puis on pré-compense par `inverse_undistort` pour annuler le `undistort()` générique // appliqué plus bas à tous les calques (écran compris) — sans quoi ce rect déjà correct // se ferait déformer une seconde fois par cet undistort partagé. let mut w_dst = if app_webcam_rect.is_some() { - scale_center(p.webcam.dst, webcam_size_scale) + scale_anchored(p.webcam.dst, webcam_size_scale) } else { fit_cam_aspect(scale_corner_br(p.webcam.dst, webcam_size_scale)) }; let mut w_dst_prev = if app_webcam_rect.is_some() { - scale_center(pp.webcam.dst, webcam_size_scale_prev) + scale_anchored(pp.webcam.dst, webcam_size_scale_prev) } else { fit_cam_aspect(scale_corner_br(pp.webcam.dst, webcam_size_scale_prev)) }; @@ -3028,8 +3031,8 @@ pub fn cursor_plane_point(cut: [f32; 4], uv_max: [f32; 2], p: (f32, f32)) -> Opt /// multiplier encore par le poids des régions (`ZoomState::click_impact`) ; la porte du préset /// vient ensuite, dans `dynamic_tilt`. /// -/// - curseur visible (`cursor_alpha`) : `cursor.show` explicite, parce que l'export ne charge la -/// piste que si le curseur est affiché alors que la preview la charge toujours ; +/// - curseur visible (`cursor_alpha`) : `cursor.show` explicite, la piste étant chargée même +/// curseur masqué (le focus auto la suit) ; /// - clics dans la fenêtre source du clip actif seulement (cf. `click_impact`) ; /// - vitesse : poids `clamp(2 − vitesse, 0, 1)`. À 100× une frame couvre 3,3 s de source, la /// courbe serait échantillonnée une fois, au hasard : une secousse d'une frame ; @@ -5418,6 +5421,35 @@ mod tests { assert_eq!(flat.zoom_rotation_dyn, [0.0; 3], "sans préset"); } + /// Le zoom réactif rétrécit la caméra vers son ANCRE : une caméra de coin garde sa marge aux + /// deux bords, une caméra de milieu de bord reste centrée sur lui. Sans ancre, le centre. + #[test] + fn the_reactive_zoom_shrinks_the_camera_toward_its_anchor() { + let cfg = crate::config::all().pop().expect("au moins une config"); + // Caméra de 0,2 de côté posée par l'app, marge 0,03 ; zoom ×2 à 1,5 s → échelle 0,5. + let camera_at = |anchor: Option<&str>, x: f32, y: f32| { + let anchor = anchor.map(|a| format!(r#","webcamAnchor":"{a}""#)).unwrap_or_default(); + let layout = format!( + r#""webcamReactiveZoom":true{anchor},"webcamRect":{{"x":{x},"y":{y},"width":0.2,"height":0.2}}}}"# + ); + let json = zoomed_golden_scene_json().replace(r#""webcamReactiveZoom":false}"#, &layout); + let scene = Scene::from_json(&json).expect("scène"); + plan_frame(&golden_input(&scene, &cfg)).w_dst + }; + for (anchor, x, y, want) in [ + (Some("bottom-right"), 0.77, 0.77, [0.87, 0.87]), + (Some("top-left"), 0.03, 0.03, [0.03, 0.03]), + (Some("bottom"), 0.4, 0.77, [0.45, 0.87]), + (Some("left"), 0.03, 0.4, [0.03, 0.45]), + (None, 0.77, 0.77, [0.82, 0.82]), + ] { + let w = camera_at(anchor, x, y); + let got = [w[0], w[1], w[2], w[3]]; + let want = [want[0], want[1], 0.1, 0.1]; + assert!(got.iter().zip(want).all(|(g, w)| (g - w).abs() < 1e-5), "{anchor:?} : {got:?} au lieu de {want:?}"); + } + } + /// La vitesse se mesure dans la coupe VISIBLE, zoom compris : sous un x2, le même geste /// traverse deux fois plus d'écran et penche donc plus le plan (hors saturation). #[test] diff --git a/crates/compositor/src/regions.rs b/crates/compositor/src/regions.rs index 471235127..e6b8353a5 100644 --- a/crates/compositor/src/regions.rs +++ b/crates/compositor/src/regions.rs @@ -84,7 +84,10 @@ pub fn speed_segments_for_window( /// région seulement si `clip_index` est absent (vieux payload) OU vaut `active_clip_index`. pub fn speed_at(regions: &[SceneSpeedRegion], active_clip_index: usize, t: f64) -> f64 { for region in regions { - let belongs = region.clip_index.map(|i| i == active_clip_index).unwrap_or(true); + let belongs = region + .clip_index + .map(|i| i == active_clip_index) + .unwrap_or(true); if belongs && t >= region.start_sec && t < region.end_sec { if region.speed.is_finite() && region.speed > 0.0 { return region.speed; @@ -140,7 +143,11 @@ impl ProgrammeClock { .flat_map(&segments_of) .map(|s| s.frame_count) .sum(); - Some(Self { frames_before, segments: segments_of(clip_index), fps }) + Some(Self { + frames_before, + segments: segments_of(clip_index), + fps, + }) } /// Temps programme au temps source `source_sec` du clip. Borné à la fenêtre du clip : @@ -182,7 +189,11 @@ impl ScreenClock { .iter() .filter(|r| r.clip_index.map(|i| i == clip_index).unwrap_or(true)) .map(|r| { - let speed = if r.speed.is_finite() && r.speed > 0.0 { r.speed } else { 1.0 }; + let speed = if r.speed.is_finite() && r.speed > 0.0 { + r.speed + } else { + 1.0 + }; (r.start_sec, r.end_sec, speed) }) .collect(); @@ -247,7 +258,12 @@ fn push_speed_segment( let frames = (((duration - SPEED_FRAME_EPSILON_SEC) / speed) * fps) .ceil() .max(0.0) as u64; - spans.push(SpeedSegment { start_sec, end_sec, speed, frame_count: frames }); + spans.push(SpeedSegment { + start_sec, + end_sec, + speed, + frame_count: frames, + }); } // mêmes fenêtres de transition que le web (TRANSITION_WINDOW_MS etc., converties en secondes). @@ -408,10 +424,12 @@ pub fn zoom_cursor_alpha(regions: &[SceneZoomRegion], t: f32, clock: &ScreenCloc // chaîné ci-dessus. let mut best: Option<(usize, f32)> = None; for (i, r) in regions.iter().enumerate() { - let outgoing_past_end = - pairs.iter().any(|&(ci, _, _, _)| ci == i && t > regions[i].end_sec as f32); - let incoming_before_transition_end = - pairs.iter().any(|&(_, ni, _, t_end)| ni == i && screen_t < t_end); + let outgoing_past_end = pairs + .iter() + .any(|&(ci, _, _, _)| ci == i && t > regions[i].end_sec as f32); + let incoming_before_transition_end = pairs + .iter() + .any(|&(_, ni, _, t_end)| ni == i && screen_t < t_end); if outgoing_past_end || incoming_before_transition_end { continue; } @@ -526,12 +544,20 @@ fn fixed_rotation(region: &SceneZoomRegion) -> [f32; 3] { /// 1 si la région porte un angle fixe, 0 sinon : la porte de la parallaxe et de l'impact. fn fixed_flag(region: &SceneZoomRegion) -> f32 { - if matches!(camera_for(®ion.rotation), Camera::Fixed(_)) { 1.0 } else { 0.0 } + if matches!(camera_for(®ion.rotation), Camera::Fixed(_)) { + 1.0 + } else { + 0.0 + } } /// 1 si la région porte la caméra réelle, 0 sinon. fn follow_flag(region: &SceneZoomRegion) -> f32 { - if camera_for(®ion.rotation) == Camera::Follow { 1.0 } else { 0.0 } + if camera_for(®ion.rotation) == Camera::Follow { + 1.0 + } else { + 0.0 + } } /// Ce que la caméra d'une région lit à `t` (le centre hors `follow-cursor`), non pondéré. @@ -550,7 +576,11 @@ fn weighted(p: [f32; 2], camera: f32) -> [f32; 2] { /// 1 si la région active l'impact du clic, 0 sinon. Sous un angle fixe, le clic presse l'écran ; /// sous la caméra réelle, l'écran reste immobile et c'est l'œil qui recule. fn impact_flag(region: &SceneZoomRegion) -> f32 { - if region.click_impact { 1.0 } else { 0.0 } + if region.click_impact { + 1.0 + } else { + 0.0 + } } /// Port de `easeConnectedPan` (TS) : cubic-bezier(0.1, 0, 0.2, 1). @@ -585,7 +615,11 @@ fn rotation3d_for(rotation: &Option) -> [f32; 3] { } fn lerp_rotation3d(a: [f32; 3], b: [f32; 3], t: f32) -> [f32; 3] { - [lerp(a[0], b[0], t), lerp(a[1], b[1], t), lerp(a[2], b[2], t)] + [ + lerp(a[0], b[0], t), + lerp(a[1], b[1], t), + lerp(a[2], b[2], t), + ] } /// Les trois segments d'une flèche d'annotation, en unités du viewBox SVG (0..100), repris @@ -594,19 +628,47 @@ fn lerp_rotation3d(a: [f32; 3], b: [f32; 3], t: f32) -> [f32; 3] { /// flèche — inutile de réinventer une géométrie « équivalente ». pub fn arrow_segments_viewbox(direction: &str) -> [[f32; 4]; 3] { match direction { - "up" => [[50.0, 20.0, 50.0, 80.0], [50.0, 20.0, 35.0, 35.0], [50.0, 20.0, 65.0, 35.0]], - "down" => [[50.0, 20.0, 50.0, 80.0], [50.0, 80.0, 35.0, 65.0], [50.0, 80.0, 65.0, 65.0]], - "left" => [[80.0, 50.0, 20.0, 50.0], [20.0, 50.0, 35.0, 35.0], [20.0, 50.0, 35.0, 65.0]], - "up-right" => [[25.0, 75.0, 75.0, 25.0], [75.0, 25.0, 53.8, 25.0], [75.0, 25.0, 75.0, 46.2]], - "up-left" => [[75.0, 75.0, 25.0, 25.0], [25.0, 25.0, 25.0, 46.2], [25.0, 25.0, 46.2, 25.0]], - "down-right" => { - [[25.0, 25.0, 75.0, 75.0], [75.0, 75.0, 75.0, 53.8], [75.0, 75.0, 53.8, 75.0]] - } - "down-left" => { - [[75.0, 25.0, 25.0, 75.0], [25.0, 75.0, 46.2, 75.0], [25.0, 75.0, 25.0, 53.8]] - } + "up" => [ + [50.0, 20.0, 50.0, 80.0], + [50.0, 20.0, 35.0, 35.0], + [50.0, 20.0, 65.0, 35.0], + ], + "down" => [ + [50.0, 20.0, 50.0, 80.0], + [50.0, 80.0, 35.0, 65.0], + [50.0, 80.0, 65.0, 65.0], + ], + "left" => [ + [80.0, 50.0, 20.0, 50.0], + [20.0, 50.0, 35.0, 35.0], + [20.0, 50.0, 35.0, 65.0], + ], + "up-right" => [ + [25.0, 75.0, 75.0, 25.0], + [75.0, 25.0, 53.8, 25.0], + [75.0, 25.0, 75.0, 46.2], + ], + "up-left" => [ + [75.0, 75.0, 25.0, 25.0], + [25.0, 25.0, 25.0, 46.2], + [25.0, 25.0, 46.2, 25.0], + ], + "down-right" => [ + [25.0, 25.0, 75.0, 75.0], + [75.0, 75.0, 75.0, 53.8], + [75.0, 75.0, 53.8, 75.0], + ], + "down-left" => [ + [75.0, 25.0, 25.0, 75.0], + [25.0, 75.0, 46.2, 75.0], + [25.0, 75.0, 25.0, 53.8], + ], // "right" et tout ce qui n'est pas reconnu — même défaut que le schéma côté app. - _ => [[20.0, 50.0, 80.0, 50.0], [80.0, 50.0, 65.0, 35.0], [80.0, 50.0, 65.0, 65.0]], + _ => [ + [20.0, 50.0, 80.0, 50.0], + [80.0, 50.0, 65.0, 35.0], + [80.0, 50.0, 65.0, 65.0], + ], } } @@ -623,7 +685,10 @@ pub fn arrow_local_geometry( quad_px: [f32; 2], ) -> ([[f32; 4]; 3], f32) { let scale = quad_px[0].min(quad_px[1]) / 100.0; - let off = [(quad_px[0] - 100.0 * scale) * 0.5, (quad_px[1] - 100.0 * scale) * 0.5]; + let off = [ + (quad_px[0] - 100.0 * scale) * 0.5, + (quad_px[1] - 100.0 * scale) * 0.5, + ]; let to_local = |v: [f32; 4]| { [ off[0] + v[0] * scale, @@ -634,13 +699,25 @@ pub fn arrow_local_geometry( }; let segments = arrow_segments_viewbox(direction); let half_stroke = (stroke_width_viewbox.max(0.0) * scale) * 0.5; - ([to_local(segments[0]), to_local(segments[1]), to_local(segments[2])], half_stroke) + ( + [ + to_local(segments[0]), + to_local(segments[1]), + to_local(segments[2]), + ], + half_stroke, + ) } /// Focus effectif d'une région à `t` : sa position fixe, sauf en mode "auto" où elle suit la /// télémétrie curseur (port de `getResolvedFocus`, sans le clamp — le crop-window de /// `compositor.rs` clampe déjà après coup, cf. `su0.clamp(...)`, donc redondant ici). -fn resolve_focus(region: &SceneZoomRegion, t: f32, cursor: Option<&CursorTrack>) -> [f32; 2] { +fn resolve_focus( + region: &SceneZoomRegion, + t: f32, + cursor: Option<&CursorTrack>, + clock: &ScreenClock, +) -> [f32; 2] { // Sous la caméra réelle, le cadrage vient de son pivot : la boîte zoome sur son centre, sans // glissement 2D. Chaînée à une région plate, la transition glisse donc vers le focus de // celle-ci sans saut. @@ -649,10 +726,19 @@ fn resolve_focus(region: &SceneZoomRegion, t: f32, cursor: Option<&CursorTrack>) } if region.focus_mode.as_deref() == Some("auto") { if let Some(track) = cursor { - // `follow_at`, pas `at` : la caméra suit la piste LISSÉE. Suivre la télémétrie brute - // donne un pan nerveux — l'étage de lissage de `cursorFollowUtils.ts` manquait au - // portage. - if let Some((cx, cy)) = track.follow_at(t) { + // La piste LISSÉE, avec une zone morte au centre de la vue zoomée (`follow_in_view`) : + // la vue ne bouge que quand le pointeur en sort. Suivre la télémétrie brute, ou chaque + // petit geste, donne un pan nerveux. Le filtre part du début de l'ease-in de la région, + // un instant fixe : preview et export rejouent la même chose. L'ease-in se mesure à + // l'écran (`zoom_region_strength`), son début se ramène donc à la source par l'horloge. + let window = zoom_transition_s(region.scale); + let t0 = clock.source_at(clock.at(region.start_sec as f32) - window); + let t_end = if region.under_trim { + region.end_sec as f32 + } else { + clock.source_at(clock.at(region.end_sec as f32) + window) + }; + if let Some((cx, cy)) = track.follow_in_view(t0, t, t_end, region.scale) { return [cx, cy]; } } @@ -671,16 +757,31 @@ fn resolve_focus(region: &SceneZoomRegion, t: f32, cursor: Option<&CursorTrack>) /// rendu, donc un pan lissé vers (ou depuis) l'une d'elles ferait bouger des frames gardées au /// nom d'une région que l'export ne joue pas. Elles restent des régions dominantes indépendantes, /// sèches sur leur propre span (cf. `zoom_region_strength`). -fn connected_pairs(regions: &[SceneZoomRegion], clock: &ScreenClock) -> Vec<(usize, usize, f32, f32)> { - let mut order: Vec = (0..regions.len()).filter(|&i| !regions[i].under_trim).collect(); - order.sort_by(|&a, &b| regions[a].start_sec.partial_cmp(®ions[b].start_sec).unwrap()); +fn connected_pairs( + regions: &[SceneZoomRegion], + clock: &ScreenClock, +) -> Vec<(usize, usize, f32, f32)> { + let mut order: Vec = (0..regions.len()) + .filter(|&i| !regions[i].under_trim) + .collect(); + order.sort_by(|&a, &b| { + regions[a] + .start_sec + .partial_cmp(®ions[b].start_sec) + .unwrap() + }); let mut pairs = Vec::new(); for w in order.windows(2) { let (ci, ni) = (w[0], w[1]); let transition_start = clock.at(regions[ci].end_sec as f32); let gap = clock.at(regions[ni].start_sec as f32) - transition_start; if gap <= CHAINED_ZOOM_PAN_GAP_S { - pairs.push((ci, ni, transition_start, transition_start + CONNECTED_ZOOM_PAN_DURATION_S)); + pairs.push(( + ci, + ni, + transition_start, + transition_start + CONNECTED_ZOOM_PAN_DURATION_S, + )); } } pairs @@ -688,8 +789,18 @@ fn connected_pairs(regions: &[SceneZoomRegion], clock: &ScreenClock) -> Vec<(usi /// `zoom_state_in` sans speed region ni caméra `follow-cursor` : ce que les tests comparent. #[cfg(test)] -pub fn zoom_state_at(regions: &[SceneZoomRegion], t: f32, cursor: Option<&CursorTrack>) -> ZoomState { - zoom_state_in(regions, t, cursor, &CameraFrame::NONE, &ScreenClock::default()) +pub fn zoom_state_at( + regions: &[SceneZoomRegion], + t: f32, + cursor: Option<&CursorTrack>, +) -> ZoomState { + zoom_state_in( + regions, + t, + cursor, + &CameraFrame::NONE, + &ScreenClock::default(), + ) } /// État de zoom au temps `t` (secondes source du clip actif). Port de @@ -720,16 +831,19 @@ pub fn zoom_state_in( let progress = ease_connected_pan(clamp01((screen_t - t_start) / (t_end - t_start).max(1e-3))); let (cur, next) = (®ions[ci], ®ions[ni]); - let cur_focus = resolve_focus(cur, t, cursor); - let next_focus = resolve_focus(next, t, cursor); + let cur_focus = resolve_focus(cur, t, cursor, clock); + let next_focus = resolve_focus(next, t, cursor, clock); // Entre deux présets DIFFÉRENTS, la base traverse des poses qu'aucun préset ne valide // (left→right passe par Y = 0) : la marge que l'échelle gelée laissait au budget n'y est // plus. La porte se ferme donc en milieu de course et ne se rouvre qu'au ras des bouts — // 1 − 4p(1−p) vaut 1 en p = 0 et p = 1, donc aucun saut avec la région d'avant ni avec // le palier d'après. Cf. `the_chained_sweep_keeps_every_edge_off_axis_and_inside`. let (cur_cam, next_cam) = (camera_for(&cur.rotation), camera_for(&next.rotation)); - let crossing = - if cur_cam == next_cam { 1.0 } else { 1.0 - 4.0 * progress * (1.0 - progress) }; + let crossing = if cur_cam == next_cam { + 1.0 + } else { + 1.0 - 4.0 * progress * (1.0 - progress) + }; // Un angle fixe incline l'écran, la caméra réelle le laisse immobile : les deux modèles ne // se mélangent pas. Entre eux, la transition passe par l'écran droit à mi-course, l'un // s'éteignant sur la première moitié, l'autre s'allumant sur la seconde. @@ -738,9 +852,16 @@ pub fn zoom_state_in( (Camera::Follow, Camera::Fixed(_)) | (Camera::Fixed(_), Camera::Follow) ); let (rotation, camera) = if split { - let (out, into) = ((1.0 - 2.0 * progress).max(0.0), (2.0 * progress - 1.0).max(0.0)); + let (out, into) = ( + (1.0 - 2.0 * progress).max(0.0), + (2.0 * progress - 1.0).max(0.0), + ); let r = fixed_rotation(cur).map(|a| a * out); - let r = if r == [0.0; 3] { fixed_rotation(next).map(|a| a * into) } else { r }; + let r = if r == [0.0; 3] { + fixed_rotation(next).map(|a| a * into) + } else { + r + }; (r, follow_flag(cur) * out + follow_flag(next) * into) } else { ( @@ -749,10 +870,14 @@ pub fn zoom_state_in( ) }; let (a, b) = (follow_at(cur, t, frame), follow_at(next, t, frame)); - let mix = |p: [f32; 2], q: [f32; 2]| [lerp(p[0], q[0], progress), lerp(p[1], q[1], progress)]; + let mix = + |p: [f32; 2], q: [f32; 2]| [lerp(p[0], q[0], progress), lerp(p[1], q[1], progress)]; return ZoomState { scale: scale_lerp(cur.scale, next.scale, progress), - focus: [lerp(cur_focus[0], next_focus[0], progress), lerp(cur_focus[1], next_focus[1], progress)], + focus: [ + lerp(cur_focus[0], next_focus[0], progress), + lerp(cur_focus[1], next_focus[1], progress), + ], rotation, tilt: lerp(fixed_flag(cur), fixed_flag(next), progress) * crossing, click_impact: lerp(impact_flag(cur), impact_flag(next), progress), @@ -771,7 +896,7 @@ pub fn zoom_state_in( let seen = follow_at(next, t, frame); return ZoomState { scale: next.scale, - focus: resolve_focus(next, t, cursor), + focus: resolve_focus(next, t, cursor, clock), rotation: fixed_rotation(next), tilt: fixed_flag(next), click_impact: impact_flag(next), @@ -786,10 +911,12 @@ pub fn zoom_state_in( // chaîné ci-dessus (sinon leur propre ease-in/out "percerait" à travers la fenêtre chaînée). let mut best: Option<(usize, f32)> = None; for (i, r) in regions.iter().enumerate() { - let outgoing_past_end = - pairs.iter().any(|&(ci, _, _, _)| ci == i && t > regions[i].end_sec as f32); - let incoming_before_transition_end = - pairs.iter().any(|&(_, ni, _, t_end)| ni == i && screen_t < t_end); + let outgoing_past_end = pairs + .iter() + .any(|&(ci, _, _, _)| ci == i && t > regions[i].end_sec as f32); + let incoming_before_transition_end = pairs + .iter() + .any(|&(_, ni, _, t_end)| ni == i && screen_t < t_end); if outgoing_past_end || incoming_before_transition_end { continue; } @@ -808,7 +935,7 @@ pub fn zoom_state_in( match best { Some((i, strength)) => { let r = ®ions[i]; - let focus = resolve_focus(r, t, cursor); + let focus = resolve_focus(r, t, cursor, clock); let scale = scale_lerp(1.0, r.scale, strength); // La référence (`zoomTransform.ts`) fait converger le point de focus vers le centre // de l'écran LINÉAIREMENT : screen(f) = 0.5 + (f - 0.5)(1 - strength). Passer @@ -858,13 +985,21 @@ fn camera_fullscreen_region_strength( let lead_in_end = start + lead_in; let lead_out_start = end - lead_out; if t < lead_in_end { - let progress = if lead_in > 0.0 { (t - start) / lead_in } else { 1.0 }; + let progress = if lead_in > 0.0 { + (t - start) / lead_in + } else { + 1.0 + }; return ease_out_screen_studio(progress); } if t <= lead_out_start { return 1.0; } - let progress = if lead_out > 0.0 { (end - t) / lead_out } else { 0.0 }; + let progress = if lead_out > 0.0 { + (end - t) / lead_out + } else { + 0.0 + }; ease_out_screen_studio(progress) } @@ -926,7 +1061,11 @@ fn rotate_corner(x0: f32, y0: f32, rot: [f32; 3]) -> (f32, f32, f32) { /// caméra) : Z, puis Y, puis X, en degrés. Le curseur modélisé (mode 15) a besoin de points /// hors du plan, et doit tourner exactement comme lui. pub(crate) fn rotate_point(p: [f32; 3], rot: [f32; 3]) -> [f32; 3] { - let (a, b, g) = (rot[0].to_radians(), rot[1].to_radians(), rot[2].to_radians()); + let (a, b, g) = ( + rot[0].to_radians(), + rot[1].to_radians(), + rot[2].to_radians(), + ); let (ca, sa) = (a.cos(), a.sin()); let (cb, sb) = (b.cos(), b.sin()); let (cg, sg) = (g.cos(), g.sin()); @@ -949,7 +1088,11 @@ pub(crate) fn rotate_point(p: [f32; 3], rot: [f32; 3]) -> [f32; 3] { /// L'inverse de `rotate_point` : du repère caméra vers celui du plan (X, puis Y, puis Z, chacun /// transposé). Une rotation est orthonormée, sa transposée est son inverse. pub(crate) fn rotate_point_inv(p: [f32; 3], rot: [f32; 3]) -> [f32; 3] { - let (a, b, g) = (rot[0].to_radians(), rot[1].to_radians(), rot[2].to_radians()); + let (a, b, g) = ( + rot[0].to_radians(), + rot[1].to_radians(), + rot[2].to_radians(), + ); let (ca, sa) = (a.cos(), a.sin()); let (cb, sb) = (b.cos(), b.sin()); let (cg, sg) = (g.cos(), g.sin()); @@ -973,7 +1116,11 @@ pub(crate) fn rotate_point_inv(p: [f32; 3], rot: [f32; 3]) -> [f32; 3] { /// rotateZ. En développant x1 = x0·cg − y0·sg et y1 = x0·sg + y0·cg, `pz` est linéaire en /// (x0, y0) — d'où ces deux coefficients, sans rien de plus à calculer par pixel. pub(crate) fn depth_coefficients(rot: [f32; 3]) -> (f32, f32) { - let (a, b, g) = (rot[0].to_radians(), rot[1].to_radians(), rot[2].to_radians()); + let (a, b, g) = ( + rot[0].to_radians(), + rot[1].to_radians(), + rot[2].to_radians(), + ); let (ca, sa) = (a.cos(), a.sin()); let sb = b.sin(); let (cg, sg) = (g.cos(), g.sin()); @@ -1018,7 +1165,9 @@ fn project_scaled_corners( /// Demi-étendue des coins projetés sur chaque axe. fn projected_extents(corners: &[(f32, f32); 4]) -> (f32, f32) { - corners.iter().fold((0.0f32, 0.0f32), |(mx, my), &(x, y)| (mx.max(x.abs()), my.max(y.abs()))) + corners.iter().fold((0.0f32, 0.0f32), |(mx, my), &(x, y)| { + (mx.max(x.abs()), my.max(y.abs())) + }) } /// Un écran incliné : ses 4 coins projetés, et la réduction qu'il a fallu pour qu'ils tiennent. @@ -1082,7 +1231,12 @@ pub fn rotated_quad_corners_px( // Un coin derrière le plan de fuite : on rend le quad non tourné plutôt qu'une projection // absurde (même repli qu'avant). return TiltedQuad { - corners: [(-half_w, -half_h), (half_w, -half_h), (half_w, half_h), (-half_w, half_h)], + corners: [ + (-half_w, -half_h), + (half_w, -half_h), + (half_w, half_h), + (-half_w, half_h), + ], scale: 1.0, depth_k: (0.0, 0.0), rot: [0.0; 3], @@ -1096,7 +1250,11 @@ pub fn rotated_quad_corners_px( // décollerait du plan pendant la parallaxe. let mut drawn = rot; if rot_dyn != [0.0; 3] { - let full = [rot[0] + rot_dyn[0], rot[1] + rot_dyn[1], rot[2] + rot_dyn[2]]; + let full = [ + rot[0] + rot_dyn[0], + rot[1] + rot_dyn[1], + rot[2] + rot_dyn[2], + ]; // Un coin qui passerait derrière le plan de fuite : on garde la pose de base. if let Some(c) = project_scaled_corners(width, height, scale, full, perspective) { corners = c; @@ -1172,7 +1330,11 @@ pub const DYNAMIC_TILT_BUDGET: [f32; 3] = [1.9, 3.0, 0.0]; /// Borne une part dynamique au budget, axe par axe. pub fn clamp_dynamic_tilt(rot: [f32; 3]) -> [f32; 3] { let b = DYNAMIC_TILT_BUDGET; - [rot[0].clamp(-b[0], b[0]), rot[1].clamp(-b[1], b[1]), rot[2].clamp(-b[2], b[2])] + [ + rot[0].clamp(-b[0], b[0]), + rot[1].clamp(-b[1], b[1]), + rot[2].clamp(-b[2], b[2]), + ] } /// Force du préset sous laquelle la part dynamique est nulle ; elle s'installe en smoothstep @@ -1238,7 +1400,11 @@ pub fn dynamic_tilt( } _ => [0.0; 3], }; - let sum = [parallax[0] + impact[0], parallax[1] + impact[1], parallax[2] + impact[2]]; + let sum = [ + parallax[0] + impact[0], + parallax[1] + impact[1], + parallax[2] + impact[2], + ]; clamp_dynamic_tilt(sum).map(|d| d * gate) } @@ -1298,8 +1464,13 @@ pub fn click_impact( if tc < window[0] || tc >= window[1] { continue; } - let Some([fx, fy]) = track.at(tc).and_then(&aim) else { continue }; - let (dx, dy) = ((2.0 * fx - 1.0).clamp(-1.0, 1.0), (2.0 * fy - 1.0).clamp(-1.0, 1.0)); + let Some([fx, fy]) = track.at(tc).and_then(&aim) else { + continue; + }; + let (dx, dy) = ( + (2.0 * fx - 1.0).clamp(-1.0, 1.0), + (2.0 * fy - 1.0).clamp(-1.0, 1.0), + ); let k = tap((t - tc) / CLICK_IMPACT_WINDOW_S); sum[0] += k * dy; sum[1] -= k * dx; @@ -1373,7 +1544,10 @@ impl TiltedQuad { let [tl, tr, br, bl] = self.corners; let top = (tl.0 + (tr.0 - tl.0) * fx, tl.1 + (tr.1 - tl.1) * fx); let bottom = (bl.0 + (br.0 - bl.0) * fx, bl.1 + (br.1 - bl.1) * fx); - (top.0 + (bottom.0 - top.0) * fy, top.1 + (bottom.1 - top.1) * fy) + ( + top.0 + (bottom.0 - top.0) * fy, + top.1 + (bottom.1 - top.1) * fy, + ) } /// Le `mb` du draw mode 8 : `[gx, gy, z_focus, k]`. @@ -1394,13 +1568,21 @@ impl TiltedQuad { let gy = screen_px[1] * self.scale * self.depth_k.1; let z_focus = (focus_plane[0] - 0.5) * gx + (focus_plane[1] - 0.5) * gy; let perspective = self.perspective; - let k = if dof && perspective > 0.0 { DOF_COC_PER_DEPTH / perspective } else { 0.0 }; + let k = if dof && perspective > 0.0 { + DOF_COC_PER_DEPTH / perspective + } else { + 0.0 + }; [gx, gy, z_focus, k] } /// Le drapeau « warp projectif » des shaders (1) ou bilinéaire (0). pub fn warp_flag(&self) -> f32 { - if self.projective { 1.0 } else { 0.0 } + if self.projective { + 1.0 + } else { + 0.0 + } } /// Demi-largeur / demi-hauteur de la bounding box des coins projetés, en px. @@ -1435,17 +1617,62 @@ mod zoom_focus_tests { /// dernière (ou la première) position suivie au lieu de retomber sur le point statique de la /// région. Sans ça, le plan net sauterait d'un bout de l'écran à l'autre à la fin de la /// télémétrie — et la netteté avec lui, maintenant que `z_focus` en dépend. + /// Le suivi auto part du début de l'ease-in MESURÉ À L'ÉCRAN : sous un ralenti avant la + /// région, ce début tombe plus tard en source que `start − fenêtre`. + #[test] + fn auto_focus_starts_its_dead_zone_at_the_on_screen_ease_in() { + let track = CursorTrack::new( + (0..=240) + .map(|i| (i as f32 / 30.0, 0.5 + 0.35 * (i as f32 / 15.0).sin(), 0.5)) + .collect(), + vec![], + vec![], + ); + let mut r = region(2.0, 0.5); + r.focus_mode = Some("auto".into()); + let slow = crate::scene::SceneSpeedRegion { + clip_index: None, + start_sec: 0.0, + end_sec: 2.0, + speed: 0.5, + }; + let clock = ScreenClock::new(&[slow], 0); + let window = zoom_transition_s(r.scale); + let t0 = clock.source_at(clock.at(r.start_sec as f32) - window); + assert!( + t0 > r.start_sec as f32 - window + 0.1, + "garde : le ralenti doit déplacer le départ ({t0})" + ); + let t_end = clock.source_at(clock.at(r.end_sec as f32) + window); + let (x, y) = track.follow_in_view(t0, 3.0, t_end, r.scale).unwrap(); + assert_eq!(resolve_focus(&r, 3.0, Some(&track), &clock), [x, y]); + } + #[test] fn auto_focus_holds_the_last_tracked_point_past_the_track() { - let track = CursorTrack::new(vec![(3.0, 0.8, 0.2), (4.0, 0.9, 0.1)], Vec::new(), Vec::new()); + let track = CursorTrack::new( + vec![(3.0, 0.8, 0.2), (4.0, 0.9, 0.1)], + Vec::new(), + Vec::new(), + ); let mut r = region(1.5, 0.1); r.focus_mode = Some("auto".into()); - let last = track.follow_at(4.0).unwrap(); + let t0 = r.start_sec as f32 - zoom_transition_s(r.scale); + let clock = ScreenClock::default(); + let t_end = clock.source_at(clock.at(r.end_sec as f32) + zoom_transition_s(r.scale)); + let last = track.follow_in_view(t0, t_end, t_end, r.scale).unwrap(); for t in [4.5f32, 6.0, 7.9] { - assert_eq!(resolve_focus(&r, t, Some(&track)), [last.0, last.1], "t = {t}"); + assert_eq!( + resolve_focus(&r, t, Some(&track), &clock), + [last.0, last.1], + "t = {t}" + ); } - let first = track.follow_at(3.0).unwrap(); - assert_eq!(resolve_focus(&r, 2.1, Some(&track)), [first.0, first.1]); + let first = track.follow_in_view(t0, 2.1, t_end, r.scale).unwrap(); + assert_eq!( + resolve_focus(&r, 2.1, Some(&track), &clock), + [first.0, first.1] + ); } #[test] @@ -1458,13 +1685,22 @@ mod zoom_focus_tests { let regions = vec![r]; // Bien avant le zoom (t=0.0) -> pleine opacité - assert_eq!(zoom_cursor_alpha(®ions, 0.0, &ScreenClock::default()), 1.0); + assert_eq!( + zoom_cursor_alpha(®ions, 0.0, &ScreenClock::default()), + 1.0 + ); // Plein milieu du zoom (t=4.5) -> complètement masqué - assert_eq!(zoom_cursor_alpha(®ions, 4.5, &ScreenClock::default()), 0.0); + assert_eq!( + zoom_cursor_alpha(®ions, 4.5, &ScreenClock::default()), + 0.0 + ); // Bien après le zoom (t=10.0) -> pleine opacité - assert_eq!(zoom_cursor_alpha(®ions, 10.0, &ScreenClock::default()), 1.0); + assert_eq!( + zoom_cursor_alpha(®ions, 10.0, &ScreenClock::default()), + 1.0 + ); } #[test] @@ -1482,13 +1718,25 @@ mod zoom_focus_tests { let regions = vec![r1, r2]; // Pendant la région 1 (t=3.0) -> masqué - assert_eq!(zoom_cursor_alpha(®ions, 3.0, &ScreenClock::default()), 0.0); + assert_eq!( + zoom_cursor_alpha(®ions, 3.0, &ScreenClock::default()), + 0.0 + ); // Pendant la transition chaînée (connected pan entre t=4.0 et t=4.3) -> reste masqué - assert_eq!(zoom_cursor_alpha(®ions, 4.15, &ScreenClock::default()), 0.0); + assert_eq!( + zoom_cursor_alpha(®ions, 4.15, &ScreenClock::default()), + 0.0 + ); // Pendant le palier chaîné (t=4.4) -> reste masqué - assert_eq!(zoom_cursor_alpha(®ions, 4.4, &ScreenClock::default()), 0.0); + assert_eq!( + zoom_cursor_alpha(®ions, 4.4, &ScreenClock::default()), + 0.0 + ); // Pendant la région 2 (t=5.5) -> masqué - assert_eq!(zoom_cursor_alpha(®ions, 5.5, &ScreenClock::default()), 0.0); + assert_eq!( + zoom_cursor_alpha(®ions, 5.5, &ScreenClock::default()), + 0.0 + ); } #[test] @@ -1506,9 +1754,15 @@ mod zoom_focus_tests { let regions = vec![r1, r2]; // Avant la transition (r1 actif) -> 0.0 - assert_eq!(zoom_cursor_alpha(®ions, 3.0, &ScreenClock::default()), 0.0); + assert_eq!( + zoom_cursor_alpha(®ions, 3.0, &ScreenClock::default()), + 0.0 + ); // Après la transition (r2 actif avec hide_cursor=false) -> 1.0 - assert_eq!(zoom_cursor_alpha(®ions, 5.5, &ScreenClock::default()), 1.0); + assert_eq!( + zoom_cursor_alpha(®ions, 5.5, &ScreenClock::default()), + 1.0 + ); } /// Où le point source `f` atterrit à l'écran (0..1) : le crop est centré sur `focus` et @@ -1558,9 +1812,21 @@ mod zoom_focus_tests { .map(|i| speed(lead_in + i as f32 * dt)) .fold(0.0f32, f32::max); assert!(peak < 3.0, "{scale}× : pic à {peak}/s"); - assert!(speed(lead_in) < 0.1 * peak, "{scale}× : départ à {}/s", speed(lead_in)); - assert!(speed(8.0) < 0.1 * peak, "{scale}× : retour à {}/s", speed(8.0)); - assert_eq!(zoom_state_at(&r, 2.0, None).scale, scale, "{scale}× en place au début"); + assert!( + speed(lead_in) < 0.1 * peak, + "{scale}× : départ à {}/s", + speed(lead_in) + ); + assert!( + speed(8.0) < 0.1 * peak, + "{scale}× : retour à {}/s", + speed(8.0) + ); + assert_eq!( + zoom_state_at(&r, 2.0, None).scale, + scale, + "{scale}× en place au début" + ); } } @@ -1569,7 +1835,11 @@ mod zoom_focus_tests { let f = 0.8; let regions = [region(2.5, f)]; let state = zoom_state_at(®ions, 5.0, None); - assert!((state.scale - 2.5).abs() < 1e-4, "plein régime attendu, scale={}", state.scale); + assert!( + (state.scale - 2.5).abs() < 1e-4, + "plein régime attendu, scale={}", + state.scale + ); assert!((screen_x(&state, f) - 0.5).abs() < 1e-4); assert!((state.focus[0] - f).abs() < 1e-4); } @@ -1609,8 +1879,18 @@ mod zoom_focus_tests { assert!(connected_pairs(&[region(2.0, 0.5), cut], &ScreenClock::default()).is_empty()); } - fn speed(clip_index: Option, start_sec: f64, end_sec: f64, speed: f64) -> SceneSpeedRegion { - SceneSpeedRegion { clip_index, start_sec, end_sec, speed } + fn speed( + clip_index: Option, + start_sec: f64, + end_sec: f64, + speed: f64, + ) -> SceneSpeedRegion { + SceneSpeedRegion { + clip_index, + start_sec, + end_sec, + speed, + } } #[test] @@ -1620,16 +1900,31 @@ mod zoom_focus_tests { assert_eq!(clock.at(4.0), 2.5); assert_eq!(clock.at(8.0), 5.0); for t in [0.5f32, 2.0, 3.3, 6.0, 7.25] { - assert!((clock.source_at(clock.at(t)) - t).abs() < 1e-5, "aller-retour à t = {t}"); + assert!( + (clock.source_at(clock.at(t)) - t).abs() < 1e-5, + "aller-retour à t = {t}" + ); } // Une frame d'écran en arrière depuis une frontière : le pas est celui du span d'AVANT. let back = |t: f32| clock.source_at(clock.at(t) - 1.0 / 60.0); - assert!((back(2.0) - (2.0 - 1.0 / 60.0)).abs() < 1e-5, "entrée du 4× : {}", back(2.0)); - assert!((back(6.0) - (6.0 - 4.0 / 60.0)).abs() < 1e-5, "sortie du 4× : {}", back(6.0)); + assert!( + (back(2.0) - (2.0 - 1.0 / 60.0)).abs() < 1e-5, + "entrée du 4× : {}", + back(2.0) + ); + assert!( + (back(6.0) - (6.0 - 4.0 / 60.0)).abs() < 1e-5, + "sortie du 4× : {}", + back(6.0) + ); // Autre clip ignoré ; recouvrement : la première région garde [4, 6), la seconde ne // compte que sur [6, 8). 2 + 1 + 1 à t = 8. let clock = ScreenClock::new( - &[speed(Some(1), 0.0, 10.0, 2.0), speed(Some(0), 2.0, 6.0, 4.0), speed(Some(0), 4.0, 8.0, 2.0)], + &[ + speed(Some(1), 0.0, 10.0, 2.0), + speed(Some(0), 2.0, 6.0, 4.0), + speed(Some(0), 4.0, 8.0, 2.0), + ], 0, ); assert_eq!(clock.at(8.0), 4.0); @@ -1643,21 +1938,37 @@ mod zoom_focus_tests { let clock = ScreenClock::new(&[speed(None, 0.0, 1000.0, 4.0)], 0); let zooms = |k: f64| { let (mut a, mut b) = (region(2.5, 0.8), region(1.8, 0.3)); - (a.start_sec, a.end_sec, b.start_sec, b.end_sec) = (5.0 * k, 10.0 * k, 11.0 * k, 15.0 * k); + (a.start_sec, a.end_sec, b.start_sec, b.end_sec) = + (5.0 * k, 10.0 * k, 11.0 * k, 15.0 * k); [a, b] }; - let full_camera = - |k: f64| [SceneCameraFullscreenRegion { clip_index: None, start_sec: 18.0 * k, end_sec: 22.0 * k }]; + let full_camera = |k: f64| { + [SceneCameraFullscreenRegion { + clip_index: None, + start_sec: 18.0 * k, + end_sec: 22.0 * k, + }] + }; let (sped, plain) = (zooms(4.0), zooms(1.0)); - assert_eq!(connected_pairs(&sped, &clock).len(), 1, "la paire doit se chaîner"); + assert_eq!( + connected_pairs(&sped, &clock).len(), + 1, + "la paire doit se chaîner" + ); for step in 0..=1000 { let t = step as f32 * 0.1; let a = zoom_state_in(&sped, t, None, &CameraFrame::NONE, &clock); let b = zoom_state_at(&plain, t / 4.0, None); - assert!((a.scale - b.scale).abs() < 1e-4, "t={t} : {} vs {}", a.scale, b.scale); + assert!( + (a.scale - b.scale).abs() < 1e-4, + "t={t} : {} vs {}", + a.scale, + b.scale + ); assert!((a.focus[0] - b.focus[0]).abs() < 1e-4, "t={t}"); let a = camera_fullscreen_progress_at(&full_camera(4.0), t, &clock); - let b = camera_fullscreen_progress_at(&full_camera(1.0), t / 4.0, &ScreenClock::default()); + let b = + camera_fullscreen_progress_at(&full_camera(1.0), t / 4.0, &ScreenClock::default()); assert!((a - b).abs() < 1e-4, "Full Camera t={t} : {a} vs {b}"); } } @@ -1675,7 +1986,8 @@ mod zoom_focus_tests { for seg in speed_segments_for_window(speeds, 0.0, 20.0, 60.0) { for k in 0..seg.frame_count { let t = (seg.start_sec + k as f64 * seg.speed / 60.0) as f32; - let scale = zoom_state_in(&[r.clone()], t, None, &CameraFrame::NONE, &clock).scale; + let scale = + zoom_state_in(&[r.clone()], t, None, &CameraFrame::NONE, &clock).scale; if scale > 1.0 && scale < 2.0 { frames += 1; } @@ -1687,8 +1999,14 @@ mod zoom_focus_tests { }; let (plain_frames, plain_step) = walk(&[]); let (sped_frames, sped_step) = walk(&[speed(None, 9.5, 11.0, 8.0)]); - assert!(plain_frames.abs_diff(sped_frames) <= 2, "{plain_frames} vs {sped_frames} frames"); - assert!(sped_step <= plain_step + 0.01, "saut de {sped_step} (1× : {plain_step})"); + assert!( + plain_frames.abs_diff(sped_frames) <= 2, + "{plain_frames} vs {sped_frames} frames" + ); + assert!( + sped_step <= plain_step + 0.01, + "saut de {sped_step} (1× : {plain_step})" + ); } } @@ -1721,8 +2039,16 @@ mod arrow_tests { // ~25° du fût quand les cardinales ouvrent à 45°, ce qui donnait une pointe étroite, // avalée par le fût dès que le trait épaississait. Corriger la longueur seule ne suffisait // pas — ce test verrouille l'angle, qui est le paramètre réellement visible. - for dir in ["up", "down", "left", "right", "up-right", "up-left", "down-right", "down-left"] - { + for dir in [ + "up", + "down", + "left", + "right", + "up-right", + "up-left", + "down-right", + "down-left", + ] { for barb in 1..=2 { let deg = barb_opening_deg(dir, barb); assert!( @@ -1738,7 +2064,8 @@ mod arrow_tests { // Les barbes diagonales faisaient 15,8 unités contre 21,2 pour les cardinales : une // flèche en diagonale avait une tête ~25 % plus petite que sa voisine horizontale, ce // qui se lisait comme une déformation. Ce test interdit la divergence de revenir. - let barb_len = |seg: [f32; 4]| ((seg[2] - seg[0]).powi(2) + (seg[3] - seg[1]).powi(2)).sqrt(); + let barb_len = + |seg: [f32; 4]| ((seg[2] - seg[0]).powi(2) + (seg[3] - seg[1]).powi(2)).sqrt(); let cardinal = barb_len(arrow_segments_viewbox("up")[1]); for dir in ["up-right", "up-left", "down-right", "down-left"] { for barb in 1..=2 { @@ -1757,11 +2084,19 @@ mod arrow_tests { // dessinent deux flèches différentes. assert_eq!( arrow_segments_viewbox("right"), - [[20.0, 50.0, 80.0, 50.0], [80.0, 50.0, 65.0, 35.0], [80.0, 50.0, 65.0, 65.0]] + [ + [20.0, 50.0, 80.0, 50.0], + [80.0, 50.0, 65.0, 35.0], + [80.0, 50.0, 65.0, 65.0] + ] ); assert_eq!( arrow_segments_viewbox("up"), - [[50.0, 20.0, 50.0, 80.0], [50.0, 20.0, 35.0, 35.0], [50.0, 20.0, 65.0, 35.0]] + [ + [50.0, 20.0, 50.0, 80.0], + [50.0, 20.0, 35.0, 35.0], + [50.0, 20.0, 65.0, 35.0] + ] ); } @@ -1769,7 +2104,10 @@ mod arrow_tests { fn an_unknown_direction_falls_back_to_right() { // Même défaut que le schéma côté app, pour qu'une donnée abîmée dessine quelque chose // de sensé plutôt que rien. - assert_eq!(arrow_segments_viewbox("sideways"), arrow_segments_viewbox("right")); + assert_eq!( + arrow_segments_viewbox("sideways"), + arrow_segments_viewbox("right") + ); } #[test] @@ -1815,11 +2153,16 @@ mod tilt_tests { #[test] fn the_plane_corners_map_to_the_projected_corners() { let quad = rotated_quad_corners_px(1920.0, 1080.0, preset("iso"), [0.0; 3]); - for (i, (fx, fy)) in [(0.0, 0.0), (1.0, 0.0), (1.0, 1.0), (0.0, 1.0)].into_iter().enumerate() + for (i, (fx, fy)) in [(0.0, 0.0), (1.0, 0.0), (1.0, 1.0), (0.0, 1.0)] + .into_iter() + .enumerate() { let (x, y) = quad.point_px(fx, fy); let (ex, ey) = quad.corners[i]; - assert!((x - ex).abs() < 1e-3 && (y - ey).abs() < 1e-3, "coin {i}: {x},{y} != {ex},{ey}"); + assert!( + (x - ex).abs() < 1e-3 && (y - ey).abs() < 1e-3, + "coin {i}: {x},{y} != {ex},{ey}" + ); } } @@ -1848,7 +2191,10 @@ mod tilt_tests { let (ux, uy) = ((fx - 0.5) * w, (fy - 0.5) * h); worst = worst.max((x - ux).hypot(y - uy)); } - assert!(worst > 20.0, "ecart max au rect droit trop faible: {worst}px"); + assert!( + worst > 20.0, + "ecart max au rect droit trop faible: {worst}px" + ); } /// Le contrat du containment : après projection, aucun coin ne sort du rect d'origine. @@ -1925,13 +2271,18 @@ mod tilt_tests { let quad = rotated_quad_corners_px(w, h, rot, [0.0; 3]); let mb = quad.depth_mb([w, h], [0.5, 0.5], false); let perspective = w.min(h) * PERSPECTIVE_FACTOR; - for (i, (s, t)) in [(0.0, 0.0), (1.0, 0.0), (1.0, 1.0), (0.0, 1.0)].into_iter().enumerate() + for (i, (s, t)) in [(0.0, 0.0), (1.0, 0.0), (1.0, 1.0), (0.0, 1.0)] + .into_iter() + .enumerate() { let (x0, y0) = ((s - 0.5) * w * quad.scale, (t - 0.5) * h * quad.scale); // Même repère que les coins : projeter ce point redonne le coin rendu. let (px, py) = project_corner(x0, y0, rot, perspective).unwrap(); let (cx, cy) = quad.corners[i]; - assert!((px - cx).abs() < 1e-2 && (py - cy).abs() < 1e-2, "{name} coin {i}"); + assert!( + (px - cx).abs() < 1e-2 && (py - cy).abs() < 1e-2, + "{name} coin {i}" + ); } for i in 0..=8 { for j in 0..=8 { @@ -1967,7 +2318,10 @@ mod tilt_tests { quad.corners[i].0.hypot(quad.corners[i].1) / rx.hypot(ry) }; let (m_tr, m_bl) = (magnification(1, 1.0, 0.0), magnification(3, 0.0, 1.0)); - assert!(m_tr > m_bl * 1.2, "TR x{m_tr} devrait être nettement plus grossi que BL x{m_bl}"); + assert!( + m_tr > m_bl * 1.2, + "TR x{m_tr} devrait être nettement plus grossi que BL x{m_bl}" + ); } /// Le focus au centre du plan est à la profondeur du centre (0), et `k` reste nul réglage @@ -2025,7 +2379,10 @@ mod tilt_tests { // d'un quad incliné ne peuvent pas rester alignés deux à deux comme un rectangle droit. let corners = rotated_quad_corners_px(1920.0, 1080.0, preset("iso"), [0.0; 3]).corners; let top_edge_slope = (corners[1].1 - corners[0].1).abs(); - assert!(top_edge_slope > 1.0, "arête supérieure horizontale : le tilt a disparu"); + assert!( + top_edge_slope > 1.0, + "arête supérieure horizontale : le tilt a disparu" + ); } fn preset(name: &str) -> [f32; 3] { @@ -2036,7 +2393,10 @@ mod tilt_tests { pub(super) fn min_edge_angle(c: &[(f32, f32); 4]) -> f32 { let h = |p: (f32, f32), q: (f32, f32)| (q.1 - p.1).atan2(q.0 - p.0).to_degrees().abs(); let v = |p: (f32, f32), q: (f32, f32)| (q.0 - p.0).atan2(q.1 - p.1).to_degrees().abs(); - h(c[0], c[1]).min(h(c[3], c[2])).min(v(c[0], c[3])).min(v(c[1], c[2])) + h(c[0], c[1]) + .min(h(c[3], c[2])) + .min(v(c[0], c[3])) + .min(v(c[1], c[2])) } /// Débordement relatif du quad hors du rect d'origine (> 0 = déborde). @@ -2052,7 +2412,11 @@ mod tilt_tests { let b = DYNAMIC_TILT_BUDGET; (-n..=n).flat_map(move |i| { (-n..=n).map(move |j| { - [b[0] * g * i as f32 / n as f32, b[1] * g * j as f32 / n as f32, 0.0] + [ + b[0] * g * i as f32 / n as f32, + b[1] * g * j as f32 / n as f32, + 0.0, + ] }) }) } @@ -2094,8 +2458,14 @@ mod tilt_tests { min_edge_angle(&c) < 2.0 || overflow(&c, w, h) > 0.0 }; let b = DYNAMIC_TILT_BUDGET; - assert!(breaks([b[0] * 1.15, 0.0, 0.0]) || breaks([-b[0] * 1.15, 0.0, 0.0]), "X"); - assert!(breaks([0.0, b[1] * 1.15, 0.0]) || breaks([0.0, -b[1] * 1.15, 0.0]), "Y"); + assert!( + breaks([b[0] * 1.15, 0.0, 0.0]) || breaks([-b[0] * 1.15, 0.0, 0.0]), + "X" + ); + assert!( + breaks([0.0, b[1] * 1.15, 0.0]) || breaks([0.0, -b[1] * 1.15, 0.0]), + "Y" + ); assert!(breaks([0.0, 0.0, 1.2]) && breaks([0.0, 0.0, -1.2]), "Z"); assert_eq!(b[2], 0.0, "Z n'a pas de budget"); } @@ -2146,7 +2516,13 @@ mod tilt_tests { }; // `follow-cursor` n'incline pas l'écran (caméra réelle, `camera.rs`) : chaînée à un angle // fixe, la transition passe par l'écran droit, que ce balayage couvre aussi. - let presets = [None, Some("iso"), Some("left"), Some("right"), Some("follow-cursor")]; + let presets = [ + None, + Some("iso"), + Some("left"), + Some("right"), + Some("follow-cursor"), + ]; for a in presets { for b in presets { // Transition chaînée sur [4, 5] s. @@ -2156,7 +2532,10 @@ mod tilt_tests { let state = zoom_state_at(®ions, t, None); let base = state.rotation; // Jamais les deux modèles à la fois. - assert!(state.camera == 0.0 || is_identity_rotation(base), "{a:?}→{b:?} t {t}"); + assert!( + state.camera == 0.0 || is_identity_rotation(base), + "{a:?}→{b:?} t {t}" + ); let gate = smoothstep(PARALLAX_GATE_START, 1.0, state.tilt); for (w, h) in [(1920.0f32, 1080.0f32), (1080.0, 1920.0), (800.0, 800.0)] { let still = rotated_quad_corners_px(w, h, base, [0.0; 3]).corners; @@ -2188,7 +2567,11 @@ mod tilt_tests { // entre deux caméras réelles, la caméra aussi. if a.is_some() && a == b { let mid = zoom_state_at(®ions, 4.5, None); - let open = if a == Some("follow-cursor") { mid.camera } else { mid.tilt }; + let open = if a == Some("follow-cursor") { + mid.camera + } else { + mid.tilt + }; assert_eq!(open, 1.0, "{a:?}"); } } @@ -2246,7 +2629,10 @@ mod tilt_tests { "force {strength}" ); } - assert_ne!(dynamic_tilt(1.0, Some(&track), FULL_CUT, 0.95, [0.0; 3]), [0.0; 3]); + assert_ne!( + dynamic_tilt(1.0, Some(&track), FULL_CUT, 0.95, [0.0; 3]), + [0.0; 3] + ); } /// Le geste penche le plan dans son sens, puis le plan revient à la pose du préset au repos. @@ -2261,7 +2647,9 @@ mod tilt_tests { assert!(rest[1].abs() < 0.05, "au repos : {rest:?}"); // Vers le bas → le bord bas recule (−X). let down = CursorTrack::new( - (0..=60).map(|i| (i as f32 / 30.0, 0.5, 0.1 + 0.4 * i as f32 / 30.0)).collect(), + (0..=60) + .map(|i| (i as f32 / 30.0, 0.5, 0.1 + 0.4 * i as f32 / 30.0)) + .collect(), vec![], vec![], ); @@ -2277,7 +2665,10 @@ mod tilt_tests { let track = swipe(speed, 9.0); for cut in [FULL_CUT, [0.4, 0.4, 0.45, 0.45]] { let d = dynamic_tilt(1.0, Some(&track), cut, 1.0, [0.0; 3]); - assert!(d[0].abs() <= b[0] && d[1].abs() <= b[1] && d[2] == 0.0, "{speed} {d:?}"); + assert!( + d[0].abs() <= b[0] && d[1].abs() <= b[1] && d[2] == 0.0, + "{speed} {d:?}" + ); } } let fast = dynamic_tilt(1.0, Some(&swipe(1000.0, 9.0)), FULL_CUT, 1.0, [0.0; 3]); @@ -2315,8 +2706,14 @@ mod tilt_tests { (hi, hi_ms) = (v, ms); } } - assert!((lo + 1.0).abs() < 1e-3 && (lo_ms - 49.5).abs() < 0.3, "creux {lo} à {lo_ms} ms"); - assert!((hi - 0.164).abs() < 1e-3 && (hi_ms - 165.3).abs() < 0.5, "rebond {hi} à {hi_ms} ms"); + assert!( + (lo + 1.0).abs() < 1e-3 && (lo_ms - 49.5).abs() < 0.3, + "creux {lo} à {lo_ms} ms" + ); + assert!( + (hi - 0.164).abs() < 1e-3 && (hi_ms - 165.3).abs() < 0.5, + "rebond {hi} à {hi_ms} ms" + ); assert_eq!(tap(1.0), 0.0); assert_eq!(tap(-1e-4), 0.0); assert_eq!(tap(0.0), 0.0); @@ -2325,13 +2722,21 @@ mod tilt_tests { // Pas de cassure de pente (celle de `bounce` à 98,8 ms) : dérivée seconde bornée partout. let d2 = |e: f32| (tap(e + 1e-3) - 2.0 * tap(e) + tap(e - 1e-3)) / 1e-6; for i in 2..998 { - assert!(d2(i as f32 / 1000.0).abs() < 60.0, "cassure à e = {}", i as f32 / 1000.0); + assert!( + d2(i as f32 / 1000.0).abs() < 60.0, + "cassure à e = {}", + i as f32 / 1000.0 + ); } } /// Une piste immobile au point `(x, y)`, avec des clics. fn still(x: f32, y: f32, clicks: Vec) -> CursorTrack { - CursorTrack::new((0..=90).map(|i| (i as f32 / 30.0, x, y)).collect(), clicks, vec![]) + CursorTrack::new( + (0..=90).map(|i| (i as f32 / 30.0, x, y)).collect(), + clicks, + vec![], + ) } /// La coupe entière, sans restriction : le point de la piste EST le point du plan. @@ -2346,22 +2751,43 @@ mod tilt_tests { fn the_clicked_side_recedes() { let t = 1.0 + 49.5 / 1000.0; let right = click_impact(t, &still(1.0, 0.5, vec![1.0]), WHOLE, full_aim); - assert!((right[1] - CLICK_IMPACT_DEG).abs() < 1e-2 && right[0].abs() < 1e-6, "{right:?}"); + assert!( + (right[1] - CLICK_IMPACT_DEG).abs() < 1e-2 && right[0].abs() < 1e-6, + "{right:?}" + ); let bottom = click_impact(t, &still(0.5, 1.0, vec![1.0]), WHOLE, full_aim); - assert!((bottom[0] + CLICK_IMPACT_DEG).abs() < 1e-2 && bottom[1].abs() < 1e-6, "{bottom:?}"); + assert!( + (bottom[0] + CLICK_IMPACT_DEG).abs() < 1e-2 && bottom[1].abs() < 1e-6, + "{bottom:?}" + ); let z = |x: f32, y: f32, rot: [f32; 3]| rotate_corner(x, y, rot).2; for name in ["iso", "left", "right"] { let base = preset(name); let with = |d: [f32; 3]| [base[0] + d[0], base[1] + d[1], base[2] + d[2]]; // Milieu du bord droit / du bord bas, et le bord opposé qui avance. - assert!(z(960.0, 0.0, with(right)) < z(960.0, 0.0, base), "{name} droite"); - assert!(z(-960.0, 0.0, with(right)) > z(-960.0, 0.0, base), "{name} gauche"); - assert!(z(0.0, 540.0, with(bottom)) < z(0.0, 540.0, base), "{name} bas"); - assert!(z(0.0, -540.0, with(bottom)) > z(0.0, -540.0, base), "{name} haut"); + assert!( + z(960.0, 0.0, with(right)) < z(960.0, 0.0, base), + "{name} droite" + ); + assert!( + z(-960.0, 0.0, with(right)) > z(-960.0, 0.0, base), + "{name} gauche" + ); + assert!( + z(0.0, 540.0, with(bottom)) < z(0.0, 540.0, base), + "{name} bas" + ); + assert!( + z(0.0, -540.0, with(bottom)) > z(0.0, -540.0, base), + "{name} haut" + ); } // Au centre : pas d'axe, rien ne bouge. - assert_eq!(click_impact(t, &still(0.5, 0.5, vec![1.0]), WHOLE, full_aim), [0.0; 3]); + assert_eq!( + click_impact(t, &still(0.5, 0.5, vec![1.0]), WHOLE, full_aim), + [0.0; 3] + ); } /// Double clic : la somme est bornée — à 33 ms d'écart elle atteindrait 1,81. @@ -2371,23 +2797,53 @@ mod tilt_tests { let mut peak = 0.0f32; for i in 0..400 { let d = click_impact(1.0 + i as f32 / 1000.0, &track, WHOLE, full_aim); - assert!(d[0].abs() <= CLICK_IMPACT_DEG && d[1].abs() <= CLICK_IMPACT_DEG, "{d:?}"); + assert!( + d[0].abs() <= CLICK_IMPACT_DEG && d[1].abs() <= CLICK_IMPACT_DEG, + "{d:?}" + ); peak = peak.max(d[1]); } - assert_eq!(peak, CLICK_IMPACT_DEG, "la somme brute dépasse 1 et se fait borner"); + assert_eq!( + peak, CLICK_IMPACT_DEG, + "la somme brute dépasse 1 et se fait borner" + ); } /// Rien hors de la fenêtre du clic, du clip actif ou de la coupe visible. #[test] fn clicks_outside_the_window_the_clip_or_the_crop_do_nothing() { let track = still(1.0, 0.5, vec![1.0]); - assert_eq!(click_impact(0.99, &track, WHOLE, full_aim), [0.0; 3], "avant le clic"); - assert_eq!(click_impact(1.26, &track, WHOLE, full_aim), [0.0; 3], "après 260 ms"); + assert_eq!( + click_impact(0.99, &track, WHOLE, full_aim), + [0.0; 3], + "avant le clic" + ); + assert_eq!( + click_impact(1.26, &track, WHOLE, full_aim), + [0.0; 3], + "après 260 ms" + ); let t = 1.05; - assert_eq!(click_impact(t, &track, [1.01, 9.0], full_aim), [0.0; 3], "clic coupé avant"); - assert_eq!(click_impact(t, &track, [0.0, 1.0], full_aim), [0.0; 3], "fin exclusive"); - assert_ne!(click_impact(t, &track, [1.0, 9.0], full_aim), [0.0; 3], "début inclus"); - assert_eq!(click_impact(t, &track, WHOLE, |_| None), [0.0; 3], "hors coupe"); + assert_eq!( + click_impact(t, &track, [1.01, 9.0], full_aim), + [0.0; 3], + "clic coupé avant" + ); + assert_eq!( + click_impact(t, &track, [0.0, 1.0], full_aim), + [0.0; 3], + "fin exclusive" + ); + assert_ne!( + click_impact(t, &track, [1.0, 9.0], full_aim), + [0.0; 3], + "début inclus" + ); + assert_eq!( + click_impact(t, &track, WHOLE, |_| None), + [0.0; 3], + "hors coupe" + ); assert_eq!(dynamic_tilt(t, None, FULL_CUT, 1.0, [0.0; 3]), [0.0; 3]); } @@ -2398,7 +2854,15 @@ mod tilt_tests { (0..=90) .map(|i| { let t = i as f32 / 30.0; - (t, if t <= 1.0 { 1.0 } else { (1.0 - 3.0 * (t - 1.0)).max(0.0) }, 0.5) + ( + t, + if t <= 1.0 { + 1.0 + } else { + (1.0 - 3.0 * (t - 1.0)).max(0.0) + }, + 0.5, + ) }) .collect(), vec![1.0], @@ -2422,7 +2886,10 @@ mod tilt_tests { let impact = [-CLICK_IMPACT_DEG, CLICK_IMPACT_DEG, 0.0]; for k in 0..=85 { let strength = k as f32 / 100.0; - assert_eq!(dynamic_tilt(1.0, None, FULL_CUT, strength, impact), [0.0; 3]); + assert_eq!( + dynamic_tilt(1.0, None, FULL_CUT, strength, impact), + [0.0; 3] + ); } assert_eq!(dynamic_tilt(1.0, None, FULL_CUT, 1.0, impact), impact); let b = DYNAMIC_TILT_BUDGET; @@ -2433,7 +2900,10 @@ mod tilt_tests { assert!(d[1] <= b[1] && d[1] > b[1] - 1e-3, "{d:?}"); let d = dynamic_tilt(1.0, Some(&fast), FULL_CUT, 0.95, impact); let gate = smoothstep(PARALLAX_GATE_START, 1.0, 0.95); - assert!(d[1] <= b[1] * gate + 1e-5 && d[0].abs() <= b[0] * gate + 1e-5, "{d:?}"); + assert!( + d[1] <= b[1] * gate + 1e-5 && d[0].abs() <= b[0] * gate + 1e-5, + "{d:?}" + ); } /// Échelle gelée pendant l'impact : seuls les coins bougent, et assez pour se voir. @@ -2474,7 +2944,11 @@ mod tilt_tests { }; assert_eq!(zoom_state_at(&[region(true)], 5.0, None).click_impact, 1.0); assert_eq!(zoom_state_at(&[region(false)], 5.0, None).click_impact, 0.0); - assert_eq!(zoom_state_at(&[region(true)], 0.0, None).click_impact, 0.0, "hors région"); + assert_eq!( + zoom_state_at(&[region(true)], 0.0, None).click_impact, + 0.0, + "hors région" + ); } /// `ZoomState::tilt` : la force pour une région à préset, 0 sans préset. @@ -2522,7 +2996,10 @@ mod tilt_tests { let e = sub(c10, c00); let f = sub(c01, c00); let g = (c00.0 - c10.0 - c01.0 + c11.0, c00.1 - c10.1 - c01.1 + c11.1); - (c00.0 + e.0 * s + f.0 * t + g.0 * s * t, c00.1 + e.1 * s + f.1 * t + g.1 * s * t) + ( + c00.0 + e.0 * s + f.0 * t + g.0 * s * t, + c00.1 + e.1 * s + f.1 * t + g.1 * s * t, + ) } /// `None` = le shader rend ce pixel TRANSPARENT (donc un trou dans l'écran incliné). @@ -2621,7 +3098,12 @@ mod exporter_frame_totals { use crate::scene::SceneSpeedRegion; fn region(start_sec: f64, end_sec: f64, speed: f64) -> SceneSpeedRegion { - SceneSpeedRegion { clip_index: None, start_sec, end_sec, speed } + SceneSpeedRegion { + clip_index: None, + start_sec, + end_sec, + speed, + } } fn frames(start_sec: f64, end_sec: f64, regions: &[SceneSpeedRegion], fps: f64) -> u64 { @@ -2649,7 +3131,11 @@ mod exporter_frame_totals { 240, "1,25× : 80 % de 300, exactement le symptôme" ); - assert_eq!(frames(0.0, 10.0, &[region(0.0, 10.0, 0.5)], FPS), 600, "0,5×"); + assert_eq!( + frames(0.0, 10.0, &[region(0.0, 10.0, 0.5)], FPS), + 600, + "0,5×" + ); assert_eq!( frames(0.0, 10.0, &[region(2.0, 4.0, 2.0)], FPS), 60 + 30 + 180, @@ -2661,7 +3147,12 @@ mod exporter_frame_totals { "région débordant la fenêtre gardée" ); assert_eq!( - frames(0.0, 10.0, &[region(2.0, 6.0, 2.0), region(4.0, 8.0, 4.0)], FPS), + frames( + 0.0, + 10.0, + &[region(2.0, 6.0, 2.0), region(4.0, 8.0, 4.0)], + FPS + ), 60 + 60 + 15 + 60, "recouvrement : la première région garde la portion déjà couverte" ); @@ -2803,7 +3294,11 @@ mod follow_camera_tests { } fn whole(track: &CursorTrack) -> CameraFrame<'_> { - CameraFrame { track: Some(track), crop: [0.0, 0.0, 1.0, 1.0], window: [0.0, 100.0] } + CameraFrame { + track: Some(track), + crop: [0.0, 0.0, 1.0, 1.0], + window: [0.0, 100.0], + } } /// Sous `follow-cursor`, l'écran n'est pas incliné et ne glisse pas (focus au centre, quel que @@ -2816,25 +3311,67 @@ mod follow_camera_tests { let f = whole(&tr); let r = [region("follow-cursor", 2.0, 8.0)]; let full = zoom_state_in(&r, 5.0, Some(&tr), &f, &ScreenClock::default()); - assert_eq!((full.rotation, full.tilt, full.click_impact, full.camera), ([0.0; 3], 0.0, 1.0, 1.0)); + assert_eq!( + (full.rotation, full.tilt, full.click_impact, full.camera), + ([0.0; 3], 0.0, 1.0, 1.0) + ); assert_eq!((full.scale, full.focus), (2.0, [0.5, 0.5])); - assert!(full.aim[0] > 0.7 && (full.aim[1] - 0.5).abs() < 1e-3, "{:?}", full.aim); - assert!((full.orbit[0] - 0.85).abs() < 1e-3 && (full.orbit[1] - 0.5).abs() < 1e-3, "{:?}", full.orbit); + assert!( + full.aim[0] > 0.7 && (full.aim[1] - 0.5).abs() < 1e-3, + "{:?}", + full.aim + ); + assert!( + (full.orbit[0] - 0.85).abs() < 1e-3 && (full.orbit[1] - 0.5).abs() < 1e-3, + "{:?}", + full.orbit + ); let easing = zoom_state_in(&r, 1.5, Some(&tr), &f, &ScreenClock::default()); - assert!(easing.camera > 0.0 && easing.camera < 1.0, "{}", easing.camera); + assert!( + easing.camera > 0.0 && easing.camera < 1.0, + "{}", + easing.camera + ); let raw = follow_at(&r[0], 1.5, &f); - assert_eq!((easing.aim, easing.orbit), (weighted(raw.aim, easing.camera), weighted(raw.orbit, easing.camera))); + assert_eq!( + (easing.aim, easing.orbit), + ( + weighted(raw.aim, easing.camera), + weighted(raw.orbit, easing.camera) + ) + ); assert_eq!(easing.focus, [0.5, 0.5]); let out = zoom_state_in(&r, 0.0, Some(&tr), &f, &ScreenClock::default()); - assert_eq!((out.scale, out.rotation, out.camera, out.aim, out.orbit), (1.0, [0.0; 3], 0.0, [0.5; 2], [0.5; 2])); + assert_eq!( + (out.scale, out.rotation, out.camera, out.aim, out.orbit), + (1.0, [0.0; 3], 0.0, [0.5; 2], [0.5; 2]) + ); // Sans piste, la caméra vise le centre, au repos. let blind = zoom_state_at(&r, 5.0, None); assert_eq!((blind.aim, blind.orbit), ([0.5, 0.5], [0.5, 0.5])); // Un angle fixe garde exactement son état. - let iso = zoom_state_in(&[region("iso", 2.0, 8.0)], 5.0, Some(&tr), &f, &ScreenClock::default()); - assert_eq!((iso.rotation, iso.camera, iso.click_impact), ([-12.0, -18.0, -2.0], 0.0, 1.0)); - let unknown = zoom_state_in(&[region("orbit", 2.0, 8.0)], 5.0, Some(&tr), &f, &ScreenClock::default()); - assert_eq!((unknown.rotation, unknown.tilt, unknown.camera), ([0.0; 3], 0.0, 0.0)); + let iso = zoom_state_in( + &[region("iso", 2.0, 8.0)], + 5.0, + Some(&tr), + &f, + &ScreenClock::default(), + ); + assert_eq!( + (iso.rotation, iso.camera, iso.click_impact), + ([-12.0, -18.0, -2.0], 0.0, 1.0) + ); + let unknown = zoom_state_in( + &[region("orbit", 2.0, 8.0)], + 5.0, + Some(&tr), + &f, + &ScreenClock::default(), + ); + assert_eq!( + (unknown.rotation, unknown.tilt, unknown.camera), + ([0.0; 3], 0.0, 0.0) + ); } /// Focus auto ou manuel, même visée : elle se lit dans l'image source, pas dans la coupe. @@ -2844,8 +3381,20 @@ mod follow_camera_tests { let mut auto = region("follow-cursor", 2.0, 8.0); auto.focus_mode = Some("auto".into()); let manual = region("follow-cursor", 2.0, 8.0); - let a = zoom_state_in(&[auto], 5.0, Some(&tr), &whole(&tr), &ScreenClock::default()); - let m = zoom_state_in(&[manual], 5.0, Some(&tr), &whole(&tr), &ScreenClock::default()); + let a = zoom_state_in( + &[auto], + 5.0, + Some(&tr), + &whole(&tr), + &ScreenClock::default(), + ); + let m = zoom_state_in( + &[manual], + 5.0, + Some(&tr), + &whole(&tr), + &ScreenClock::default(), + ); assert_eq!((a.aim, a.focus), (m.aim, m.focus)); assert!(a.aim[0] > 0.7 && a.aim[1] < 0.35, "{:?}", a.aim); } @@ -2859,7 +3408,10 @@ mod follow_camera_tests { for regions in [ [region("iso", 1.0, 4.0), region("follow-cursor", 4.5, 8.0)], [region("follow-cursor", 1.0, 4.0), region("right", 4.5, 8.0)], - [region("follow-cursor", 1.0, 4.0), region("follow-cursor", 4.5, 8.0)], + [ + region("follow-cursor", 1.0, 4.0), + region("follow-cursor", 4.5, 8.0), + ], ] { let both = regions[0].rotation == regions[1].rotation; let mut last: Option = None; @@ -2867,14 +3419,22 @@ mod follow_camera_tests { // La transition chaînée couvre [4, 5] s, le palier la suit jusqu'à 4,5 s. let t = 3.9 + k as f32 * 1.3 / 400.0; let s = zoom_state_in(®ions, t, Some(&tr), &f, &ScreenClock::default()); - assert!(s.camera == 0.0 || is_identity_rotation(s.rotation), "t {t} : {s:?}", s = (s.camera, s.rotation)); + assert!( + s.camera == 0.0 || is_identity_rotation(s.rotation), + "t {t} : {s:?}", + s = (s.camera, s.rotation) + ); if both { assert_eq!(s.camera, 1.0, "t {t}"); } if let Some(p) = &last { let jump = (s.camera - p.camera).abs() - + (0..3).map(|i| (s.rotation[i] - p.rotation[i]).abs() / 20.0).sum::() - + (0..2).map(|i| (s.aim[i] - p.aim[i]).abs() + (s.orbit[i] - p.orbit[i]).abs()).sum::(); + + (0..3) + .map(|i| (s.rotation[i] - p.rotation[i]).abs() / 20.0) + .sum::() + + (0..2) + .map(|i| (s.aim[i] - p.aim[i]).abs() + (s.orbit[i] - p.orbit[i]).abs()) + .sum::(); assert!(jump < 0.05, "saut à t {t} : {jump}"); } last = Some(s); @@ -2889,7 +3449,13 @@ mod follow_camera_tests { let f = whole(&tr); let r = [region("follow-cursor", 2.0, 8.0)]; let at = |i: usize| { - let s = zoom_state_in(&r, 1.0 + i as f32 / 30.0, Some(&tr), &f, &ScreenClock::default()); + let s = zoom_state_in( + &r, + 1.0 + i as f32 / 30.0, + Some(&tr), + &f, + &ScreenClock::default(), + ); (s.camera, s.aim, s.orbit, s.scale) }; let forward: Vec<_> = (0..240).map(at).collect(); diff --git a/crates/compositor/src/scene.rs b/crates/compositor/src/scene.rs index 36dac9bb7..163b5edac 100644 --- a/crates/compositor/src/scene.rs +++ b/crates/compositor/src/scene.rs @@ -45,6 +45,11 @@ pub struct SceneLayout { pub webcam_position: Option, /// la webcam rétrécit pendant un zoom actif. pub webcam_reactive_zoom: bool, + /// Picture-in-picture : le coin ou le milieu de bord où la caméra est ancrée, à marge + /// constante du bord (`WEBCAM_ANCHORS`, TS : "top-left", "top", …, "bottom-right"). Le + /// rétrécissement du zoom réactif se fait vers lui (`anchor_fractions`). Absent : le centre. + #[serde(default)] + pub webcam_anchor: Option, /// User-authored source crop for the camera. Absent keeps the full frame. #[serde(default)] pub webcam_crop: Option, @@ -111,6 +116,19 @@ pub struct SceneLayout { pub webcam_radius_frac: Option, } +impl SceneLayout { + /// L'ancre de la caméra en fractions de sa boîte, par axe : 0, 0,5 ou 1, la part de la place + /// libre qu'elle laisse d'un côté (`webcamAnchorFractions`, TS). Le centre sans ancre. + pub fn webcam_anchor_fractions(&self) -> [f32; 2] { + let Some(anchor) = self.webcam_anchor.as_deref() else { return [0.5, 0.5] }; + let side = |low: bool, high: bool| if low { 0.0 } else if high { 1.0 } else { 0.5 }; + [ + side(anchor.ends_with("left"), anchor.ends_with("right")), + side(anchor.starts_with("top"), anchor.starts_with("bottom")), + ] + } +} + /// La moitié du layout qui dépend de la FORME de la source, résolue pour un clip. /// Voir `SceneLayout::layout_by_clip`. /// diff --git a/crates/compositor/src/timeline_walk.rs b/crates/compositor/src/timeline_walk.rs index 67218ccf8..f139e6a2c 100644 --- a/crates/compositor/src/timeline_walk.rs +++ b/crates/compositor/src/timeline_walk.rs @@ -158,7 +158,11 @@ pub(crate) unsafe fn walk_composited_timeline( on_frame: &mut dyn FnMut(u64) -> Result<()>, on_clip_end: &mut dyn FnMut(usize, f64, u64, &[SpeedSegment]) -> Result<()>, ) -> Result { - let cursor_enabled = scene.as_ref().map(|s| s.cursor.show).unwrap_or(false); + // La piste se charge même curseur MASQUÉ : le focus auto des zooms la suit, comme en preview + // (qui la charge toujours). Sans elle, un zoom auto suivait en preview et restait figé à + // l'export. Ce qui ne doit rien voir d'un curseur masqué a sa propre porte sur `cursor.show` : + // le dessin (`cursor_alpha`), la parallaxe, l'impact du clic et la caméra `follow-cursor`. + let cursor_enabled = scene.is_some(); let cursor_smoothing = scene.as_ref().map(|s| s.cursor.smoothing).unwrap_or(0.0); let mut cursor_tracks: HashMap = HashMap::new(); let mut cursor_active_path: Option = None; diff --git a/src/components/video-editor/types.ts b/src/components/video-editor/types.ts index d2ac07008..42c33f024 100644 --- a/src/components/video-editor/types.ts +++ b/src/components/video-editor/types.ts @@ -83,7 +83,7 @@ export type FixedRotation3DPreset = (typeof FIXED_ROTATION_3D_PRESETS)[number]; * A moving 3D camera. `follow-cursor` keeps the screen still and moves a real camera around it: * the camera orbits to the side the cursor is on and rises or dips with it, always level. The * native compositor renders it (`crates/compositor/src/camera.rs`). It needs the cursor track, - * which the export only loads while the cursor is shown. + * which only drives it while the cursor is shown. */ export const MOVING_ROTATION_3D_PRESETS = ["follow-cursor"] as const; export type MovingRotation3DPreset = (typeof MOVING_ROTATION_3D_PRESETS)[number]; diff --git a/src/native/sceneDescription.test.ts b/src/native/sceneDescription.test.ts index 153a48947..09935b53f 100644 --- a/src/native/sceneDescription.test.ts +++ b/src/native/sceneDescription.test.ts @@ -1099,6 +1099,15 @@ describe("buildSceneDescription.settings mapping", () => { expect(buildSceneDescription(high).layout.webcamSize).toBeCloseTo(0.35, 5); }); + it("carries the camera's anchor, the point its zoom-time shrink keeps fixed", () => { + expect(buildSceneDescription(makeDoc({})).layout.webcamAnchor).toBe("bottom-right"); + const top = makeDoc({ legacyEditor: { webcamAnchor: "top" } }); + expect(buildSceneDescription(top).layout.webcamAnchor).toBe("top"); + // An older project stored a free position: it reads as the anchor nearest to it. + const legacy = makeDoc({ legacyEditor: { webcamPosition: { cx: 0.1, cy: 0.9 } } }); + expect(buildSceneDescription(legacy).layout.webcamAnchor).toBe("bottom-left"); + }); + it("keeps the flat cursor for an older project and carries the 3D cursor switch", () => { expect(buildSceneDescription(makeDoc({ legacyEditor: {} })).cursor.model3d).toBe(false); const on = makeDoc({ legacyEditor: { cursorModel3d: true } }); diff --git a/src/native/sceneDescription.ts b/src/native/sceneDescription.ts index a3d8cf816..32f577971 100644 --- a/src/native/sceneDescription.ts +++ b/src/native/sceneDescription.ts @@ -55,7 +55,7 @@ import { webcamSizeToFraction, } from "@/lib/compositeLayout"; import { parseCssGradient, resolveLinearGradientAngle } from "@/lib/exporter/gradientParser"; -import type { FrameTheme, RecordingFrame } from "@/lib/projectDefaults"; +import type { FrameTheme, RecordingFrame, WebcamAnchor } from "@/lib/projectDefaults"; import type { CompositorClipInput } from "./contracts"; /** Background behind the screen. Parsed from `settings.wallpaper`. */ @@ -268,6 +268,12 @@ export interface SceneLayout { webcamPosition: { cx: number; cy: number } | null; /** Webcam shrinks while a zoom region is active. */ webcamReactiveZoom: boolean; + /** + * Picture-in-picture: the corner or edge middle the camera is anchored to, at a constant + * margin from the border (`computeCompositeLayout`). The zoom-time shrink keeps that anchor + * fixed, so a corner camera keeps its margin to both edges. + */ + webcamAnchor: WebcamAnchor; /** User-authored webcam framing, as fractions of the camera source. */ webcamCrop: { x: number; y: number; width: number; height: number }; /** @@ -1070,6 +1076,7 @@ export function buildSceneDescription( settings.webcamLayoutPreset, settings.webcamReactiveZoom, ), + webcamAnchor: settings.webcamAnchor, webcamCrop: settings.webcamCropRegion, webcamRect, screenRect,