Skip to content
Closed
369 changes: 369 additions & 0 deletions crates/compositor/src/cursor.rs

Large diffs are not rendered by default.

43 changes: 35 additions & 8 deletions crates/compositor/src/live.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1348,6 +1348,9 @@ unsafe fn render_thread(
let mut last_preview_size: (u32, u32) = (0, 0);
let mut last_ip: Option<InspectorParams> = 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<crate::cursor::CursorMotionRegion> = 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.
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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<crate::cursor::CursorMotionRegion> = 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
Expand Down
147 changes: 147 additions & 0 deletions crates/compositor/src/scene.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<String, SceneCursorSprite>,
/// 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<SceneCursorMotionRegion>,
}

/// 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<usize>,
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,
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

#[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.
Expand Down Expand Up @@ -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);
}
}
40 changes: 31 additions & 9 deletions crates/compositor/src/timeline_walk.rs
Original file line number Diff line number Diff line change
Expand Up @@ -159,8 +159,14 @@ pub(crate) unsafe fn walk_composited_timeline(
) -> Result<u64> {
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<String, CursorTrack> = HashMap::new();
let mut cursor_active_path: Option<String> = 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<crate::scene::SceneCursorMotionRegion> =
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;

Expand Down Expand Up @@ -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<crate::cursor::CursorMotionRegion> = 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;
}
}
}
Expand All @@ -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)?;
Expand Down
Loading
Loading