diff --git a/crates/compositor/src/cursor.rs b/crates/compositor/src/cursor.rs index 621309e67..ced763690 100644 --- a/crates/compositor/src/cursor.rs +++ b/crates/compositor/src/cursor.rs @@ -50,6 +50,34 @@ fn sample_at(samples: &[(f32, f32, f32)], t: f32) -> Option<(f32, f32)> { Some((a.1 + (b.1 - a.1) * f, a.2 + (b.2 - a.2) * f)) } +/// Sous-intervalles de `[lo, hi]` une fois retranchée l'union de `blockers` : ce qui reste +/// du span d'une région après que les régions postérieures — qui gagnent le recouvrement — +/// y ont masqué leurs portions. Un `blockers` vide rend `[(lo, hi)]` ; une région entièrement +/// recouverte rend une liste vide et ne pose aucun point. +fn visible_spans(lo: f32, hi: f32, blockers: &[(f32, f32)]) -> Vec<(f32, f32)> { + let mut sorted: Vec<(f32, f32)> = + blockers.iter().copied().filter(|(b_lo, b_hi)| *b_lo < hi && *b_hi > lo).collect(); + sorted.sort_by(|a, b| a.0.partial_cmp(&b.0).unwrap_or(std::cmp::Ordering::Equal)); + let mut spans = Vec::new(); + let mut cursor = lo; + for (b_lo, b_hi) in sorted { + if b_hi <= cursor { + continue; // déjà masqué par un bloqueur précédent + } + if b_lo > cursor { + spans.push((cursor, b_lo)); + } + cursor = b_hi; + if cursor >= hi { + return spans; + } + } + if cursor < hi { + spans.push((cursor, hi)); + } + spans +} + /// Port de `advanceFollowFocus` (`cursorFollowUtils.ts`) : lissage exponentiel dont le facteur /// croît avec la distance à la cible (loin = rattrape vite, près = décélère), corrigé en temps /// pour être indépendant de la cadence. @@ -219,6 +247,215 @@ impl CursorTrack { // déplace la trajectoire, pas la chronologie de ce que faisait l'utilisateur. CursorTrack::new(samples, self.clicks.clone(), self.types.clone()) } + + /// Piste dont les portions couvertes par une région éditée suivent la géométrie du preset + /// au lieu de la télémétrie. + /// + /// Appliqué UNE fois aux échantillons, comme `smoothed` et pour la même raison : la piste + /// reste une pure fonction de `t`, donc la preview et l'export rendent le même tracé, et un + /// seek retombe sur l'image de la lecture linéaire. + /// + /// La courbe est ré-échantillonnée à 240 Hz à l'intérieur des régions, indépendamment de la + /// densité de la télémétrie : le tracé est analytique, alors qu'une capture peut n'avoir que + /// quelques points sur la durée d'une région — les reprendre tels quels rendrait un arc en + /// ligne brisée. Hors régions, les échantillons bruts passent intacts. + /// + /// Les clics et les états gardent leurs instants (même principe que `smoothed`) : rien ici + /// ne retime ce que faisait l'utilisateur, seule la position change. + pub fn with_motion(&self, regions: &[CursorMotionRegion]) -> CursorTrack { + // `Recorded` est inerte : une région qui ne change rien ne doit pas déclencher le + // ré-échantillonnage, sinon elle remplacerait la télémétrie par sa propre relecture. + let active: Vec<&CursorMotionRegion> = regions + .iter() + .filter(|r| r.preset != CursorMotionPreset::Recorded && r.end_s > r.start_s) + .collect(); + if active.is_empty() { + return CursorTrack::new(self.samples.clone(), self.clicks.clone(), self.types.clone()); + } + + // Recouvrement : la DERNIÈRE région gagne, parité `findCursorMotionRegionAtTime` (TS) + // qui parcourt la liste à l'envers. + let covering = |t: f32| -> Option<&CursorMotionRegion> { + active.iter().rev().find(|r| t >= r.start_s && t <= r.end_s).copied() + }; + + const STEP_S: f32 = 1.0 / 240.0; + let mut samples: Vec<(f32, f32, f32)> = + self.samples.iter().filter(|(t, _, _)| covering(*t).is_none()).copied().collect(); + + for (i, region) in active.iter().enumerate() { + // La dernière région doit gagner sur TOUT son recouvrement, pas seulement aux + // instants échantillonnés : chaque région ne pose donc des points que sur ses + // sous-intervalles visibles (son span moins ceux des régions qui la suivent + // dans la liste). Ré-échantillonner un span recouvert entier entrelacerait les + // grilles des deux régions, et `sample_at` interpolerait d'une courbe à l'autre + // entre les points. + let blockers: Vec<(f32, f32)> = + active[i + 1..].iter().map(|r| (r.start_s, r.end_s)).collect(); + for (lo, hi) in visible_spans(region.start_s, region.end_s, &blockers) { + let span = hi - lo; + let steps = ((span / STEP_S).round() as usize).max(1); + for k in 0..=steps { + // Le dernier point est posé sur la borne exacte du sous-intervalle plutôt + // que sur la grille : la couture avec ce qui suit (télémétrie brute ou la + // région masquante elle-même) doit être franche, sinon l'interpolation de + // `sample_at` traverse un trou et coupe le raccord. + let t = if k == steps { hi } else { lo + k as f32 * STEP_S }; + let (x, y) = sample_region(region, t); + samples.push((t, x, y)); + } + } + } + + samples.sort_by(|a, b| a.0.partial_cmp(&b.0).unwrap_or(std::cmp::Ordering::Equal)); + CursorTrack::new(samples, self.clicks.clone(), self.types.clone()) + } +} + +/// Forme dessinée entre les deux ancres d'une région. `Recorded` est inerte : la région +/// existe pour porter une sélection dans l'éditeur sans toucher au tracé. +#[derive(Clone, Copy, PartialEq, Eq, Debug)] +pub enum CursorMotionPreset { + Recorded, + Straight, + Arc, + Wave, + Loop, + Overshoot, +} + +#[derive(Clone, Copy, PartialEq, Eq, Debug)] +pub enum CursorMotionEasing { + Linear, + EaseInOut, + EaseIn, + EaseOut, +} + +/// Une portion de trajectoire éditée, qui remplace la télémétrie entre `start_s` et `end_s`. +/// +/// Les ancres arrivent RÉSOLUES (l'éditeur possède la détection des rests, clics et coupes +/// manuelles) : ce module ne fait que de la géométrie, donc la même liste redonne toujours +/// le même tracé. `control` est un point absolu, pas un décalage — le sampler en prend +/// l'écart au milieu de [start, end], si bien qu'un point de contrôle laissé au milieu est +/// neutre pour tous les presets. +#[derive(Clone, Debug, PartialEq)] +pub struct CursorMotionRegion { + pub start_s: f32, + pub end_s: f32, + pub start: (f32, f32), + pub end: (f32, f32), + pub control: (f32, f32), + pub preset: CursorMotionPreset, + pub cycles: u32, + pub speed: f32, + pub easing: CursorMotionEasing, +} + +fn lerp(a: f32, b: f32, p: f32) -> f32 { + a + (b - a) * p +} + +/// Parité `clampCursorMotionCycles` (TS) : entier, borné 1..6. +fn clamp_cycles(cycles: u32) -> f32 { + cycles.clamp(1, 6) as f32 +} + +/// Parité `clampCursorMotionSpeed` (TS) : borné 1..4 PUIS arrondi au dixième. L'arrondi +/// n'est pas cosmétique — il entre dans l'exposant ci-dessous, donc l'omettre ferait +/// diverger le tracé de ce que l'éditeur a affiché. +fn clamp_speed(speed: f32) -> f32 { + let v = if speed.is_finite() { speed } else { 1.0 }; + (v.clamp(1.0, 4.0) * 10.0).round() / 10.0 +} + +/// Parité `applyCursorMotionSpeed` (TS) : `1 - (1-t)^vitesse`. Reprofile la progression, +/// ne retime pas la région — et démarre au premier instant, sans palier gelé en tête (un +/// palier avait rendu invisibles la plupart des courts déplacements à 2x). +fn apply_speed(progress: f32, speed: f32) -> f32 { + let t = progress.clamp(0.0, 1.0); + (1.0 - (1.0 - t).powf(clamp_speed(speed))).clamp(0.0, 1.0) +} + +/// Parité `easeProgress` (TS). +fn ease_progress(progress: f32, easing: CursorMotionEasing) -> f32 { + let t = progress.clamp(0.0, 1.0); + match easing { + CursorMotionEasing::EaseIn => t * t * t, + CursorMotionEasing::EaseOut => 1.0 - (1.0 - t).powi(3), + CursorMotionEasing::EaseInOut => { + if t < 0.5 { + 4.0 * t * t * t + } else { + 1.0 - (-2.0 * t + 2.0).powi(3) / 2.0 + } + } + CursorMotionEasing::Linear => t, + } +} + +/// Parité `easeOutBack` (TS) : dépasse la cible puis revient. +fn ease_out_back(progress: f32) -> f32 { + const C1: f32 = 1.70158; + const C3: f32 = C1 + 1.0; + let t = progress.clamp(0.0, 1.0) - 1.0; + 1.0 + C3 * t.powi(3) + C1 * t.powi(2) +} + +/// Position dans une région à l'instant `t` — port direct de `sampleCursorMotionRegion` +/// (TS). Fonction pure de `t` : c'est ce qui autorise à l'appliquer une fois aux +/// échantillons plutôt que par frame, donc à faire coïncider preview et export. +fn sample_region(region: &CursorMotionRegion, t: f32) -> (f32, f32) { + let duration = (region.end_s - region.start_s).max(0.001); + let raw = ((t - region.start_s) / duration).clamp(0.0, 1.0); + if raw <= 0.0 { + return region.start; + } + if raw >= 1.0 { + return region.end; + } + let motion = apply_speed(raw, region.speed); + if motion == 0.0 { + return region.start; + } + let progress = ease_progress(motion, region.easing); + + let mid = ((region.start.0 + region.end.0) / 2.0, (region.start.1 + region.end.1) / 2.0); + let offset = (region.control.0 - mid.0, region.control.1 - mid.1); + let envelope = (std::f32::consts::PI * motion).sin(); + + if region.preset == CursorMotionPreset::Overshoot { + let p = ease_out_back(progress); + return ( + lerp(region.start.0, region.end.0, p) + offset.0 * envelope * 0.35, + lerp(region.start.1, region.end.1, p) + offset.1 * envelope * 0.35, + ); + } + + let base = ( + lerp(region.start.0, region.end.0, progress), + lerp(region.start.1, region.end.1, progress), + ); + let cycles = clamp_cycles(region.cycles); + + match region.preset { + CursorMotionPreset::Arc => (base.0 + offset.0 * envelope, base.1 + offset.1 * envelope), + CursorMotionPreset::Wave => { + let wave = (std::f32::consts::PI * 2.0 * cycles * motion).sin() * envelope; + (base.0 + offset.0 * wave, base.1 + offset.1 * wave) + } + CursorMotionPreset::Loop => { + let phase = std::f32::consts::PI * 2.0 * cycles * motion; + let tangent = phase.sin() * envelope; + let normal = (1.0 - phase.cos()) * 0.5 * envelope; + ( + base.0 + offset.0 * tangent - offset.1 * normal, + base.1 + offset.1 * tangent + offset.0 * normal, + ) + } + // `Straight` suit la droite ; `Recorded` n'arrive jamais ici (filtré en amont). + _ => base, + } } /// Ressort-amortisseur, intégration semi-implicite (symplectique) d'Euler — stable pour ces @@ -277,6 +514,138 @@ mod tests { assert_eq!(track.type_at(99.0), Some("pointer"), "la dernière tient jusqu'à la fin"); } + fn region(preset: CursorMotionPreset) -> CursorMotionRegion { + CursorMotionRegion { + start_s: 1.0, + end_s: 2.0, + start: (0.0, 0.0), + end: (1.0, 0.0), + // Écarté du milieu (0.5, 0.0) : de quoi rendre les presets courbes visibles. + control: (0.5, 0.4), + preset, + cycles: 1, + speed: 1.0, + easing: CursorMotionEasing::Linear, + } + } + + /// Une piste qui fait un détour par le haut entre t=1 et t=2. + fn detour_track() -> CursorTrack { + CursorTrack::new( + vec![ + (0.0, 0.0, 0.0), + (1.0, 0.0, 0.0), + (1.5, 0.5, -0.9), // le détour que l'édition doit effacer + (2.0, 1.0, 0.0), + (3.0, 1.0, 0.0), + ], + vec![1.5], + vec![(0.0, "arrow".into())], + ) + } + + /// `Straight` remplace la trajectoire enregistrée par la droite entre les deux ancres : + /// au milieu de la région on doit être sur la corde, pas sur le détour capturé. + #[test] + fn straight_region_replaces_the_recorded_detour() { + let edited = detour_track().with_motion(&[region(CursorMotionPreset::Straight)]); + let (x, y) = edited.at(1.5).expect("position au milieu de la région"); + assert!((x - 0.5).abs() < 0.01, "x devrait être à mi-corde, obtenu {x}"); + assert!(y.abs() < 0.01, "le détour vertical devrait avoir disparu, obtenu {y}"); + } + + /// `Recorded` est inerte — c'est le préréglage par défaut, et il ne doit surtout pas + /// ré-échantillonner la piste : le tracé capturé passe intact. + #[test] + fn recorded_region_leaves_the_track_untouched() { + let raw = detour_track(); + let edited = raw.with_motion(&[region(CursorMotionPreset::Recorded)]); + assert_eq!(edited.sample_count(), raw.sample_count()); + assert_eq!(edited.at(1.5), raw.at(1.5), "le détour doit survivre"); + } + + /// Les bornes sont exactes : une région ne doit pas décaler la position aux instants où + /// elle se raccorde au reste de la piste, sinon la couture saute à l'image près. + #[test] + fn region_endpoints_land_exactly_on_their_anchors() { + let edited = detour_track().with_motion(&[region(CursorMotionPreset::Arc)]); + let (sx, sy) = edited.at(1.0).unwrap(); + let (ex, ey) = edited.at(2.0).unwrap(); + assert!(sx.abs() < 1e-4 && sy.abs() < 1e-4, "début ({sx}, {sy})"); + assert!((ex - 1.0).abs() < 1e-4 && ey.abs() < 1e-4, "fin ({ex}, {ey})"); + } + + /// Hors région, la télémétrie brute n'est pas touchée. + #[test] + fn samples_outside_the_region_survive() { + let edited = detour_track().with_motion(&[region(CursorMotionPreset::Straight)]); + assert_eq!(edited.at(0.0), Some((0.0, 0.0))); + assert_eq!(edited.at(3.0), Some((1.0, 0.0))); + } + + /// `Arc` s'écarte de la corde du côté du point de contrôle, au maximum en milieu de course. + #[test] + fn arc_bends_toward_its_control_point() { + let edited = detour_track().with_motion(&[region(CursorMotionPreset::Arc)]); + let (_, y) = edited.at(1.5).unwrap(); + assert!(y > 0.3, "l'arc devrait culminer vers le point de contrôle, obtenu {y}"); + } + + /// Recouvrement : la dernière région gagne, comme `findCursorMotionRegionAtTime` en TS. + #[test] + fn overlapping_regions_resolve_to_the_last_one() { + let mut second = region(CursorMotionPreset::Straight); + second.end = (1.0, 0.8); // destination distincte, pour trancher sans ambiguïté + let edited = + detour_track().with_motion(&[region(CursorMotionPreset::Arc), second]); + let (_, y) = edited.at(2.0).unwrap(); + assert!((y - 0.8).abs() < 1e-3, "la seconde région devrait l'emporter, obtenu {y}"); + } + + /// …et pas seulement AUX instants échantillonnés : ré-échantillonner chaque région sur + /// son span entier entrelace les grilles, et `sample_at` interpolerait d'une courbe à + /// l'autre entre deux points. Un temps hors grille des deux côtés doit donc rendre la + /// courbe de la région postérieure, au point près. + #[test] + fn overlap_keeps_the_last_region_between_sample_times() { + let mut second = region(CursorMotionPreset::Straight); + second.start_s = 1.25; + second.end_s = 2.0; + second.end = (1.0, 0.8); + let edited = + detour_track().with_motion(&[region(CursorMotionPreset::Arc), second.clone()]); + + // 1.401 n'est multiple de la grille 240 Hz d'aucune des deux régions (ancrées à + // 1.0 et 1.25) : toute coincidence de points est exclue du chemin de `sample_at`. + let t = 1.401; + let (x, y) = edited.at(t).expect("dans le recouvrement"); + let (want_x, want_y) = sample_region(&second, t); + assert!( + (x - want_x).abs() < 1e-3 && (y - want_y).abs() < 1e-3, + "la seconde région doit régner sur tout le recouvrement, obtenu ({x}, {y}), sa courbe dit ({want_x}, {want_y})" + ); + + // Avant le recouvrement, la première région reste seule maîtresse de son tracé. + let t = 1.1234; + let (x, y) = edited.at(t).expect("avant le recouvrement"); + let (want_x, want_y) = sample_region(®ion(CursorMotionPreset::Arc), t); + assert!( + (x - want_x).abs() < 1e-3 && (y - want_y).abs() < 1e-3, + "la première région doit rester seule avant le recouvrement, obtenu ({x}, {y}), sa courbe dit ({want_x}, {want_y})" + ); + } + + /// L'édition déplace la trajectoire, pas la chronologie : clics et états gardent leurs + /// instants, exactement comme sous `smoothed`. + #[test] + fn motion_preserves_clicks_and_types() { + let edited = detour_track().with_motion(&[region(CursorMotionPreset::Loop)]); + assert_eq!(edited.type_at(1.5), Some("arrow")); + // Échantillonné DANS la fenêtre d'animation, pas sur son bord : à l'instant même du + // clic la pression commence tout juste et vaut encore 1.0. + assert!(edited.bounce(1.55) < 1.0, "le clic à 1.5 s doit toujours produire son bounce"); + } + /// Le lissage déplace la trajectoire, pas la chronologie : les états doivent survivre /// intacts à `smoothed()`, comme les clics. #[test] diff --git a/crates/compositor/src/live.rs b/crates/compositor/src/live.rs index 552d2264a..7df162ff5 100644 --- a/crates/compositor/src/live.rs +++ b/crates/compositor/src/live.rs @@ -1348,6 +1348,9 @@ unsafe fn render_thread( let mut last_preview_size: (u32, u32) = (0, 0); let mut last_ip: Option = None; let mut last_smoothing: f32 = -1.0; // force la 1re application (0.0 est une valeur valide) + // Dernières régions appliquées. La liste vide est l'état de repos ET une valeur valide : + // c'est `last_smoothing = -1.0` qui garantit la première application, pas ce champ. + let mut last_motion: Vec = Vec::new(); // La vue live est TOUJOURS pilotée par la scène de l'app. Tant qu'aucune scène n'a été // appliquée, on refuse de jouer le layout fixture (POC) : un fallback fixture ne ferait que // MASQUER un scene-push cassé. On attend la scène avant de produire le 1er frame. @@ -1492,14 +1495,6 @@ unsafe fn render_thread( cursor_motion_blur: ip.cursor_motion_blur, has_webcam: has_real_webcam, }); - // Lissage ressort-amortisseur : re-génère la piste (240 Hz) uniquement quand la valeur - // change (pas à chaque frame — le resample+ressort parcourt tout l'enregistrement). - if let Some(raw) = &raw_cursor { - if ip.cursor_smoothing != last_smoothing { - comp.set_cursor(raw.smoothed(ip.cursor_smoothing)); - last_smoothing = ip.cursor_smoothing; - } - } // un changement de param doit se voir même en pause (édition live des sliders) : // on recompose la frame courante dans la branche pause ci-dessous. let ip_changed = last_ip != Some(ip); @@ -1529,6 +1524,38 @@ unsafe fn render_thread( comp.set_scene(scene); } + // Piste curseur : trajectoire éditée puis lissage ressort-amortisseur. Re-générée + // uniquement quand l'un des deux change — le resample 240 Hz parcourt tout + // l'enregistrement, c'est trop cher par frame. + // + // Placé APRÈS le bloc de scène, pas avant : les régions viennent de `full_scene`, et + // les lire au-dessus les prendrait à l'état du tour précédent — une trajectoire + // éditée n'apparaîtrait qu'à la frame suivante, ou jamais si rien d'autre ne bouge. + if let Some(raw) = &raw_cursor { + // Régions du clip ACTIF seulement : chacune appartient au clip dont le temps + // source la porte (`clipIndex`), et la piste rechargée est celle de ce clip. + // Sans propriétaire (`clipIndex` absent, scènes d'avant le champ) une région + // s'applique à toutes les pistes — le comportement historique. Le filtre lit + // `active_clip_index`, rafraîchi par les blocs set_active_clip et scène + // ci-dessus : placé avant, il prendrait le clip du tour précédent. + let motion: Vec = full_scene + .as_ref() + .map(|s| { + s.cursor + .motion + .iter() + .filter(|r| r.clip_index.map(|i| i == active_clip_index).unwrap_or(true)) + .map(Into::into) + .collect() + }) + .unwrap_or_default(); + if ip.cursor_smoothing != last_smoothing || motion != last_motion { + comp.set_cursor(raw.with_motion(&motion).smoothed(ip.cursor_smoothing)); + last_smoothing = ip.cursor_smoothing; + last_motion = motion; + } + } + // résolution cible du preview (le canvas Electron) → force le recadrage des // ressources GPU si elle change. BUG évité : sans ce suivi, redimensionner le // panneau preview PENDANT une pause ne redéclenchait ni recompose ni readback diff --git a/crates/compositor/src/scene.rs b/crates/compositor/src/scene.rs index b6fb420c0..e66caea12 100644 --- a/crates/compositor/src/scene.rs +++ b/crates/compositor/src/scene.rs @@ -382,6 +382,92 @@ pub struct SceneCursor { /// `#[serde(default)]` : champ ajouté après coup, absent des JSON de test existants. #[serde(default)] pub cursor_sprites: std::collections::HashMap, + /// Portions de trajectoire éditées, dans l'ordre de la timeline. Absent ou vide → la + /// télémétrie enregistrée joue telle quelle, ce que produit tout projet sans l'éditeur + /// de choréographie. `#[serde(default)]` pour cette raison : les scènes existantes ne + /// portent pas ce champ et doivent rester lisibles. + #[serde(default)] + pub motion: Vec, +} + +/// Point normalisé dans le cadre screen. +#[derive(Debug, Clone, Copy, Deserialize)] +pub struct ScenePoint { + pub cx: f32, + pub cy: f32, +} + +/// Une région de trajectoire éditée. Miroir de `SceneCursorMotionRegion` (TS, +/// `src/native/sceneDescription.ts`) — les ancres arrivent résolues, ce module ne fait que +/// de la géométrie. +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct SceneCursorMotionRegion { + #[allow(dead_code)] + pub id: String, + /// Index du clip dont le temps SOURCE porte cette région (voir `SceneZoomRegion`). + /// Absent pour les scènes d'avant le champ — la région s'applique alors à toutes les + /// pistes curseur, le comportement historique. + #[serde(default)] + pub clip_index: Option, + pub start_sec: f32, + pub end_sec: f32, + pub start_point: ScenePoint, + pub end_point: ScenePoint, + pub control_point: ScenePoint, + pub preset: SceneCursorMotionPreset, + pub cycles: u32, + pub speed: f32, + pub easing: SceneCursorMotionEasing, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum SceneCursorMotionPreset { + Recorded, + Straight, + Arc, + Wave, + Loop, + Overshoot, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)] +#[serde(rename_all = "kebab-case")] +pub enum SceneCursorMotionEasing { + Linear, + EaseInOut, + EaseIn, + EaseOut, +} + +impl From<&SceneCursorMotionRegion> for crate::cursor::CursorMotionRegion { + fn from(r: &SceneCursorMotionRegion) -> Self { + use crate::cursor::{CursorMotionEasing as E, CursorMotionPreset as P}; + crate::cursor::CursorMotionRegion { + start_s: r.start_sec, + end_s: r.end_sec, + start: (r.start_point.cx, r.start_point.cy), + end: (r.end_point.cx, r.end_point.cy), + control: (r.control_point.cx, r.control_point.cy), + preset: match r.preset { + SceneCursorMotionPreset::Recorded => P::Recorded, + SceneCursorMotionPreset::Straight => P::Straight, + SceneCursorMotionPreset::Arc => P::Arc, + SceneCursorMotionPreset::Wave => P::Wave, + SceneCursorMotionPreset::Loop => P::Loop, + SceneCursorMotionPreset::Overshoot => P::Overshoot, + }, + cycles: r.cycles, + speed: r.speed, + easing: match r.easing { + SceneCursorMotionEasing::Linear => E::Linear, + SceneCursorMotionEasing::EaseInOut => E::EaseInOut, + SceneCursorMotionEasing::EaseIn => E::EaseIn, + SceneCursorMotionEasing::EaseOut => E::EaseOut, + }, + } + } } /// Un sprite de curseur : image + point de pivot. @@ -793,4 +879,65 @@ mod annotation_tests { vec!["keep"] ); } + + /// Le contrat JS→Rust, dans les deux sens où il dérive en silence : le camelCase des + /// champs et l'orthographe des variantes. `easing` est en kebab-case (`ease-in-out`) + /// alors que `preset` est en minuscules collées — deux conventions dans le même objet, + /// donc exactement le genre de détail qu'un test doit tenir. + #[test] + fn a_cursor_motion_region_parses_from_the_typescript_shape() { + let json = r#"{ + "show": true, "size": 1.0, "smoothing": 0.5, "motionBlur": 0.0, + "clickBounce": 1.0, "clipToBounds": false, "theme": "system", + "motion": [{ + "id": "r1", + "clipIndex": 2, + "startSec": 1.0, "endSec": 2.5, + "startPoint": { "cx": 0.1, "cy": 0.2 }, + "endPoint": { "cx": 0.8, "cy": 0.4 }, + "controlPoint": { "cx": 0.5, "cy": 0.9 }, + "preset": "overshoot", "cycles": 3, "speed": 2.5, + "easing": "ease-in-out" + }] + }"#; + let cursor: SceneCursor = serde_json::from_str(json).expect("SceneCursor doit parser"); + assert_eq!(cursor.motion.len(), 1); + let r = &cursor.motion[0]; + assert_eq!(r.preset, SceneCursorMotionPreset::Overshoot); + assert_eq!(r.easing, SceneCursorMotionEasing::EaseInOut); + assert_eq!(r.start_sec, 1.0); + assert_eq!(r.control_point.cy, 0.9); + assert_eq!(r.cycles, 3); + assert_eq!(r.clip_index, Some(2), "le propriétaire de la région doit traverser le pont"); + } + + /// Une scène d'avant ce champ doit rester lisible : `motion` absent → aucune région, + /// pas une erreur de parse. C'est le cas de tout projet existant. + #[test] + fn a_cursor_without_motion_still_parses() { + let json = r#"{ + "show": true, "size": 1.0, "smoothing": 0.0, "motionBlur": 0.0, + "clickBounce": 1.0, "clipToBounds": false, "theme": "system" + }"#; + let cursor: SceneCursor = serde_json::from_str(json).expect("scène héritée"); + assert!(cursor.motion.is_empty()); + } + + /// Une région d'avant `clipIndex` reste lisible et sans propriétaire : elle s'applique + /// à toutes les pistes, le comportement historique — pas un échec de parse. + #[test] + fn a_cursor_motion_region_without_clip_index_parses_unowned() { + let json = r#"{ + "show": true, "size": 1.0, "smoothing": 0.0, "motionBlur": 0.0, + "clickBounce": 1.0, "clipToBounds": false, "theme": "system", + "motion": [{ + "id": "legacy", "startSec": 0.0, "endSec": 1.0, + "startPoint": { "cx": 0.0, "cy": 0.0 }, "endPoint": { "cx": 1.0, "cy": 1.0 }, + "controlPoint": { "cx": 0.5, "cy": 0.5 }, + "preset": "straight", "cycles": 1, "speed": 1.0, "easing": "linear" + }] + }"#; + let cursor: SceneCursor = serde_json::from_str(json).expect("région héritée"); + assert_eq!(cursor.motion[0].clip_index, None); + } } diff --git a/crates/compositor/src/timeline_walk.rs b/crates/compositor/src/timeline_walk.rs index 5d95a81db..8edc0a90a 100644 --- a/crates/compositor/src/timeline_walk.rs +++ b/crates/compositor/src/timeline_walk.rs @@ -159,8 +159,14 @@ pub(crate) unsafe fn walk_composited_timeline( ) -> Result { let cursor_enabled = scene.as_ref().map(|s| s.cursor.show).unwrap_or(false); 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; + // Régions éditées gardées dans leur forme de scène : chacune appartient au clip dont le + // temps source la porte (`clipIndex`), donc la conversion vers la géométrie du sampler + // se fait PAR CLIP dans la boucle — une même recording coupée en deux clips ne partage + // pas ses régions, et la piste éditée qui en découle non plus. + let scene_cursor_motion: Vec = + scene.as_ref().map(|s| s.cursor.motion.clone()).unwrap_or_default(); + let mut cursor_tracks: HashMap<(usize, String), CursorTrack> = HashMap::new(); + let mut cursor_active: Option<(usize, String)> = None; let mut frames: u64 = 0; @@ -280,22 +286,38 @@ pub(crate) unsafe fn walk_composited_timeline( } if cursor_enabled { - if !cursor_tracks.contains_key(&clip.screen) { + // La clé du cache est (clip, fichier), pas le fichier seul : deux coupes d'une + // même recording peuvent porter des régions différentes, et leur piste éditée + // respective ne doit pas se confondre. + let cursor_key = (clip_index, clip.screen.clone()); + if !cursor_tracks.contains_key(&cursor_key) { + let cursor_motion: Vec = scene_cursor_motion + .iter() + // Sans propriétaire (`clipIndex` absent, scènes d'avant le champ) la + // région s'applique à toutes les pistes — le comportement historique. + .filter(|r| r.clip_index.map(|i| i == clip_index).unwrap_or(true)) + .map(Into::into) + .collect(); let path = format!("{}.cursor.json", clip.screen); if let Ok(raw) = CursorTrack::load(&path, 0.0, 24.0 * 3600.0) { - cursor_tracks.insert(clip.screen.clone(), raw.smoothed(cursor_smoothing)); + // Trajectoire éditée d'ABORD, lissage ensuite : le preset définit le tracé, + // le lissage est un filtre de rendu qui s'applique à celui qu'on a. Dans + // l'autre ordre les portions éditées resteraient nettes au milieu d'une + // piste amortie, et le slider n'aurait plus d'effet sur elles. + let edited = raw.with_motion(&cursor_motion); + cursor_tracks.insert((clip_index, clip.screen.clone()), edited.smoothed(cursor_smoothing)); } // absente/illisible → pas d'entrée : ce clip s'exporte sans curseur (visible, // pas masqué en un curseur fantôme d'un autre clip). } - if cursor_active_path.as_deref() != Some(clip.screen.as_str()) { - if let Some(track) = cursor_tracks.get(&clip.screen) { + if cursor_active.as_ref() != Some(&cursor_key) { + if let Some(track) = cursor_tracks.get(&cursor_key) { comp.set_cursor(track.clone()); - cursor_active_path = Some(clip.screen.clone()); + cursor_active = Some(cursor_key); } else { comp.clear_cursor(); comp.set_cursor_time(None); - cursor_active_path = None; + cursor_active = None; } } } @@ -318,7 +340,7 @@ pub(crate) unsafe fn walk_composited_timeline( } comp.set_timeline_time(Some(target_source_time as f32)); - if cursor_enabled && cursor_active_path.is_some() { + if cursor_enabled && cursor_active.is_some() { comp.set_cursor_time(Some(target_source_time as f32)); } comp.compose_frame(sf, wf, frames as f32, cfg)?; diff --git a/src/components/ai-edition/CursorMotionPathOverlay.tsx b/src/components/ai-edition/CursorMotionPathOverlay.tsx new file mode 100644 index 000000000..e6e69d8c1 --- /dev/null +++ b/src/components/ai-edition/CursorMotionPathOverlay.tsx @@ -0,0 +1,318 @@ +// The edited cursor path drawn over the preview, with the one handle that shapes +// it. Sibling of `ZoomFocusOverlay` and built the same way — a plain CSS/SVG layer +// over the screen stage, not a Pixi one; #116's version was built on Pixi, which +// `main` dropped when the preview moved to the native compositor. +// +// Two paths are drawn, and the pair is the point: the recorded trajectory says +// where the cursor actually went, the edited one says where it will go. A single +// path would leave "is this better than what I recorded" unanswerable without +// scrubbing back and forth. +// +// Coordinates are normalised (0..1) against the SCREEN RECT, which is what the +// motion model stores and what the compositor samples, so nothing here converts +// between spaces. The viewBox is 0..100 with `preserveAspectRatio="none"`: the +// overlay stretches with the stage, and every stroke carries +// `vector-effect="non-scaling-stroke"` so that stretch never thickens a line. + +import type { KeyboardEvent as ReactKeyboardEvent, PointerEvent as ReactPointerEvent } from "react"; +import { useCallback, useMemo, useRef } from "react"; +import type { AxcutCursorMotionRegion } from "@/lib/ai-edition/schema"; +import { toModelCursorMotionRegion } from "@/lib/ai-edition/timeline/cursorMotionRegions"; +import type { CursorMotionPoint } from "@/lib/cursor/cursorMotion"; +import { sampleCursorMotionRegion } from "@/lib/cursor/cursorMotion"; +import { clamp01 } from "@/utils/math"; + +/** Enough segments that an arc reads as a curve at any stage size, few enough that + * re-sampling on every drag frame stays free. The compositor re-samples the same + * curve at 240 Hz; this is only what the eye needs. */ +const PATH_STEPS = 64; + +interface CursorMotionPathOverlayProps { + region: AxcutCursorMotionRegion; + /** Recorded cursor positions for the owning asset, in SOURCE ms. Only the ones + * inside the region's span are drawn. Absent when the recording carries no + * telemetry, which just means the comparison line is absent. + * + * Nullable, not just optional: `useCursorTelemetry` declares an array but hands + * through whatever the bridge returns, and a bridge with no cursor endpoint — + * the browser-mode shim, a platform without the sampler helper — returns null. + * It reached here as a crash on `.filter`, so the null is handled where it + * arrives rather than assumed away. */ + telemetry?: readonly { timeMs: number; cx: number; cy: number }[] | null; + onControlPointChange: (id: string, point: CursorMotionPoint) => void; + onControlPointCommit?: () => void; +} + +function toPolyline(points: readonly CursorMotionPoint[]): string { + return points.map((p) => `${(p.cx * 100).toFixed(3)},${(p.cy * 100).toFixed(3)}`).join(" "); +} + +function AnchorMarker({ + point, + kind, +}: { + point: CursorMotionPoint; + kind: AxcutCursorMotionRegion["startAnchor"]; +}) { + const x = point.cx * 100; + const y = point.cy * 100; + // A rest is a square, a click is a ringed dot, a manual cut is a plain dot. + // Three shapes rather than three colours: the anchors sit on footage, and a + // colour-only distinction disappears over the wrong frame. + if (kind === "rest") { + return ( + + ); + } + return ( + <> + + {kind === "click" ? : null} + + ); +} + +export function CursorMotionPathOverlay({ + region, + telemetry, + onControlPointChange, + onControlPointCommit, +}: CursorMotionPathOverlayProps) { + const hostRef = useRef(null); + const draggingRef = useRef(false); + + const model = useMemo(() => toModelCursorMotionRegion(region), [region]); + + const editedPath = useMemo(() => { + // A hold has no path — its two anchors are the same point, and drawing a + // zero-length line there would put a stray dot on the frame. + if (region.segmentKind === "hold") return []; + const span = model.sourceEndMs - model.sourceStartMs; + return Array.from({ length: PATH_STEPS + 1 }, (_, i) => + sampleCursorMotionRegion(model, model.sourceStartMs + (span * i) / PATH_STEPS), + ); + }, [model, region.segmentKind]); + + const recordedPath = useMemo( + () => + (telemetry ?? []) + .filter((s) => s.timeMs >= model.sourceStartMs && s.timeMs <= model.sourceEndMs) + .map((s) => ({ cx: s.cx, cy: s.cy })), + [telemetry, model.sourceStartMs, model.sourceEndMs], + ); + + const updateFromClientPoint = useCallback( + (clientX: number, clientY: number) => { + const el = hostRef.current; + if (!el) return; + const rect = el.getBoundingClientRect(); + if (!rect.width || !rect.height) return; + onControlPointChange(region.id, { + cx: clamp01((clientX - rect.left) / rect.width), + cy: clamp01((clientY - rect.top) / rect.height), + }); + }, + [onControlPointChange, region.id], + ); + + const onPointerDown = useCallback( + (e: ReactPointerEvent) => { + e.preventDefault(); + e.stopPropagation(); + draggingRef.current = true; + (e.target as Element).setPointerCapture?.(e.pointerId); + updateFromClientPoint(e.clientX, e.clientY); + }, + [updateFromClientPoint], + ); + + const onPointerMove = useCallback( + (e: ReactPointerEvent) => { + if (!draggingRef.current) return; + updateFromClientPoint(e.clientX, e.clientY); + }, + [updateFromClientPoint], + ); + + const endDrag = useCallback(() => { + if (!draggingRef.current) return; + draggingRef.current = false; + onControlPointCommit?.(); + }, [onControlPointCommit]); + + // The handle is focusable, so it must also be movable from the keyboard: arrows + // nudge both coordinates (Shift = coarse step), each press landing as one committed + // edit — the drag commits once on release, a keypress is its own release. + const onKeyDown = useCallback( + (e: ReactKeyboardEvent) => { + const step = e.shiftKey ? 0.1 : 0.01; + let dx = 0; + let dy = 0; + switch (e.key) { + case "ArrowLeft": + dx = -step; + break; + case "ArrowRight": + dx = step; + break; + case "ArrowUp": + dy = -step; + break; + case "ArrowDown": + dy = step; + break; + default: + return; + } + e.preventDefault(); + onControlPointChange(region.id, { + cx: clamp01(region.controlPoint.cx + dx), + cy: clamp01(region.controlPoint.cy + dy), + }); + onControlPointCommit?.(); + }, + [ + onControlPointChange, + onControlPointCommit, + region.controlPoint.cx, + region.controlPoint.cy, + region.id, + ], + ); + + const controlX = region.controlPoint.cx * 100; + const controlY = region.controlPoint.cy * 100; + const shapeable = region.segmentKind === "move" && region.preset !== "recorded"; + + return ( +
+ + {shapeable ? ( + // The handle is a DOM node, not an SVG circle: the overlay stretches with + // `preserveAspectRatio="none"`, which would squash a circle into an + // ellipse on any non-square stage, and a grab target should not change + // shape with the aspect ratio of the footage. +
+ ) : null} +
+ ); +} diff --git a/src/components/ai-edition/EditorEmptyState.test.tsx b/src/components/ai-edition/EditorEmptyState.test.tsx index acb513e4b..690aa294c 100644 --- a/src/components/ai-edition/EditorEmptyState.test.tsx +++ b/src/components/ai-edition/EditorEmptyState.test.tsx @@ -53,6 +53,7 @@ const sampleDoc = vi.hoisted( }, annotations: [], zoomRanges: [], + cursorMotionRegions: [], legacyEditor: null, }), ); diff --git a/src/components/ai-edition/ExportDialog.showInFolder.test.tsx b/src/components/ai-edition/ExportDialog.showInFolder.test.tsx index 835d9c8fd..7c7c2d506 100644 --- a/src/components/ai-edition/ExportDialog.showInFolder.test.tsx +++ b/src/components/ai-edition/ExportDialog.showInFolder.test.tsx @@ -75,6 +75,7 @@ const DOC: AxcutDocument = { }, annotations: [], zoomRanges: [], + cursorMotionRegions: [], legacyEditor: null, }; diff --git a/src/components/ai-edition/ExportDialog.test.ts b/src/components/ai-edition/ExportDialog.test.ts index 3aa8d85b5..f9335b306 100644 --- a/src/components/ai-edition/ExportDialog.test.ts +++ b/src/components/ai-edition/ExportDialog.test.ts @@ -57,6 +57,7 @@ function doc(assets: AxcutAsset[], clips: AxcutClip[]): AxcutDocument { }, annotations: [], zoomRanges: [], + cursorMotionRegions: [], legacyEditor: null, }; } diff --git a/src/components/ai-edition/NewEditorShell.tsx b/src/components/ai-edition/NewEditorShell.tsx index b371d100c..28bb9033d 100644 --- a/src/components/ai-edition/NewEditorShell.tsx +++ b/src/components/ai-edition/NewEditorShell.tsx @@ -848,9 +848,11 @@ export function NewEditorShell() { // copied is what the user is looking at — the old version dug into the raw // document with a ternary chain that mapped a trim to "zoom" and sent // cameraFullscreen down the speed branch, where neither could ever be found. - const handleCopyRegion = useCallback(async () => { + // Resolves to whether anything actually landed on the clipboard: Ctrl+X must + // not delete a selection whose copy was refused or failed. + const handleCopyRegion = useCallback(async (): Promise => { const sel = tl.selection; - if (!sel) return; + if (!sel) return false; const { copyRegion } = await import("@/lib/ai-edition/store/regionClipboard"); // A trim is stored in SOURCE time against a clip anchor, so there is no @@ -863,11 +865,22 @@ export function NewEditorShell() { const group = coalescedTrimGroups(tl.trimRanges, tl.clips).find((g) => g.ids.includes(sel.id), ); - if (!group) return; + if (!group) return false; copyRegion({ kind: "trim", region: { durationSec: group.end - group.start } }); setCopiedClipId(null); toast.success("Region copied"); - return; + return true; + } + + // A cursor motion region is a path between two RESOLVED points — the cursor's + // actual position at a rest or a recorded click. Pasting it at the playhead + // would draw that path between two places the cursor never was. The inspector's + // "apply to all move sections" is the operation that means what copy would mean + // here, and it copies the styling without the anchors. Cut is refused with it: + // there is no clipboard entry to justify deleting the section. + if (sel.kind === "cursorMotion") { + toast.info("Cursor motion is tied to its anchors — use Apply to all moves instead"); + return false; } const source = @@ -879,11 +892,12 @@ export function NewEditorShell() { ? tl.speedRegions : tl.cameraFullscreenRegions; const region = (source as Array<{ id: string }>).find((r) => r.id === sel.id); - if (!region) return; + if (!region) return false; copyRegion({ kind: sel.kind, region: region as unknown as Record }); // One clipboard wins at a time: a copied pill retires the copied clip. setCopiedClipId(null); toast.success("Region copied"); + return true; }, [tl]); useEffect(() => { @@ -975,11 +989,15 @@ export function NewEditorShell() { } if (ctrl && e.key.toLowerCase() === "x") { // F2.8 — cut: remember the region in the clipboard, then remove it. - // Trims included now that copying one means copying its length. + // Trims included now that copying one means copying its length. The + // removal only happens on a confirmed copy — cursor-motion selections + // refuse theirs, and deleting uncopied would make Ctrl+X a delete. if (tl.selection) { e.preventDefault(); const cut = tl.selection; - void handleCopyRegion().then(() => tl.removeRegion(cut.kind, cut.id)); + void handleCopyRegion().then((copied) => { + if (copied) tl.removeRegion(cut.kind, cut.id); + }); return; } } @@ -1243,6 +1261,12 @@ export function NewEditorShell() { selectedZoomRegionId={tl.selection?.kind === "zoom" ? tl.selection.id : null} onZoomFocusChange={tl.updateZoomFocusLive} onZoomFocusCommit={() => void tl.commitZoomFocus()} + cursorMotionRegions={tl.cursorMotionRegions} + selectedCursorMotionId={ + tl.selection?.kind === "cursorMotion" ? tl.selection.id : null + } + onCursorMotionControlPointChange={tl.updateCursorMotionControlPointLive} + onCursorMotionControlPointCommit={() => void tl.commitCursorMotionControlPoint()} annotationRegions={tl.annotationRegions} selectedAnnotationId={ tl.selection?.kind === "annotation" ? tl.selection.id : null diff --git a/src/components/ai-edition/Preview.tsx b/src/components/ai-edition/Preview.tsx index aaf04f2db..1c4b19398 100644 --- a/src/components/ai-edition/Preview.tsx +++ b/src/components/ai-edition/Preview.tsx @@ -4,6 +4,7 @@ import { useScopedT } from "@/contexts/I18nContext"; import type { AxcutAnnotationRegion, AxcutClip, + AxcutCursorMotionRegion, AxcutTrimRange, AxcutZoomRegion, } from "@/lib/ai-edition/schema"; @@ -29,6 +30,10 @@ interface PreviewProps { selectedZoomRegionId?: string | null; onZoomFocusChange?: (id: string, focus: ZoomFocus) => void; onZoomFocusCommit?: () => void; + cursorMotionRegions?: AxcutCursorMotionRegion[]; + selectedCursorMotionId?: string | null; + onCursorMotionControlPointChange?: (id: string, point: { cx: number; cy: number }) => void; + onCursorMotionControlPointCommit?: () => void; annotationRegions?: AxcutAnnotationRegion[]; selectedAnnotationId?: string | null; onSelectAnnotation?: (id: string) => void; @@ -59,6 +64,10 @@ export function Preview({ trimRanges, selectedZoomRegionId, onZoomFocusChange, + cursorMotionRegions, + selectedCursorMotionId, + onCursorMotionControlPointChange, + onCursorMotionControlPointCommit, onZoomFocusCommit, annotationRegions, selectedAnnotationId, @@ -185,6 +194,10 @@ export function Preview({ trimRanges={trimRanges} selectedZoomRegionId={selectedZoomRegionId} onZoomFocusChange={onZoomFocusChange} + cursorMotionRegions={cursorMotionRegions} + selectedCursorMotionId={selectedCursorMotionId} + onCursorMotionControlPointChange={onCursorMotionControlPointChange} + onCursorMotionControlPointCommit={onCursorMotionControlPointCommit} onZoomFocusCommit={onZoomFocusCommit} annotationRegions={annotationRegions} selectedAnnotationId={selectedAnnotationId} diff --git a/src/components/ai-edition/PreviewCanvas.tsx b/src/components/ai-edition/PreviewCanvas.tsx index 6f59e066f..829b6033f 100644 --- a/src/components/ai-edition/PreviewCanvas.tsx +++ b/src/components/ai-edition/PreviewCanvas.tsx @@ -22,6 +22,7 @@ import type { PointerEvent as ReactPointerEvent } from "react"; import { useEffect, useMemo, useRef, useState } from "react"; +import { fromFileUrl } from "@/components/video-editor/projectPersistence"; import { type CameraFullscreenRegion, type CropRegion, @@ -35,6 +36,7 @@ import { resolveAspectRatioValue } from "@/lib/ai-edition/document/outputFormat" import type { AxcutAnnotationRegion, AxcutClip, + AxcutCursorMotionRegion, AxcutTrimRange, AxcutZoomRegion, } from "@/lib/ai-edition/schema"; @@ -53,8 +55,10 @@ import { import { classifyWallpaper, resolveImageWallpaperUrl } from "@/lib/wallpaper"; import { getCssClipPath } from "@/lib/webcamMaskShapes"; import { computeCameraFullscreenProgress } from "@/lib/zoomMath/cameraFullscreenUtils"; +import { useCursorTelemetry } from "@/native/hooks/useCursorTelemetry"; import { clamp, clamp01 } from "@/utils/math"; import { AnnotationLayer } from "./AnnotationLayer"; +import { CursorMotionPathOverlay } from "./CursorMotionPathOverlay"; import { NativeCompositorOverlay } from "./NativeCompositorOverlay"; import styles from "./NewEditorShell.module.css"; import { type VideoSource, VirtualPreview } from "./VirtualPreview"; @@ -73,6 +77,10 @@ interface PreviewCanvasProps { selectedZoomRegionId?: string | null; onZoomFocusChange?: (id: string, focus: ZoomFocus) => void; onZoomFocusCommit?: () => void; + cursorMotionRegions?: AxcutCursorMotionRegion[]; + selectedCursorMotionId?: string | null; + onCursorMotionControlPointChange?: (id: string, point: { cx: number; cy: number }) => void; + onCursorMotionControlPointCommit?: () => void; annotationRegions?: AxcutAnnotationRegion[]; selectedAnnotationId?: string | null; onSelectAnnotation?: (id: string) => void; @@ -387,6 +395,23 @@ export function PreviewCanvas(props: PreviewCanvasProps) { const isPipGrab = settings.webcamLayoutPreset === "picture-in-picture"; + // Telemetry for the recorded-trace comparison line. Loaded from the SELECTED + // section's own asset, not the primary one: in a multi-clip project those differ, + // and comparing an edited path against another recording's trace would be worse + // than showing no comparison at all. + const selectedCursorMotion = props.selectedCursorMotionId + ? (props.cursorMotionRegions?.find((r) => r.id === props.selectedCursorMotionId) ?? null) + : null; + // The recorded trace is loaded through the same hook the cursor preview layer + // uses, keyed by the SELECTED section's asset. `useCursorTelemetry` takes null + // as "nothing to load" and returns an empty list, so nothing is fetched while no + // section is selected — which is the common case. + const cursorMotionSource = selectedCursorMotion?.assetId + ? (props.videoSources.find((v) => v.id === selectedCursorMotion.assetId) ?? null) + : null; + const { samples: cursorMotionTelemetry } = useCursorTelemetry( + cursorMotionSource ? fromFileUrl(cursorMotionSource.src) : null, + ); const selectedZoomRegion = props.selectedZoomRegionId ? (props.zoomRegions?.find((z) => z.id === props.selectedZoomRegionId) ?? null) : null; @@ -429,6 +454,14 @@ export function PreviewCanvas(props: PreviewCanvasProps) { /> ); })()} + {selectedCursorMotion && props.onCursorMotionControlPointChange ? ( + + ) : null} {selectedZoomRegion && props.onZoomFocusChange ? ( = { + recorded: "M4 18 C12 8 18 21 27 11 S38 4 44 9", + straight: "M4 18 L44 6", + arc: "M4 18 Q24 1 44 18", + wave: "M4 13 C10 2 16 2 22 13 S34 24 44 13", + loop: "M4 16 C14 2 34 2 32 15 C30 25 14 23 18 13 C22 5 36 8 44 16", + overshoot: "M4 18 C24 18 37 5 46 8 C42 8 40 10 44 14", +}; +const CURSOR_MOTION_PREVIEW_END_Y: Record = { + recorded: 9, + straight: 6, + arc: 18, + wave: 13, + loop: 16, + overshoot: 14, +}; +// The speeds worth one click. The slider covers everything between them; these are +// the values an editor actually reaches for, and hunting for "2.0" on a 0.1-step +// slider is a worse way to get there. +const CURSOR_MOTION_SPEED_PRESETS = [1, 1.5, 2, 3, 4] as const; + +function CursorMotionPresetGlyph({ preset }: { preset: CursorMotionPreset }) { + return ( + + ); +} + type AnnotationKind = AxcutAnnotationRegion["type"]; type ArrowDirectionKind = NonNullable["arrowDirection"]; @@ -610,6 +670,291 @@ function SelectionPane({ tl, onClose }: { tl: TimelineApi; onClose: () => void } ); } + if (selection.kind === "cursorMotion") { + const regions = tl.cursorMotionRegions ?? []; + const region = regions.find((r) => r.id === selection.id); + if (!region) return null; + // Sections in timeline order, so "previous / next" walks the recording rather + // than the order they happen to sit in the document. + const ordered = [...regions].sort((a, b) => a.startMs - b.startMs); + const index = ordered.findIndex((r) => r.id === region.id); + const step = (delta: number) => { + const target = ordered[index + delta]; + if (target) tl.selectRegion("cursorMotion", target.id); + }; + const isHold = region.segmentKind === "hold"; + return ( +
+ {paneHeader(, ts("cursorMotion.title"), onClose, tc("actions.close"))} +
+
+ + + {ts("cursorMotion.sectionCounter", { + current: index + 1, + total: ordered.length, + })} + + +
+ + {isHold ? ( + // A hold has no path, so every control below would be inert on it. Say + // what the section IS instead of showing six shapes that cannot apply — + // this is the section that keeps the cursor still, and that is the whole + // reason the rests were split out in the first place. +

+ {ts("cursorMotion.holdDescription")} +

+ ) : ( + <> +
+ + {ts("cursorMotion.preset")} + +
+ {CURSOR_MOTION_PRESETS.map((preset) => { + const active = region.preset === preset; + return ( + + ); + })} +
+
+ + {/* `recorded` is inert: it plays the captured trace, so speed, turns and + timing have nothing to reshape. Hiding them is the honest reading of + "this section is unedited" — greying six controls says the same thing + at four times the visual weight. */} + {region.preset !== "recorded" ? ( + <> +
+ {paneRow( + ts("cursorMotion.speed"), + + {region.speed.toFixed(1)}× + , + )} +
+ {CURSOR_MOTION_SPEED_PRESETS.map((speed) => ( + + ))} +
+ + void tl.updateCursorMotionSettings(region.id, { + speed: Number(e.target.value), + }) + } + style={{ width: "100%", accentColor: "var(--accent)" }} + /> +

+ {ts("cursorMotion.speedHint")} +

+
+ + {region.preset === "wave" || region.preset === "loop" ? ( +
+ {paneRow( + ts("cursorMotion.cycles"), + + {region.cycles} + , + )} + + void tl.updateCursorMotionSettings(region.id, { + cycles: Number(e.target.value), + }) + } + style={{ width: "100%", accentColor: "var(--accent)" }} + /> +
+ ) : null} + + {paneRow( + ts("cursorMotion.easing"), + , + )} + + + + ) : null} + + )} + +

+ {ts("cursorMotion.anchorHint")} +

+ + + + +
+
+ ); + } + if (selection.kind === "speed") { const region = tl.speedRegions.find((s) => s.id === selection.id); if (!region) return null; diff --git a/src/components/ai-edition/v4/V4Timeline.geometry.test.tsx b/src/components/ai-edition/v4/V4Timeline.geometry.test.tsx index 8e515568c..eb20f7e21 100644 --- a/src/components/ai-edition/v4/V4Timeline.geometry.test.tsx +++ b/src/components/ai-edition/v4/V4Timeline.geometry.test.tsx @@ -80,6 +80,7 @@ function renderTimeline( speedRegions: [], cameraFullscreenRegions: [], zoomRegions: [], + cursorMotionRegions: [], trimRanges: [], selection: null, multiSelection: [], diff --git a/src/components/ai-edition/v4/V4Timeline.tsx b/src/components/ai-edition/v4/V4Timeline.tsx index f6c94e2ca..35942b739 100644 --- a/src/components/ai-edition/v4/V4Timeline.tsx +++ b/src/components/ai-edition/v4/V4Timeline.tsx @@ -4,9 +4,11 @@ import { Loader2, Maximize2, MessageSquare, + Pause, Pencil, Scissors, Sparkles, + Spline, SplitSquareHorizontal, Trash2, Wand2, @@ -37,13 +39,17 @@ import { useChatPromptBus } from "@/lib/ai-edition/store/useChatPromptBus"; import { useEditorSettings } from "@/lib/ai-edition/store/useEditorSettings"; import type { useTimeline } from "@/lib/ai-edition/store/useTimeline"; import { hasAnyClipWithCamera } from "@/lib/ai-edition/timeline/camera"; +import { toCursorMotionSamples } from "@/lib/ai-edition/timeline/cursorMotionRegions"; import { formatSec } from "@/lib/ai-edition/timeline/format"; import { newRegionDurationSec, setTimelineScale, } from "@/lib/ai-edition/timeline/newRegionDuration"; import { ventilateSpanAcrossClips } from "@/lib/ai-edition/timeline/region-ventilation"; -import { coalesceRegionsForRuler } from "@/lib/ai-edition/timeline/timelineMap"; +import { + coalesceRegionsForRuler, + resolveNativePosition, +} from "@/lib/ai-edition/timeline/timelineMap"; import { coalescedTrimGroups, resolveTimelineSpanToTrim, @@ -54,6 +60,7 @@ import { buildAutoZoomSuggestionsForClips, } from "@/lib/ai-edition/timeline/zoom-suggestions"; import { nativeBridgeClient } from "@/native/client"; +import { resolveVisibleClips } from "@/native/sceneDescription"; import { TransportBar } from "../TransportBar"; import type { VideoSource } from "../VirtualPreview"; import styles from "./EditorShellV4.module.css"; @@ -337,12 +344,15 @@ const ClipWaveform = memo(function ClipWaveform({ interface LanePill { id: string; - kind: "annotation" | "speed" | "trim" | "zoom" | "cameraFullscreen"; + kind: "annotation" | "speed" | "trim" | "zoom" | "cameraFullscreen" | "cursorMotion"; start: number; end: number; label: string; /** Underlying row ids this pill represents — >1 for a coalesced trim group. */ sourceIds: string[]; + /** A cursor-motion HOLD: the pointer stays where it stopped. Drawn quieter than + * a move because there is no path in it to direct — see `laneCursorHold`. */ + quiet?: boolean; } export function V4Timeline({ @@ -509,6 +519,30 @@ export function V4Timeline({ label: `${(p.member.customScale ?? ZOOM_DEPTH_SCALES[p.member.depth]).toFixed(2)}×`, sourceIds: p.ids, })); + // Cursor motion is the one lane that does NOT coalesce. Everywhere else, two + // touching regions with equal properties are the same effect and merging them + // is what makes a ventilated region read as one pill. Here they are two + // SECTIONS, and being individually selectable is the whole feature: an editor + // tunes one move, walks to the next, tunes that one. Merging the two moves + // that happen to share a preset would silently take that away — and it is the + // state a fresh auto-split is always in, since every section starts + // `recorded` at 1x. + const cursorMotionPills: LanePill[] = (tl.cursorMotionRegions ?? []).map((region) => ({ + id: region.id, + kind: "cursorMotion" as const, + start: region.startMs / 1000, + end: region.endMs / 1000, + // The speed rides on the label because it is the property with no other + // visible tell: a preset shows itself in the preview path, a 3x does not. + label: + region.segmentKind === "hold" + ? t("cursorMotion.segmentKinds.hold") + : `${t(`cursorMotion.presets.${region.preset}`)}${ + region.speed === 1 ? "" : ` ${region.speed.toFixed(1)}×` + }`, + sourceIds: [region.id], + quiet: region.segmentKind === "hold", + })); // trims: content-free (no per-instance text/settings), so touching rows — // inevitable once a trim is ventilated across a clip boundary — are // coalesced into one pill. This is what makes growing a trim across a @@ -665,6 +699,12 @@ export function V4Timeline({ e.preventDefault(); e.stopPropagation(); tl.selectRegion(pill.kind, pill.id, { additive: e.shiftKey }); + // A cursor motion section selects but does not drag. Its two ends are not + // a span someone chose — they are a rest, a click, or a cut, each pinned to + // a POINT the recording puts the pointer at. Sliding the section would keep + // the anchors and move the times, so the path would start where the cursor + // no longer is. Re-splitting is how you change these boundaries. + if (pill.kind === "cursorMotion") return; // Scale drag deltas against the canvas (full zoomed timeline) width, so a // drag tracks the cursor exactly regardless of padding, scrollbar or zoom. const el = canvasRef.current; @@ -892,25 +932,37 @@ export function V4Timeline({ transform: `translateX(${(-nav.start * 100).toFixed(3)}%)`, } as const; - const laneOf = (kind: LanePill["kind"]) => - kind === "annotation" + const laneOf = (pill: LanePill) => + pill.kind === "annotation" ? styles.laneAnnotation - : kind === "speed" + : pill.kind === "speed" ? styles.laneSpeed - : kind === "trim" + : pill.kind === "trim" ? styles.laneTrim - : kind === "cameraFullscreen" + : pill.kind === "cameraFullscreen" ? styles.laneCameraFullscreen - : styles.laneZoom; - const pillIcon = (kind: LanePill["kind"]) => - kind === "annotation" ? ( + : pill.kind === "cursorMotion" + ? pill.quiet + ? styles.laneCursorHold + : styles.laneCursorMotion + : styles.laneZoom; + const pillIcon = (pill: LanePill) => + pill.kind === "annotation" ? ( - ) : kind === "speed" ? ( + ) : pill.kind === "speed" ? ( - ) : kind === "trim" ? ( + ) : pill.kind === "trim" ? ( - ) : kind === "cameraFullscreen" ? ( + ) : pill.kind === "cameraFullscreen" ? ( + ) : pill.kind === "cursorMotion" ? ( + // A hold is the absence of movement, so it gets the pause glyph rather + // than a dimmer copy of the path one — the lane already dims it. + pill.quiet ? ( + + ) : ( + + ) ) : ( ); @@ -1081,6 +1133,56 @@ export function V4Timeline({ } }, [videoSources, clips, tl, t]); + // Cursor motion — build the sections from the playhead to the next recorded + // click. Unlike auto-zoom above, this reads the RECORDING data rather than the + // position telemetry: the split points are clicks, and only the native cursor + // sampler records those. A capture with position-only telemetry (the fallback + // adapter, or a platform without the helper) therefore has nothing to split on, + // which is one of the three ways this can legitimately find nothing. + const [cursorMotionBusy, setCursorMotionBusy] = useState(false); + const runCursorMotion = useCallback(async () => { + const state = useProjectStore.getState(); + const doc = state.document; + // Which recording's telemetry to read is decided by the clip UNDER THE + // PLAYHEAD, not by the primary asset: in a multi-clip project those are + // different recordings, and reading the wrong one produces sections whose + // anchors sit where that other capture's cursor was. + const position = doc + ? resolveNativePosition(state.currentTimeSec ?? 0, resolveVisibleClips(doc), clips) + : null; + const source = position + ? videoSources.find((s) => s.id === position.clip.assetId) + : videoSources[0]; + if (!source) { + toast.error(t("toolbar.importRecordingFirst")); + return; + } + setCursorMotionBusy(true); + try { + const data = await nativeBridgeClient.cursor.getRecordingData(fromFileUrl(source.src)); + const samples = data?.samples ?? []; + if (samples.length === 0) { + toast.info(t("cursorMotion.noCursorData")); + return; + } + const added = await tl.addCursorMotion(toCursorMotionSamples(samples)); + // Nothing added has exactly one cause worth naming: there is no click left + // to reach. Saying "no cursor data" there would send the user looking for a + // recording problem they do not have. + if (added === 0) { + toast.info(t("cursorMotion.noFollowingClick")); + return; + } + toast.success(t("cursorMotion.createdSegments", { count: added })); + } catch (err) { + toast.error(t("cursorMotion.failed"), { + description: err instanceof Error ? err.message : String(err), + }); + } finally { + setCursorMotionBusy(false); + } + }, [clips, videoSources, tl, t]); + // Auto-enhance option 2 — hand a generic prompt to the AI agent (smart // zooms + cuts) via the chat prompt-bus. The chat panel owns the outcome // toast: submitting is not the same as being accepted (no usable provider @@ -1133,12 +1235,16 @@ export function V4Timeline({ // The box is exactly as long as the effect is; only what fits INSIDE it // varies with the zoom. const { compact, roomForLabel } = pillAffordance(durSec, pxPerSec); + // No edge handles on a cursor motion section: its boundaries are anchors the + // recording placed, not a span to stretch (see `startPillDrag`). Showing grab + // strips that refuse to grab is worse than showing none. + const resizable = p.kind !== "cursorMotion"; return (
startPillDrag(e, p, "move") : undefined} title={p.label} > - {seg.interactive ? ( + {seg.interactive && resizable ? ( - {pillIcon(p.kind)} + {pillIcon(p)} {p.label} ) : null} - {seg.interactive ? ( + {seg.interactive && resizable ? ( +