From 282dd12bbee097b6af2076e903209a8fffd917c0 Mon Sep 17 00:00:00 2001 From: EtienneLescot Date: Wed, 16 Sep 2026 10:08:24 +0200 Subject: [PATCH 1/3] fix(compositor): clamp the click-bounce cursor size at zero plan_cursor scales the bounce deviation by the raw clickBounce setting, whose slider goes up to 5. The press trough of CursorTrack::bounce is 0.76, so above 1/0.24 (about 4.17) the factor went negative and a negative size_px reached every backend's sprite draw, drawing the cursor rotated by 180 degrees for a few frames after each click. Floor the factor at 0 in plan_cursor, the one site all three backends share, and return None when the size is not positive. Skipping is safer than drawing a zero-size sprite: on a tilted plane its four corners coincide and the mode 13 inverse warp solves 0/0, whose rejection only holds if NaN comparisons behave, which Metal's fast-math does not promise. The motion-blur trail reuses the same size_px, so it is covered too. The new test sweeps a click at clickBounce 5 and fails without the clamp (size -0.0138 px at t = 0.531 s). --- crates/compositor/src/frame_geometry.rs | 94 ++++++++++++++++++++----- 1 file changed, 75 insertions(+), 19 deletions(-) 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); + } } From 7b9304fcf042de07d64a9f3917e8dd537167a774 Mon Sep 17 00:00:00 2001 From: EtienneLescot Date: Wed, 16 Sep 2026 10:08:46 +0200 Subject: [PATCH 2/3] test(cursor): pin the click-bounce envelope, align the inspect script CursorTrack::bounce had no test. The new one pins exactly 1.0 outside the 260 ms window, continuity at elapsed fractions 0, 0.38 and 1, the 0.76 trough at +49.4 ms, the 1.16 peak at +179.4 ms, and that a later click restarts the curve. Stale references cleaned up on the way: - the bounce doc comment cited getNativeCursorClickBounceScale in nativeCursor.ts, deleted with pixi.js; Rust is now the only authority and the comment says so; - scripts/inspect-native-cursor-click-bounce.mjs divided clickBounce by 5, so its report was off by up to 5x from what the compositor renders. It now scales the deviation by the raw value and floors at 0 like plan_cursor, and defaults to the app default (2.5); - the types comment claimed ~120 Hz cursor sampling; every recorder samples every 33 ms (CURSOR_SAMPLE_INTERVAL_MS) and no faster path exists. --- crates/compositor/src/cursor.rs | 57 ++++++++++++++++--- .../inspect-native-cursor-click-bounce.mjs | 25 ++++---- 2 files changed, 63 insertions(+), 19 deletions(-) diff --git a/crates/compositor/src/cursor.rs b/crates/compositor/src/cursor.rs index 6624d0735..2e508b543 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/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]); From 38d742ca3b650efe0c6997c38fec4149c15fe29f Mon Sep 17 00:00:00 2001 From: EtienneLescot Date: Wed, 16 Sep 2026 10:43:49 +0200 Subject: [PATCH 3/3] docs(cursor): drop the last references to the deleted nativeCursor.ts --- crates/compositor/src/cursor.rs | 6 +++--- scripts/generate-default-cursor-sprites.mjs | 6 +++--- technical-documentation/architecture/cursor.md | 2 +- 3 files changed, 7 insertions(+), 7 deletions(-) diff --git a/crates/compositor/src/cursor.rs b/crates/compositor/src/cursor.rs index 2e508b543..667c5d4f3 100644 --- a/crates/compositor/src/cursor.rs +++ b/crates/compositor/src/cursor.rs @@ -165,9 +165,9 @@ impl CursorTrack { /// 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 + /// `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`. 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/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.