Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
57 changes: 49 additions & 8 deletions crates/compositor/src/cursor.rs
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,8 @@ pub struct CursorTrack {
clicks: Vec<f32>,
/// 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)>,
}
Expand Down Expand Up @@ -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<f32> = None;
for &tc in &self.clicks {
Expand Down Expand Up @@ -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(
Expand Down
94 changes: 75 additions & 19 deletions crates/compositor/src/frame_geometry.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<CursorPlan> {
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);
Expand Down Expand Up @@ -1296,9 +1297,19 @@ pub fn plan_cursor(g: &FrameGeometry, input: &CursorPlanInput) -> Option<CursorP
let placement = place(input.track.at(input.t), g.s_dst)?;

let lp = input.live;
let bounce = 1.0 + (input.track.bounce(input.t) - 1.0) * lp.cursor_bounce_scale;
// `cursor_bounce_scale` est le clickBounce brut (0..5) : au-delà de 1/0.24 ≈ 4.17, le creux
// de la pression (0.76) passe sous zéro. Une taille négative retournerait le sprite, donc
// plancher à 0 — le curseur disparaît le temps du creux, c'est ce qu'une telle amplitude dit.
let bounce = (1.0 + (input.track.bounce(input.t) - 1.0) * lp.cursor_bounce_scale).max(0.0);
let size_px =
CURSOR_BASE_SIZE_FRAC * g.frame_min_px * lp.cursor_size_scale * bounce * g.padding_scale;
// Taille nulle = rien à dessiner, et surtout rien à projeter : sur un plan incliné les quatre
// coins du sprite se confondent, et le warp inverse du mode 13 résout alors 0/0. Son rejet
// ne tiendrait qu'à des comparaisons avec NaN, que Metal (fast-math) ne garantit pas.
// Couvre aussi la traînée : ses copies partagent `size_px`.
if !(size_px > 0.0) {
return None;
}

let blur01 = lp.cursor_motion_blur.clamp(0.0, 1.0);
let has_scene = input.scene.is_some();
Expand Down Expand Up @@ -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,
Expand All @@ -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 {
Expand Down Expand Up @@ -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);
}
}

6 changes: 3 additions & 3 deletions scripts/generate-default-cursor-sprites.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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 },
Expand Down
25 changes: 14 additions & 11 deletions scripts/inspect-native-cursor-click-bounce.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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 <video-or-cursor-json-path> [--bounce=5]",
"Usage: node scripts/inspect-native-cursor-click-bounce.mjs <video-or-cursor-json-path> [--bounce=2.5]",
);
process.exit(1);
}
Expand All @@ -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) {
Expand All @@ -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]);
Expand Down
2 changes: 1 addition & 1 deletion technical-documentation/architecture/cursor.md
Original file line number Diff line number Diff line change
Expand Up @@ -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/<id>/` 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/<id>/` 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.

Expand Down
Loading