diff --git a/crates/compositor/src/cursor.rs b/crates/compositor/src/cursor.rs index 6624d0735..667c5d4f3 100644 --- a/crates/compositor/src/cursor.rs +++ b/crates/compositor/src/cursor.rs @@ -24,7 +24,8 @@ pub struct CursorTrack { clicks: Vec, /// CHANGEMENTS d'état du curseur : (instant, `"arrow"` / `"text"` / `"pointer"` / …), triés. /// Une fonction en escalier, pas une valeur par échantillon : l'état tient sur des secondes - /// entières alors que la position est échantillonnée à ~120 Hz, donc n'enregistrer que les + /// entières alors que la position est échantillonnée toutes les 33 ms (~30 Hz, cf. + /// `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)>, } @@ -162,14 +163,16 @@ impl CursorTrack { sample_at(&self.samples, t) } - /// Facteur d'échelle « click bounce » — parité `getNativeCursorClickBounceScale` (TS, - /// `nativeCursor.ts`) : le curseur PRESSE (rétrécit, 0..38% de la fenêtre d'animation) - /// PUIS REBONDIT (grossit, 38..100%), pas un simple pop qui ne fait que grossir puis - /// redécroître. Seul le clic le plus récent précédant `t` compte (au-delà de la fenêtre, - /// un clic antérieur n'a plus aucun effet — contrairement à l'ancienne décroissance - /// exponentielle à queue infinie qui masquait ce bug). + /// Facteur d'échelle « click bounce ». Cette fonction est la SEULE référence de la courbe : + /// son ancien jumeau TS (`nativeCursor.ts`) a été supprimé avec pixi.js, et + /// `scripts/inspect-native-cursor-click-bounce.mjs` ne fait que la recopier. Le curseur + /// PRESSE (rétrécit jusqu'à 0.76, 0..38% de la fenêtre d'animation) PUIS REBONDIT (grossit + /// jusqu'à 1.16, 38..100%), pas un simple pop qui ne fait que grossir puis redécroître. Seul le clic le + /// plus récent précédant `t` compte (au-delà de la fenêtre, un clic antérieur n'a plus + /// aucun effet — contrairement à l'ancienne décroissance exponentielle à queue infinie qui + /// masquait ce bug). L'amplitude utilisateur (clickBounce) s'applique dans `plan_cursor`. pub fn bounce(&self, t: f32) -> f32 { - const ANIM_S: f32 = 0.26; // NATIVE_CURSOR_CLICK_ANIMATION_MS (TS) = 260ms + const ANIM_S: f32 = 0.26; // 260 ms const PRESS_FRAC: f32 = 0.38; let mut last_tc: Option = None; for &tc in &self.clicks { @@ -419,6 +422,44 @@ mod tests { ); } + /// Enveloppe du click bounce : neutre hors fenêtre, continue aux jonctions, creux à 0.76 et + /// pic à 1.16 aux bons instants, et un nouveau clic redémarre la courbe. + #[test] + fn click_bounce_envelope() { + let near = |a: f32, b: f32, tol: f32| (a - b).abs() < tol; + let track = CursorTrack::new(vec![], vec![1.0], vec![]); + let b = |t: f32| track.bounce(t); + + // Exactement neutre avant le premier clic et après la fenêtre de 260 ms. + assert_eq!(b(0.0), 1.0); + assert_eq!(b(0.999), 1.0); + assert_eq!(b(1.2601), 1.0); + assert_eq!(b(5.0), 1.0); + + // Continuité aux jonctions (fraction 0, 0.38 et 1) : ±0.1 ms autour reste à ~1. + const DT: f32 = 1e-4; + 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)); + + // 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)); + assert!(near(b(1.1794), 1.16, 1e-4), "pic : {}", b(1.1794)); + assert!(b(1.03) > 0.76 && b(1.07) > 0.76, "le creux est un minimum"); + assert!(b(1.15) < 1.16 && b(1.21) < 1.16, "le pic est un maximum"); + + // Seul le clic le plus récent compte : le second redémarre la courbe. À 1.1 s le premier + // 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"); + } + #[test] fn auto_hide_disabled_always_full_opacity() { let track = CursorTrack::new( diff --git a/crates/compositor/src/frame_geometry.rs b/crates/compositor/src/frame_geometry.rs index f26d5776e..5c4b059f9 100644 --- a/crates/compositor/src/frame_geometry.rs +++ b/crates/compositor/src/frame_geometry.rs @@ -1227,8 +1227,9 @@ pub struct CursorPlanInput<'a> { pub t: f32, } -/// `None` = rien à dessiner cette frame : curseur masqué, ou pointeur hors du rect source -/// courant (zoom serré, hors écran) — un état normal en lecture, pas une erreur. +/// `None` = rien à dessiner cette frame : curseur masqué, pointeur hors du rect source +/// courant (zoom serré, hors écran), ou sprite réduit à rien au creux d'un click bounce +/// extrême — un état normal en lecture, pas une erreur. pub fn plan_cursor(g: &FrameGeometry, input: &CursorPlanInput) -> Option { let (rw, rh) = (input.render_px[0], input.render_px[1]); let show = input.scene.map(|s| s.cursor.show).unwrap_or(input.cfg.cursor); @@ -1296,9 +1297,19 @@ pub fn plan_cursor(g: &FrameGeometry, input: &CursorPlanInput) -> Option 0.0) { + return None; + } let blur01 = lp.cursor_motion_blur.clamp(0.0, 1.0); let has_scene = input.scene.is_some(); @@ -2058,21 +2069,9 @@ mod tests { } } - #[test] - fn plan_cursor_motion_blur_adaptive_and_stationary() { - let cfg = crate::config::all().pop().expect("cfg"); - let track_immobile = crate::cursor::CursorTrack::new( - vec![(0.0, 0.5, 0.5), (2.0, 0.5, 0.5)], - vec![], - vec![], - ); - let track_moving = crate::cursor::CursorTrack::new( - vec![(0.0, 0.1, 0.1), (1.0, 0.9, 0.9)], - vec![], - vec![], - ); - let scene = zoomed_golden_scene(); - let fg = FrameGeometry { + /// Écran droit plein cadre, sans zoom : le curseur y tombe toujours dans le rect source. + fn full_frame_geometry() -> FrameGeometry { + FrameGeometry { scene_preset: None, mb_taps: 1.0, mb_amount: 0.0, @@ -2090,7 +2089,24 @@ mod tests { w_px: [0.0, 0.0], w_radius: 0.0, shape_fade: 0.0, - }; + } + } + + #[test] + fn plan_cursor_motion_blur_adaptive_and_stationary() { + let cfg = crate::config::all().pop().expect("cfg"); + let track_immobile = crate::cursor::CursorTrack::new( + vec![(0.0, 0.5, 0.5), (2.0, 0.5, 0.5)], + vec![], + vec![], + ); + let track_moving = crate::cursor::CursorTrack::new( + vec![(0.0, 0.1, 0.1), (1.0, 0.9, 0.9)], + vec![], + vec![], + ); + let scene = zoomed_golden_scene(); + let fg = full_frame_geometry(); // 1. Curseur immobile avec blur actif -> taps = 1 let live_with_blur = LiveParams { @@ -2142,5 +2158,45 @@ mod tests { let plan = plan_cursor(&fg, &input_moving).expect("plan cursor"); assert!(plan.taps >= 2 && plan.taps <= 16, "taps adaptatifs dans [2, 16], got {}", plan.taps); } + + /// clickBounce au maximum du slider (5) : le creux de la pression donnerait 1 - 0.24 * 5 < 0. + /// La taille ne doit jamais devenir négative ; au creux, rien n'est dessiné. + #[test] + fn plan_cursor_never_yields_a_negative_size_at_max_bounce() { + let cfg = crate::config::all().pop().expect("cfg"); + let scene = zoomed_golden_scene(); + let fg = full_frame_geometry(); + let track = crate::cursor::CursorTrack::new( + vec![(0.0, 0.5, 0.5), (2.0, 0.5, 0.5)], + vec![0.5], + vec![], + ); + let plan_at = |t: f32| { + plan_cursor( + &fg, + &CursorPlanInput { + render_px: [1920.0, 1080.0], + u_max: 1.0, + v_max: 1.0, + cfg: &cfg, + live: LiveParams { cursor_bounce_scale: 5.0, ..LiveParams::default() }, + scene: Some(&scene), + track: &track, + t, + }, + ) + }; + + for ms in 450..=800 { + let t = ms as f32 / 1000.0; + if let Some(plan) = plan_at(t) { + assert!(plan.size_px > 0.0, "taille {} à t = {t}", plan.size_px); + } + } + let rest = plan_at(0.45).expect("hors fenêtre de clic").size_px; + assert!(plan_at(0.5 + 0.0494).is_none(), "au creux, le sprite est réduit à rien"); + let peak = plan_at(0.5 + 0.1794).expect("au pic").size_px; + assert!((peak / rest - 1.8).abs() < 1e-3, "pic = 1 + 0.16 * 5, got {}", peak / rest); + } } diff --git a/scripts/generate-default-cursor-sprites.mjs b/scripts/generate-default-cursor-sprites.mjs index 94db9237c..d98b2180e 100644 --- a/scripts/generate-default-cursor-sprites.mjs +++ b/scripts/generate-default-cursor-sprites.mjs @@ -29,9 +29,9 @@ const OUT_SIZE = 128; /** * cursorType -> { file, hotspotX, hotspotY } in the 32-logical reference. - * Hotspots are copied from PRETTY_NATIVE_CURSOR_ASSETS in src/lib/cursor/nativeCursor.ts, - * which is the live web renderer's table and stays the source of truth; this generator - * only re-expresses them for the native path. + * Hotspots were copied from the web renderer's table (src/lib/cursor/nativeCursor.ts), which + * was deleted with pixi.js. This table is now their source of truth: the table this script + * prints for DEFAULT_CURSOR_SPRITES (src/lib/cursor/cursorThemes.ts) comes from it. */ const SPRITES = { arrow: { file: "Cursor=Default.svg", hotspotX: 16.25, hotspotY: 15.03 }, diff --git a/scripts/inspect-native-cursor-click-bounce.mjs b/scripts/inspect-native-cursor-click-bounce.mjs index 870ee8d43..4c3d092c5 100644 --- a/scripts/inspect-native-cursor-click-bounce.mjs +++ b/scripts/inspect-native-cursor-click-bounce.mjs @@ -3,10 +3,12 @@ import fs from "node:fs"; import path from "node:path"; const CLICK_ANIMATION_MS = 260; +// DEFAULT_CURSOR_CLICK_BOUNCE (src/components/video-editor/types.ts). +const DEFAULT_CLICK_BOUNCE = 2.5; function usage() { console.error( - "Usage: node scripts/inspect-native-cursor-click-bounce.mjs [--bounce=5]", + "Usage: node scripts/inspect-native-cursor-click-bounce.mjs [--bounce=2.5]", ); process.exit(1); } @@ -25,8 +27,8 @@ function getCursorJsonPath(inputPath) { function getBounceValue() { const arg = process.argv.find((value) => value.startsWith("--bounce=")); - const parsed = Number(arg?.slice("--bounce=".length) ?? 5); - return Number.isFinite(parsed) ? Math.min(5, Math.max(0, parsed)) : 5; + const parsed = Number(arg?.slice("--bounce=".length) ?? DEFAULT_CLICK_BOUNCE); + return Number.isFinite(parsed) ? Math.min(5, Math.max(0, parsed)) : DEFAULT_CLICK_BOUNCE; } function clickBounceProgress(samples, timeMs) { @@ -49,20 +51,21 @@ function clickBounceProgress(samples, timeMs) { return 0; } +// Copy of the compositor's curve, the only authority: CursorTrack::bounce +// (crates/compositor/src/cursor.rs) gives the envelope, plan_cursor +// (crates/compositor/src/frame_geometry.rs) scales its deviation by the raw +// clickBounce and floors the result at 0 (the cursor is not drawn there). function clickBounceScale(clickBounce, progress) { if (progress <= 0 || clickBounce <= 0) { return 1; } - const intensity = Math.min(5, Math.max(0, clickBounce)) / 5; const elapsed = 1 - Math.min(1, Math.max(0, progress)); - if (elapsed < 0.38) { - const pressProgress = Math.sin((elapsed / 0.38) * Math.PI); - return 1 - pressProgress * intensity * 0.24; - } - - const reboundProgress = Math.sin(((elapsed - 0.38) / 0.62) * Math.PI); - return 1 + reboundProgress * intensity * 0.16; + const envelope = + elapsed < 0.38 + ? 1 - Math.sin((elapsed / 0.38) * Math.PI) * 0.24 + : 1 + Math.sin(((elapsed - 0.38) / 0.62) * Math.PI) * 0.16; + return Math.max(0, 1 + (envelope - 1) * clickBounce); } const cursorJsonPath = getCursorJsonPath(process.argv[2]); diff --git a/technical-documentation/architecture/cursor.md b/technical-documentation/architecture/cursor.md index dda597905..71d0e3bce 100644 --- a/technical-documentation/architecture/cursor.md +++ b/technical-documentation/architecture/cursor.md @@ -31,7 +31,7 @@ Cursor telemetry also drives camera focus for auto-follow zooms. `src/lib/zoomMa ## Bundled assets -The themed cursor packs are stored under `public/cursors//` and contain arrow and pointer PNGs registered in `src/lib/cursor/cursorThemes.ts`. The built-in native replacement SVG set is under `src/assets/cursors/` and is selected by `src/lib/cursor/nativeCursor.ts`, which maps captured cursor types to render assets and hotspots. +The themed cursor packs are stored under `public/cursors//` and contain arrow and pointer PNGs registered in `src/lib/cursor/cursorThemes.ts`. The built-in native replacement SVG set is under `src/assets/cursors/`; `DEFAULT_CURSOR_SPRITES` in `src/lib/cursor/cursorThemes.ts` maps each captured cursor type to its rasterized asset and hotspot. The same built-in art also exists as PNGs under `public/cursors/default/`, generated from those SVGs by `scripts/generate-default-cursor-sprites.mjs`. The native compositor decodes png/jpeg from a real path and cannot read the SVGs, which exist only as bundler URLs in the renderer — so without the PNG set it had no default art and drew a placeholder dot-and-ring instead of a pointer. Regenerate rather than hand-editing: the script also emits the `DEFAULT_CURSOR_SPRITES` hotspot table, which would otherwise drift from the images.