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
10 changes: 5 additions & 5 deletions crates/compositor/src/compositor_linux.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1976,7 +1976,7 @@ impl Compositor {
color: [1.0, 1.0, 1.0, 1.0],
src_prev: g.cut,
dst_prev: g.s_dst_prev,
mb: [g.mb_taps, 1.0, 1.0, 0.0],
mb: [g.mb_taps, g.mb_amount, 1.0, 0.0],
..Default::default()
},
Some(quad) => self.tilted_screen_cb(quad, s_px, quad_center_px, g.cut, g.s_radius),
Expand Down Expand Up @@ -2146,7 +2146,7 @@ impl Compositor {
fx: [w_valid[0], w_valid[1], effect_code, blur_intensity],
src_prev: [u0, cv0, u1, cv1],
dst_prev: g.w_dst_prev,
mb: [g.mb_taps, 1.0, 1.0, 0.0],
mb: [g.mb_taps, g.mb_amount, 1.0, 0.0],
..Default::default()
};
// Le masque est lie par `make_bind` sur tous les draws, pas seulement
Expand Down Expand Up @@ -2856,9 +2856,9 @@ impl Compositor {
occlusion_query_set: None,
});
rpass.set_pipeline(&self.pipeline_add);
let w = 1.0 / c.binds.len() as f64;
rpass.set_blend_constant(wgpu::Color { r: w, g: w, b: w, a: w });
for bind in &c.binds {
for (k, bind) in c.binds.iter().enumerate() {
let w = crate::frame_geometry::cursor_tap_weight(k as u32, c.binds.len() as u32) as f64;
rpass.set_blend_constant(wgpu::Color { r: w, g: w, b: w, a: w });
rpass.set_bind_group(0, bind, &[]);
rpass.draw(0..4, 0..1);
}
Expand Down
8 changes: 4 additions & 4 deletions crates/compositor/src/compositor_macos.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2102,7 +2102,7 @@ impl Compositor {
color: [0.0, 0.0, 0.0, 1.0],
src_prev: [su0, sv0, su1, sv1],
dst_prev: g.s_dst_prev,
mb: [g.mb_taps, 1.0, 1.0, 0.0],
mb: [g.mb_taps, g.mb_amount, 1.0, 0.0],
..Default::default()
},
&sy,
Expand Down Expand Up @@ -2151,10 +2151,10 @@ impl Compositor {
Some(metal::MTLClearColor::new(0.0, 0.0, 0.0, 0.0)),
&self.pipeline_add,
)?;
let w = 1.0 / plan.taps as f32;
e.set_blend_color(w, w, w, w);
for k in 0..plan.taps {
let f = k as f32 / (plan.taps - 1) as f32;
let w = crate::frame_geometry::cursor_tap_weight(k, plan.taps);
e.set_blend_color(w, w, w, w);
self.draw_cur_themed(
e,
&sprites,
Expand Down Expand Up @@ -2261,7 +2261,7 @@ impl Compositor {
fx: [w_valid[0], w_valid[1], effect_code, blur_intensity],
src_prev: [u0, cv0, u1, cv1],
dst_prev: g.w_dst_prev,
mb: [g.mb_taps, 1.0, 1.0, 0.0],
mb: [g.mb_taps, g.mb_amount, 1.0, 0.0],
..Default::default()
},
wy,
Expand Down
252 changes: 79 additions & 173 deletions crates/compositor/src/compositor_windows.rs

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion crates/compositor/src/cursor.rs
Original file line number Diff line number Diff line change
Expand Up @@ -92,7 +92,7 @@ impl CursorTrack {
/// Seul point de construction : garantit que `follow_samples` est toujours dérivé des
/// échantillons courants. Une piste re-lissée (`smoothed`) recalcule donc aussi son suivi,
/// pour que la caméra suive la trajectoire que l'utilisateur voit réellement.
fn new(samples: Vec<(f32, f32, f32)>, clicks: Vec<f32>, types: Vec<(f32, String)>) -> CursorTrack {
pub(crate) fn new(samples: Vec<(f32, f32, f32)>, clicks: Vec<f32>, types: Vec<(f32, String)>) -> CursorTrack {
let follow_samples = smooth_follow_samples(&samples);
CursorTrack { samples, follow_samples, clicks, types }
}
Expand Down
162 changes: 152 additions & 10 deletions crates/compositor/src/frame_geometry.rs
Original file line number Diff line number Diff line change
Expand Up @@ -379,7 +379,7 @@ pub(crate) fn cursor_sprite_dst(center: [f32; 2], w: f32, h: f32, hotspot: [f32;
/// donc pas seulement sa position qu'il faut projeter mais son sprite entier : autrement il se
/// lit comme un autocollant plat posé sur une scène en perspective.
#[derive(Clone, Copy)]
pub(crate) enum CursorPlacement {
pub enum CursorPlacement {
/// Écran droit : centre en coordonnées sortie 0..1.
Upright { center: [f32; 2] },
/// Écran incliné : position 0..1 DANS le plan, plus de quoi projeter les coins du sprite.
Expand Down Expand Up @@ -732,6 +732,7 @@ pub struct FrameGeometryInput<'a> {
pub struct FrameGeometry {
pub scene_preset: Option<String>,
pub mb_taps: f32,
pub mb_amount: f32,
pub source_t: f32,
pub zoom_rotation: [f32; 3],
pub padding_scale: f32,
Expand Down Expand Up @@ -862,6 +863,9 @@ pub fn plan_frame(input: &FrameGeometryInput) -> FrameGeometry {
let mb_taps = scene
.map(|s| 1.0 + s.effects.motion_blur.clamp(0.0, 1.0) * 15.0)
.unwrap_or(cfg.mblur_n as f32);
let mb_amount = scene
.map(|s| s.effects.motion_blur.clamp(0.0, 1.0))
.unwrap_or(if cfg.mblur_n > 1 { 1.0 } else { 0.0 });

// Zoom regions + Full Camera : filtrées en amont pour le clip actif et échantillonnées
// dans le même référentiel source que le PTS du décodeur écran.
Expand Down Expand Up @@ -1165,6 +1169,7 @@ pub fn plan_frame(input: &FrameGeometryInput) -> FrameGeometry {
FrameGeometry {
scene_preset,
mb_taps,
mb_amount,
source_t,
zoom_rotation,
padding_scale,
Expand Down Expand Up @@ -1280,16 +1285,32 @@ pub fn plan_cursor(g: &FrameGeometry, input: &CursorPlanInput) -> Option<CursorP

let blur01 = lp.cursor_motion_blur.clamp(0.0, 1.0);
let has_scene = input.scene.is_some();
let trail_frames = if has_scene { 1.0 + blur01 * 7.0 } else { 1.0 };
let taps = if has_scene {
(1.0 + blur01 * 10.0).round() as u32
} else {
input.cfg.mblur_n
};
let prev_placement = if taps <= 1 {
placement
let (taps, prev_placement) = if !has_scene {
let taps = input.cfg.mblur_n;
let prev = if taps <= 1 {
placement
} else {
place(input.track.at(input.t - 1.0 / FPS), g.s_dst_prev).unwrap_or(placement)
};
(taps, prev)
} else if blur01 <= 0.001 {
(1, placement)
} else {
place(input.track.at(input.t - trail_frames / FPS), g.s_dst_prev).unwrap_or(placement)
// Intervalle d'obturateur court, borné à 1 frame (100% blur = 1 frame d'exposition)
let trail_dt = blur01 / FPS;
let prev = place(input.track.at(input.t - trail_dt), g.s_dst_prev).unwrap_or(placement);
let c_now = placement.upright_center();
let c_prev = prev.upright_center();
let dist_px = ((c_now[0] - c_prev[0]) * rw).hypot((c_now[1] - c_prev[1]) * rh);
if dist_px < 1.0 {
// Quasi immobile : pas de copies superflues
(1, placement)
} else {
// Densité d'échantillonnage adaptée à la distance pour éliminer les fantômes discrets
let needed = (dist_px / 2.5).ceil() as u32;
let taps = needed.clamp(2, 16);
(taps, prev)
}
};

Some(CursorPlan {
Expand All @@ -1302,6 +1323,21 @@ pub fn plan_cursor(g: &FrameGeometry, input: &CursorPlanInput) -> Option<CursorP
})
}

/// Poids d'un échantillon du flou de mouvement de curseur (0 = queue/passé, taps-1 = tête/courant).
///
/// Poids croissant de 0.25 (queue) à 1.0 (tête), normalisé pour que la somme valle 1.0.
/// La somme des (0.25 + 0.75 * k / (taps - 1)) pour k de 0 à taps-1 vaut taps * (0.25 + 1.0) / 2 = taps * 0.625.
#[inline]
pub fn cursor_tap_weight(k: u32, taps: u32) -> f32 {
if taps <= 1 {
return 1.0;
}
let t = k as f32 / (taps - 1) as f32;
let ramp = 0.25 + 0.75 * t;
let sum = taps as f32 * 0.625;
ramp / sum
}

/// Les clés à évincer d'un cache de textures pour repasser sous `budget`, la moins récemment
/// utilisée d'abord. `entries` porte `(clé, octets, tick d'usage)`.
///
Expand Down Expand Up @@ -1983,4 +2019,110 @@ mod tests {
let uv = webcam_source_rect([100.0, 100.0], [100.0, 100.0], Some(crop), 0.50 / 0.60);
assert_rect(uv, [0.25, 0.20, 0.75, 0.80]);
}

#[test]
fn cursor_tap_weight_sums_to_one_and_is_monotonically_increasing() {
assert_eq!(cursor_tap_weight(0, 1), 1.0);

for taps in [2, 4, 8, 11, 16] {
let mut sum = 0.0;
let mut prev_w = 0.0;
for k in 0..taps {
let w = cursor_tap_weight(k, taps);
assert!(w > 0.0, "poids positif");
if k > 0 {
assert!(w > prev_w, "tête plus marquée que la queue : {w} > {prev_w}");
}
prev_w = w;
sum += w;
}
assert!((sum - 1.0).abs() < 1e-5, "somme des poids = 1.0 pour taps={taps}, got {sum}");
}
}

#[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 {
scene_preset: None,
mb_taps: 1.0,
mb_amount: 0.0,
source_t: 0.0,
zoom_rotation: [0.0, 0.0, 0.0],
padding_scale: 1.0,
cut: [0.0, 0.0, 1.0, 1.0],
s_dst: [0.0, 0.0, 1.0, 1.0],
s_dst_prev: [0.0, 0.0, 1.0, 1.0],
s_ann: [0.0, 0.0, 1.0, 1.0],
s_radius: 0.0,
frame_min_px: 1080.0,
w_dst: [0.0, 0.0, 0.0, 0.0],
w_dst_prev: [0.0, 0.0, 0.0, 0.0],
w_px: [0.0, 0.0],
w_radius: 0.0,
shape_fade: 0.0,
};

// 1. Curseur immobile avec blur actif -> taps = 1
let live_with_blur = LiveParams {
cursor_motion_blur: 0.8,
..LiveParams::default()
};
let input_immobile = CursorPlanInput {
render_px: [1920.0, 1080.0],
u_max: 1.0,
v_max: 1.0,
cfg: &cfg,
live: live_with_blur,
scene: Some(&scene),
track: &track_immobile,
t: 0.5,
};
let plan = plan_cursor(&fg, &input_immobile).expect("plan cursor");
assert_eq!(plan.taps, 1, "curseur immobile doit rester à 1 tap");

// 2. Curseur avec blur = 0 -> taps = 1
let live_no_blur = LiveParams {
cursor_motion_blur: 0.0,
..LiveParams::default()
};
let input_no_blur = CursorPlanInput {
render_px: [1920.0, 1080.0],
u_max: 1.0,
v_max: 1.0,
cfg: &cfg,
live: live_no_blur,
scene: Some(&scene),
track: &track_moving,
t: 0.5,
};
let plan = plan_cursor(&fg, &input_no_blur).expect("plan cursor");
assert_eq!(plan.taps, 1, "blur=0 doit donner taps = 1");

// 3. Curseur en mouvement rapide avec blur -> taps adaptatifs entre 2 et 16
let input_moving = CursorPlanInput {
render_px: [1920.0, 1080.0],
u_max: 1.0,
v_max: 1.0,
cfg: &cfg,
live: live_with_blur,
scene: Some(&scene),
track: &track_moving,
t: 0.5,
};
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);
}
}

83 changes: 83 additions & 0 deletions crates/compositor/src/live.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1096,6 +1096,29 @@ type PendingPrefetch = (usize, std::sync::mpsc::Receiver<Result<PrefetchedClip>>
/// décodeurs ouvertes plus longtemps que nécessaire.
const PREFETCH_LEAD_SEC: f64 = 0.75;

/// Durée pendant laquelle la boucle continue de recomposer après un changement en pause, le
/// temps qu'un effet asynchrone (segmentation webcam) livre son résultat. Généreuse : à
/// l'échelle d'une pause, une demi-seconde de recomposes ne coûte rien, alors qu'une fenêtre
/// trop courte laisse l'effet invisible sur une machine lente — exactement le bug d'origine.
const SETTLE_WINDOW: Duration = Duration::from_millis(500);
/// Cadence des recomposes dans cette fenêtre : celle de la segmentation (`SEGMENTATION_HZ`),
/// pas celle de la boucle — recomposer à 250 Hz n'accélérerait pas une inférence limitée à 30 Hz.
const SETTLE_STEP: Duration = Duration::from_millis(33);

/// Ouvre (ou rouvre) la fenêtre de stabilisation. Les deux appelants — changement en pause et
/// seek en pause — doivent poser la MÊME paire : un `last_settle` oublié ferait recomposer à la
/// cadence de la boucle au lieu de celle de la segmentation.
fn open_settle_window(now: Instant) -> (Option<Instant>, Instant) {
(Some(now + SETTLE_WINDOW), now)
}

/// Faut-il recomposer alors que RIEN n'a changé ? Oui tant que la fenêtre de stabilisation
/// court et que la cadence le permet. Extrait de la boucle pour être vérifiable sans GPU.
fn should_settle(now: Instant, settle_until: Option<Instant>, last_settle: Instant) -> bool {
settle_until.is_some_and(|deadline| now < deadline)
&& now.duration_since(last_settle) >= SETTLE_STEP
}

/// Démarre le préchargement du clip suivant sur un thread dédié dès qu'on entre dans la
/// fenêtre `PREFETCH_LEAD_SEC` avant la fin du clip actif — pour que la bascule à la
/// frontière (`advance_to_next_scene_clip`) trouve les décodeurs déjà ouverts et positionnés
Expand Down Expand Up @@ -1348,6 +1371,15 @@ 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)
// Fenêtre de stabilisation après un changement EN PAUSE. Un seul recompose ne suffit pas
// quand l'effet demandé est asynchrone : la segmentation webcam (détourage / flou / fond
// personnalisé) démarre son worker au 1er compose, ne SOUMET la frame qu'au 2e et ne
// téléverse le masque qu'au 3e — d'où l'effet qui n'apparaissait qu'au scrub suivant, le
// scrub étant la seule chose qui recomposait encore.
// ponytail: fenêtre fixe plutôt qu'un vrai signal « masque en attente » exposé par les
// trois compositeurs ; à remplacer si une machine met plus que ça à inférer.
let mut settle_until: Option<Instant> = None;
let mut last_settle = Instant::now();
// 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 @@ -1575,6 +1607,11 @@ unsafe fn render_thread(
if let Some(target) = requested {
if player.present_frame(&comp, &cfg, target)? {
stepped = true;
// Un seek en pause compose UNE fois, exactement comme un changement de param :
// la frame webcam a changé, donc son masque aussi, et il arrivera deux composes
// plus tard. Sans cette fenêtre, le masque de la position PRÉCÉDENTE reste
// affiché jusqu'à ce qu'une autre action provoque un compose.
(settle_until, last_settle) = open_settle_window(now);
}
acc = 0.0; // resynchronise l'accumulateur de lecture libre après un seek
} else if shared.playing.load(Ordering::Relaxed) {
Expand Down Expand Up @@ -1687,6 +1724,14 @@ unsafe fn render_thread(
}
} else if first || ip_changed || scene_changed || clip_changed || resized {
// pause : recompose la frame courante (param / scène / clip / résolution changés).
(settle_until, last_settle) = open_settle_window(now);
let _ = player.recompose(&comp, &cfg);
stepped = true;
} else if should_settle(now, settle_until, last_settle) {
// Rien n'a changé, mais un masque de segmentation peut encore être en vol : on
// recompose à la cadence de la segmentation (pas à celle de la boucle) jusqu'à ce
// que la fenêtre expire.
last_settle = now;
let _ = player.recompose(&comp, &cfg);
stepped = true;
}
Expand Down Expand Up @@ -1905,6 +1950,44 @@ pub fn run_standalone(_screen: &str, _webcam: &str, _cursor_json: &str) -> anyho

#[cfg(test)]
mod tests {
use super::{open_settle_window, should_settle, SETTLE_STEP, SETTLE_WINDOW};
use std::time::Instant;

/// Le bug d'origine : en pause, un seul recompose par changement, donc le masque de
/// segmentation (asynchrone, 3 composes de latence) n'arrivait jamais avant un scrub.
#[test]
fn settle_recomposes_within_the_window_at_the_segmentation_rate() {
let t0 = Instant::now();
let deadline = Some(t0 + SETTLE_WINDOW);

assert!(!should_settle(t0, None, t0), "aucune fenêtre ouverte : rien à faire");
assert!(
!should_settle(t0 + SETTLE_STEP / 2, deadline, t0),
"dans la fenêtre mais trop tôt : on ne recompose pas à la cadence de la boucle",
);
assert!(
should_settle(t0 + SETTLE_STEP, deadline, t0),
"dans la fenêtre et la cadence est due : c'est le tour qui livre le masque",
);
assert!(
!should_settle(t0 + SETTLE_WINDOW, deadline, t0),
"fenêtre expirée : on retombe en pause inerte plutôt que de recomposer sans fin",
);
}

/// Un seek en pause compose aussi UNE seule fois : la frame webcam a changé, son masque
/// arrive deux composes plus tard. Le chemin `present_frame` doit donc ouvrir la même
/// fenêtre que le chemin « un param a changé », sans recomposer immédiatement.
#[test]
fn a_paused_seek_opens_the_same_window() {
let t0 = Instant::now();
let (until, last) = open_settle_window(t0);

assert!(!should_settle(t0, until, last), "pas de recompose en boucle juste après le seek");
assert!(should_settle(t0 + SETTLE_STEP, until, last), "le tour suivant livre le masque");
assert!(!should_settle(t0 + SETTLE_WINDOW, until, last), "puis la fenêtre se referme");
}

use super::*;

fn multiclip_scene() -> Scene {
Expand Down
Loading
Loading