From 3f668322cd44500697257bbed4c09534328d056b Mon Sep 17 00:00:00 2001 From: EtienneLescot Date: Sat, 5 Sep 2026 00:00:49 +0200 Subject: [PATCH 1/4] fix(compositor): high-quality motion blur and gaussian webcam blur - Restrict cursor motion blur shutter interval to current frame (<= 1.25/FPS) instead of 8 frames - Add adaptive tap density for cursor motion blur (stationary drops to 1 tap, moving scales clamp(2, 16)) - Add monotonically front-weighted tap weight ramp (cursor_tap_weight) normalized to 1.0, blended per-tap in D3D11, Metal, and Vulkan - Pass mb_amount to shaders and scale general motion blur displacement proportionally backwards from uv_now - Replace 25-tap Cartesian grid webcam background blur with isotropic 21-tap Vogel spiral Gaussian bokeh kernel and IGN rotation - Deduplicate Windows cursor placement by reusing plan_cursor --- crates/compositor/src/compositor_linux.rs | 10 +- crates/compositor/src/compositor_macos.rs | 8 +- crates/compositor/src/compositor_windows.rs | 252 ++++++-------------- crates/compositor/src/cursor.rs | 2 +- crates/compositor/src/frame_geometry.rs | 162 ++++++++++++- crates/compositor/src/shaders.hlsl | 61 +++-- crates/compositor/src/shaders.metal | 57 ++++- crates/compositor/src/vk_shaders/layer.wgsl | 61 +++-- 8 files changed, 378 insertions(+), 235 deletions(-) diff --git a/crates/compositor/src/compositor_linux.rs b/crates/compositor/src/compositor_linux.rs index c3a43878f..d27cbdf1a 100644 --- a/crates/compositor/src/compositor_linux.rs +++ b/crates/compositor/src/compositor_linux.rs @@ -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), @@ -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 @@ -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); } diff --git a/crates/compositor/src/compositor_macos.rs b/crates/compositor/src/compositor_macos.rs index 3d6aa9727..e3c8cff5e 100644 --- a/crates/compositor/src/compositor_macos.rs +++ b/crates/compositor/src/compositor_macos.rs @@ -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, @@ -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, @@ -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, diff --git a/crates/compositor/src/compositor_windows.rs b/crates/compositor/src/compositor_windows.rs index a1a2c13f9..e6ed30ecf 100644 --- a/crates/compositor/src/compositor_windows.rs +++ b/crates/compositor/src/compositor_windows.rs @@ -1629,14 +1629,7 @@ impl Compositor { let scene_ref = self.scene.borrow(); let cursor_ref = self.cursor.borrow(); let lp = *self.live_params.borrow(); - // Toute la géométrie vit dans `frame_geometry::plan_frame` — 353 lignes sans un - // appel GPU, partagées avec le backend Metal. Ce qui suit ce destructure est - // inchangé, à l'octet près. - let crate::frame_geometry::FrameGeometry { - scene_preset, mb_taps, source_t, zoom_rotation, padding_scale, cut, s_dst, - s_dst_prev, s_ann, s_radius, frame_min_px, w_dst, w_dst_prev, w_px, w_radius, - shape_fade, - } = crate::frame_geometry::plan_frame(&crate::frame_geometry::FrameGeometryInput { + let g = crate::frame_geometry::plan_frame(&crate::frame_geometry::FrameGeometryInput { render_px: [self.rw(), self.rh()], screen_tex_px: [stw as f32, sth as f32], screen_visible_px: [scw, sch], @@ -1650,6 +1643,23 @@ impl Compositor { cursor: cursor_ref.as_ref(), timeline_t_override: *self.timeline_t_override.borrow(), }); + let scene_preset = g.scene_preset.clone(); + let mb_taps = g.mb_taps; + let mb_amount = g.mb_amount; + let source_t = g.source_t; + let zoom_rotation = g.zoom_rotation; + let _padding_scale = g.padding_scale; + let cut = g.cut; + let s_dst = g.s_dst; + let s_dst_prev = g.s_dst_prev; + let s_ann = g.s_ann; + let s_radius = g.s_radius; + let frame_min_px = g.frame_min_px; + let w_dst = g.w_dst; + let w_dst_prev = g.w_dst_prev; + let w_px = g.w_px; + let w_radius = g.w_radius; + let shape_fade = g.shape_fade; self.begin([0.0, 0.0, 0.0, 1.0]); @@ -1794,7 +1804,7 @@ impl Compositor { color: [0.0, 0.0, 0.0, 1.0], src_prev: [su0_p, sv0_p, su0_p + 2.0 * hu_p, sv0_p + 2.0 * hv_p], dst_prev: s_dst_prev, - mb: [mb_taps, 1.0, 1.0, 0.0], + mb: [mb_taps, mb_amount, 1.0, 0.0], ..Default::default() }, &sy, @@ -1856,175 +1866,71 @@ impl Compositor { } // --- curseur custom : suit le mapping src/dst (zoom+layout), click bounce, - // et flou de mouvement par fantômes le long de sa vélocité (frame-1 -> frame) --- - // Jeu de sprites résolu par l'app (art du thème + art intégrée pour les états qu'il ne - // fournit pas), sinon math dot+ring — fixture/bench sans scène uniquement. - let cursor_sprites: HashMap = self - .scene - .borrow() - .as_ref() - .map(|s| s.cursor.cursor_sprites.clone()) - .unwrap_or_default(); - // « Clip to canvas » : tronque le curseur aux bords de l'écran (utile quand le padding - // crée une marge et que la pointe, près du bord de la vidéo, dépasserait dedans). - // Rect englobant tout par défaut = pas d'effet (le mode 4/7 du shader clippe sur `fx`). - // Écran incliné : le rect droit d'origine rognerait le curseur sur les parties du plan - // qui débordent au-dessus/en dessous, donc on clippe sur la bbox du quad projeté. Un - // rect reste une approximation du quadrilatère — `fx` ne sait pas exprimer autre chose — - // mais qui ne coupe plus rien de ce qui est réellement affiché. - let cursor_bounds: [f32; 4] = match tilt.as_ref() { - None => s_dst, - Some(quad) => { - let (hx, hy) = quad.half_extents_px(); - [ - (quad_center_px[0] - hx) / self.rw(), - (quad_center_px[1] - hy) / self.rh(), - 2.0 * hx / self.rw(), - 2.0 * hy / self.rh(), - ] - } - }; - let cursor_clip_rect: [f32; 4] = match self.scene.borrow().as_ref() { - Some(s) if s.cursor.clip_to_bounds => cursor_bounds, - _ => [-1.0, -1.0, 3.0, 3.0], - }; - // « Show cursor » : piloté par la scène (contrat de l'app) quand elle est posée ; sinon - // par `cfg.cursor` (inspector / bench fixture). - let cursor_show = scene_ref - .as_ref() - .map(|s| s.cursor.show) - .unwrap_or(cfg.cursor); - if cursor_show { - let cursor_ref = self.cursor.borrow(); - if let Some(track) = cursor_ref.as_ref() { - let t = self.cursor_t_override.borrow().unwrap_or(frame / FPS); - // position sortie à un temps donné via un mapping screen (src rect + dst) - let map = |cxy: Option<(f32, f32)>, s0: [f32; 2], h: [f32; 2], dst: [f32; 4]| { - cxy.and_then(|(cx2, cy2)| { - let fx = (cx2 * u_max - s0[0]) / (2.0 * h[0]); - let fy = (cy2 * v_max - s0[1]) / (2.0 * h[1]); - if !(0.0..=1.0).contains(&fx) || !(0.0..=1.0).contains(&fy) { - return None; - } - // Écran incliné : le curseur vit SUR le plan, pas dans un calque - // au-dessus. On garde donc sa position dans le repère DU PLAN et c'est - // le dessin qui projette — position ET sprite. Le poser sur `dst`, le - // rect droit d'origine, le laissait flotter à côté de l'image, l'écart - // se comptant en dizaines de pixels là où le plan s'éloigne le plus. - Some(match tilt.as_ref() { - Some(&quad) => CursorPlacement::Tilted { - plane_pt: [fx, fy], - quad, - center_px: quad_center_px, - screen_px: s_px, - render_px: [self.rw(), self.rh()], - }, - None => CursorPlacement::Upright { - center: [dst[0] + fx * dst[2], dst[1] + fy * dst[3]], - }, - }) - }) - }; - let raw_xy = track.at(t); - // Hors de [0,1] = pointeur hors du rect source actuel (zoom serré / hors écran) — - // état normal en cours de lecture, pas une erreur : rien à dessiner cette frame. - let mapped = map(raw_xy, [su0, sv0], [hu, hv], s_dst); - if let Some(cur) = mapped { - // taille + amplitude du bounce pilotées par l'inspector (défauts = fixture). - // `padding_scale` : le curseur est un recouvrement synthétique, pas cuit dans - // la vidéo — quand le padding rétrécit l'écran, le curseur doit rétrécir - // pareil pour rester à l'échelle du contenu (sinon sa pointe semble se - // décaler/dériver à mesure que le padding grandit). - let bounce = 1.0 + (track.bounce(t) - 1.0) * lp.cursor_bounce_scale; - // Pas de facteur de tilt ici : sur un plan incliné la taille est convertie - // en fraction du plan puis projetée avec lui (voir `draw_cursor_sprite`), - // donc la réduction vient de la projection. L'ajouter en plus rétrécirait - // le curseur deux fois. - let sz = CURSOR_BASE_SIZE_FRAC - * frame_min_px - * lp.cursor_size_scale - * bounce - * padding_scale; - // flou de mouvement DU CURSEUR, indépendant de cfg.mblur_n (écran/vidéo). - // BUG corrigé : augmenter l'intensité ne faisait auparavant que sur-échantillonner - // (plus de taps) un écart figé d'1 frame (1/60s) -> la traînée ne s'allongeait - // JAMAIS, donc restait quasi invisible quel que soit le réglage. L'intensité doit - // étirer la FENÊTRE temporelle de la traînée, pas seulement sa densité d'échantillons. - // 0 -> 1 frame en arrière (net) ; 1 -> ~8 frames (~130 ms à 60fps, traînée nette). - let blur01 = lp.cursor_motion_blur.clamp(0.0, 1.0); - let has_scene = self.scene.borrow().is_some(); - let trail_frames = if has_scene { 1.0 + blur01 * 7.0 } else { 1.0 }; - // BUG corrigé : le plancher était 2 (pas 1) -> même à blur=0 le curseur - // passait TOUJOURS par le chemin additif multi-tap (poids 1/taps=0.5 chacun), - // et comme prev≠cur au pixel près, les deux copies à 0.5 d'alpha ne se - // recouvraient jamais parfaitement -> curseur en permanence semi-transparent - // (quasi invisible sur fond clair), même sans aucun flou demandé. - let taps = if has_scene { - (1.0 + blur01 * 10.0).round() as u32 // 0 -> 1 (net) ; 1 -> 11 (traînée) - } else { - cfg.mblur_n // fixture/bench : comportement historique inchangé - }; - // L'état est celui de l'instant rendu : la traînée de flou reprend le même - // sprite pour toutes ses copies, un changement d'état en plein mouvement - // n'a pas à laisser une traînée hybride. - let cursor_type = track.type_at(t).map(str::to_string); - let cursor_type = cursor_type.as_deref(); - if taps <= 1 { + // et flou de mouvement (parité `compositor_macos.rs` et `compositor_linux.rs`) --- + if let Some(track) = cursor_ref.as_ref() { + let plan = crate::frame_geometry::plan_cursor( + &g, + &crate::frame_geometry::CursorPlanInput { + render_px: [self.rw(), self.rh()], + u_max, + v_max, + cfg, + live: lp, + scene: scene_ref.as_ref(), + track, + t: self.cursor_t_override.borrow().unwrap_or(frame / FPS), + }, + ); + if let Some(plan) = plan { + let cursor_sprites: HashMap = scene_ref + .as_ref() + .map(|s| s.cursor.cursor_sprites.clone()) + .unwrap_or_default(); + let cursor_type = plan.cursor_type.as_deref(); + if plan.taps <= 1 { + self.draw_cur_themed( + &cursor_sprites, + cursor_type, + plan.placement, + plan.size_px, + 1.0, + plan.clip, + ); + } else { + // Flou RÉEL, pas des copies discrètes : accumule les N échantillons dans un + // buffer ISOLÉ (transparent), pas directement sur la scène déjà composée. + self.ctx.ClearRenderTargetView(&self.accum_rtv, &[0.0, 0.0, 0.0, 0.0]); + self.ctx.OMSetRenderTargets(Some(&[Some(self.accum_rtv.clone())]), None); + 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); + self.ctx.OMSetBlendState(&self.blend_add, Some(&[w, w, w, w]), 0xffffffff); self.draw_cur_themed( &cursor_sprites, cursor_type, - cur, - sz, + plan.prev_placement.lerp(plan.placement, f), + plan.size_px, 1.0, - cursor_clip_rect, + plan.clip, ); - } else { - let tp = t - trail_frames / FPS; - let prev = map(track.at(tp), [su0_p, sv0_p], [hu_p, hv_p], s_dst_prev) - .unwrap_or(cur); - // Flou RÉEL, pas des copies discrètes : accumule les N échantillons dans un - // buffer ISOLÉ (transparent), pas directement sur la scène déjà composée. - // BUG précédent : additionner directement sur `self.rtv` revient à AJOUTER - // la couleur du curseur (blanc) à ce qu'il y a déjà dessous — sur un fond - // clair, ajouter du blanc*petit-alpha ne change presque rien de visible - // (déjà proche du blanc) -> curseur quasi invisible. En accumulant d'abord - // dans un buffer à part (parti de zéro, même mécanisme que le motion blur - // écran de `compose_frame_mb`), la somme reste correctement normalisée - // (alpha final ~1 si les échantillons se recouvrent), puis on la composite - // sur la scène par un blend "over" classique — correct quel que soit le fond. - self.ctx.ClearRenderTargetView(&self.accum_rtv, &[0.0, 0.0, 0.0, 0.0]); - self.ctx.OMSetRenderTargets(Some(&[Some(self.accum_rtv.clone())]), None); - let w = 1.0 / taps as f32; - self.ctx.OMSetBlendState(&self.blend_add, Some(&[w, w, w, w]), 0xffffffff); - for k in 0..taps { - let f = k as f32 / (taps - 1) as f32; - self.draw_cur_themed( - &cursor_sprites, - cursor_type, - prev.lerp(cur, f), - sz, - 1.0, - cursor_clip_rect, - ); - } - // composite le buffer accumulé sur la scène (blend "over" normal, prémultiplié). - self.ctx.OMSetRenderTargets(Some(&[Some(self.rtv.clone())]), None); - self.ctx.PSSetShaderResources(0, Some(&[Some(self.accum_srv.clone())])); - self.ctx.VSSetShader(&self.vs_fs, None); - self.ctx.PSSetShader(&self.ps_tex, None); - self.ctx.PSSetSamplers(0, Some(&[Some(self.sampler.clone())])); - let vp = D3D11_VIEWPORT { - TopLeftX: 0.0, TopLeftY: 0.0, - Width: self.rw(), Height: self.rh(), MinDepth: 0.0, MaxDepth: 1.0, - }; - self.ctx.RSSetViewports(Some(&[vp])); - self.ctx.OMSetBlendState(&self.blend, None, 0xffffffff); - self.ctx.Draw(3, 0); - self.ctx.PSSetShaderResources(0, Some(&[None])); - // restaure l'état de composition standard (VS/PS/topologie quad-strip) pour - // le dessin de la webcam qui suit juste après. - self.bind_compose_state(); } + // composite le buffer accumulé sur la scène (blend "over" normal, prémultiplié). + self.ctx.OMSetRenderTargets(Some(&[Some(self.rtv.clone())]), None); + self.ctx.PSSetShaderResources(0, Some(&[Some(self.accum_srv.clone())])); + self.ctx.VSSetShader(&self.vs_fs, None); + self.ctx.PSSetShader(&self.ps_tex, None); + self.ctx.PSSetSamplers(0, Some(&[Some(self.sampler.clone())])); + let vp = D3D11_VIEWPORT { + TopLeftX: 0.0, TopLeftY: 0.0, + Width: self.rw(), Height: self.rh(), MinDepth: 0.0, MaxDepth: 1.0, + }; + self.ctx.RSSetViewports(Some(&[vp])); + self.ctx.OMSetBlendState(&self.blend, None, 0xffffffff); + self.ctx.Draw(3, 0); + self.ctx.PSSetShaderResources(0, Some(&[None])); + // restaure l'état de composition standard (VS/PS/topologie quad-strip) pour + // le dessin de la webcam qui suit juste après. + self.bind_compose_state(); } } } @@ -2126,7 +2032,7 @@ impl Compositor { fx: [w_valid[0], w_valid[1], effect_code, blur_intensity], src_prev: [u0, sv0, u1, sv1], // src fixe (pas de zoom webcam) dst_prev: w_dst_prev, - mb: [mb_taps, 1.0, 1.0, 0.0], + mb: [mb_taps, mb_amount, 1.0, 0.0], ..Default::default() }, &wy, diff --git a/crates/compositor/src/cursor.rs b/crates/compositor/src/cursor.rs index de80c043a..a34c2ae00 100644 --- a/crates/compositor/src/cursor.rs +++ b/crates/compositor/src/cursor.rs @@ -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, types: Vec<(f32, String)>) -> CursorTrack { + pub(crate) fn new(samples: Vec<(f32, f32, f32)>, clicks: Vec, types: Vec<(f32, String)>) -> CursorTrack { let follow_samples = smooth_follow_samples(&samples); CursorTrack { samples, follow_samples, clicks, types } } diff --git a/crates/compositor/src/frame_geometry.rs b/crates/compositor/src/frame_geometry.rs index 9d5aad8a3..8c1750908 100644 --- a/crates/compositor/src/frame_geometry.rs +++ b/crates/compositor/src/frame_geometry.rs @@ -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. @@ -732,6 +732,7 @@ pub struct FrameGeometryInput<'a> { pub struct FrameGeometry { pub scene_preset: Option, pub mb_taps: f32, + pub mb_amount: f32, pub source_t: f32, pub zoom_rotation: [f32; 3], pub padding_scale: f32, @@ -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. @@ -1165,6 +1169,7 @@ pub fn plan_frame(input: &FrameGeometryInput) -> FrameGeometry { FrameGeometry { scene_preset, mb_taps, + mb_amount, source_t, zoom_rotation, padding_scale, @@ -1280,16 +1285,32 @@ pub fn plan_cursor(g: &FrameGeometry, input: &CursorPlanInput) -> Option Option 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)`. /// @@ -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); + } } + diff --git a/crates/compositor/src/shaders.hlsl b/crates/compositor/src/shaders.hlsl index 57c59fabb..d9b6a13ac 100644 --- a/crates/compositor/src/shaders.hlsl +++ b/crates/compositor/src/shaders.hlsl @@ -174,22 +174,51 @@ float3 quad_inverse_bilinear(float2 P, float2 c00, float2 c10, float2 c11, float return (r0.z > 0.5) ? r0 : r1; } -// Fond floute pour le mode "blur" de la webcam. Rayon en UV pour rester isotrope quel que soit -// le rect source. 25 taps ponderes par la distance : assez doux pour un fond, assez court pour -// tenir dans le budget d'une frame webcam (qui n'occupe qu'une fraction de la sortie). -float3 blur_webcam_bg(float2 uv, float intensity, float2 qpx) +// Fond flouté pour le mode "blur" de la webcam. +// Disque de Vogel (spirale à angle d'or) à 21 échantillons avec pondération gaussienne et +// rotation par pixel via Interleaved Gradient Noise (IGN) pour un bokeh photographique doux, isotrope et rapide. +static const float3 VOGEL_TAPS[21] = { + float3( 0.154303, 0.000000, 0.942213), + float3(-0.197070, 0.180532, 0.836464), + float3( 0.030165, -0.343712, 0.742584), + float3( 0.248394, 0.323986, 0.659241), + float3(-0.455834, -0.080631, 0.585251), + float3( 0.431806, -0.274679, 0.519566), + float3(-0.144431, 0.537274, 0.461253), + float3(-0.275445, -0.530352, 0.409484), + float3( 0.597605, 0.218244, 0.363526), + float3(-0.621708, 0.256632, 0.322726), + float3( 0.299704, -0.640451, 0.286505), + float3( 0.221474, 0.706094, 0.254349), + float3(-0.667525, -0.386844, 0.225802), + float3( 0.783083, -0.172159, 0.200460), + float3(-0.477903, 0.679768, 0.177961), + float3(-0.110407, -0.852001, 0.157988), + float3( 0.677789, 0.571241, 0.140256), + float3(-0.912091, 0.037718, 0.124514), + float3( 0.665301, -0.662063, 0.110540), + float3(-0.044511, 0.962596, 0.098133), + float3(-0.633036, -0.758588, 0.087119) +}; + +float3 blur_webcam_bg(float2 uv, float intensity, float2 qpx, float2 local_px) { - float2 step = (max(intensity, 0.0) * 12.0 + 2.0) / max(qpx, 1.0); + float max_r_px = max(intensity, 0.0) * 22.0 + 1.5; + float2 step = max_r_px / max(qpx, 1.0); + // Interleaved Gradient Noise pour rotation aléatoire par pixel + float noise = frac(52.9829189 * frac(0.06711056 * local_px.x + 0.00583715 * local_px.y)); + float angle = noise * 6.2831853; + float s, c; + sincos(angle, s, c); float3 sum = 0.0; float total = 0.0; - [unroll] for (int dy = -2; dy <= 2; dy++) + [unroll] for (int k = 0; k < 21; k++) { - [unroll] for (int dx = -2; dx <= 2; dx++) - { - float w = 1.0 / (1.0 + length(float2(dx, dy))); - sum += sample_yuv(saturate(uv + float2(dx, dy) * step)) * w; - total += w; - } + float2 p = VOGEL_TAPS[k].xy; + float w = VOGEL_TAPS[k].z; + float2 rot_p = float2(p.x * c - p.y * s, p.x * s + p.y * c); + sum += sample_yuv(saturate(uv + rot_p * step)) * w; + total += w; } return sum / max(total, 1e-4); } @@ -468,8 +497,10 @@ float4 ps_main(VSOut i) : SV_Target float2 localp = (i.pout - dst_prev.xy) / dst_prev.zw; float2 uv_prev = src_prev.xy + localp * (src_prev.zw - src_prev.xy); float2 duv = uv_now - uv_prev; + float mb_scale = saturate(mb.y); + float2 duv_blur = duv * mb_scale; int taps = (int) mb.x; - if (taps <= 1 || dot(duv, duv) < 1e-9) + if (taps <= 1 || mb_scale <= 0.001 || dot(duv_blur, duv_blur) < 1e-9) { rgb = sample_yuv(uv_now); } @@ -480,7 +511,7 @@ float4 ps_main(VSOut i) : SV_Target { if (k >= taps) break; float t = (float) k / (float) (taps - 1); - acc += sample_yuv(uv_prev + duv * t); + acc += sample_yuv(uv_now - duv_blur * (1.0 - t)); } rgb = acc / (float) taps; } @@ -505,7 +536,7 @@ float4 ps_main(VSOut i) : SV_Target } else if (effect > 1.5) { - rgb = lerp(blur_webcam_bg(uv_now, fx.w, quad_px), rgb, person); + rgb = lerp(blur_webcam_bg(uv_now, fx.w, quad_px, i.local), rgb, person); } else { diff --git a/crates/compositor/src/shaders.metal b/crates/compositor/src/shaders.metal index 21d1e973a..9c89eca70 100644 --- a/crates/compositor/src/shaders.metal +++ b/crates/compositor/src/shaders.metal @@ -237,21 +237,52 @@ inline float3 quad_inverse_bilinear(float2 P, float2 c00, float2 c10, float2 c11 // Fond floute du mode "blur" webcam. Miroir de `blur_webcam_bg` cote HLSL : memes 25 taps, // memes poids, meme rayon — les deux back-ends doivent rendre le meme pixel. -inline float3 blur_webcam_bg(float2 uv, float intensity, float2 qpx, +// Fond flouté pour le mode "blur" de la webcam. +// Disque de Vogel (spirale à angle d'or) à 21 échantillons avec pondération gaussienne et +// rotation par pixel via Interleaved Gradient Noise (IGN) pour un bokeh photographique doux, isotrope et rapide. +constant float3 VOGEL_TAPS[21] = { + float3( 0.154303, 0.000000, 0.942213), + float3(-0.197070, 0.180532, 0.836464), + float3( 0.030165, -0.343712, 0.742584), + float3( 0.248394, 0.323986, 0.659241), + float3(-0.455834, -0.080631, 0.585251), + float3( 0.431806, -0.274679, 0.519566), + float3(-0.144431, 0.537274, 0.461253), + float3(-0.275445, -0.530352, 0.409484), + float3( 0.597605, 0.218244, 0.363526), + float3(-0.621708, 0.256632, 0.322726), + float3( 0.299704, -0.640451, 0.286505), + float3( 0.221474, 0.706094, 0.254349), + float3(-0.667525, -0.386844, 0.225802), + float3( 0.783083, -0.172159, 0.200460), + float3(-0.477903, 0.679768, 0.177961), + float3(-0.110407, -0.852001, 0.157988), + float3( 0.677789, 0.571241, 0.140256), + float3(-0.912091, 0.037718, 0.124514), + float3( 0.665301, -0.662063, 0.110540), + float3(-0.044511, 0.962596, 0.098133), + float3(-0.633036, -0.758588, 0.087119) +}; + +inline float3 blur_webcam_bg(float2 uv, float intensity, float2 qpx, float2 local_px, texture2d texY, texture2d texUV) { - float2 step = (max(intensity, 0.0) * 12.0 + 2.0) / max(qpx, float2(1.0)); + float max_r_px = max(intensity, 0.0) * 22.0 + 1.5; + float2 step = max_r_px / max(qpx, float2(1.0)); + float noise = fract(52.9829189 * fract(0.06711056 * local_px.x + 0.00583715 * local_px.y)); + float angle = noise * 6.2831853; + float s = sin(angle); + float c = cos(angle); float3 sum = float3(0.0); float total = 0.0; - for (int dy = -2; dy <= 2; dy++) + for (int k = 0; k < 21; k++) { - for (int dx = -2; dx <= 2; dx++) - { - float w = 1.0 / (1.0 + length(float2(dx, dy))); - sum += sample_yuv(saturate(uv + float2(dx, dy) * step), texY, texUV) * w; - total += w; - } + float2 p = VOGEL_TAPS[k].xy; + float w = VOGEL_TAPS[k].z; + float2 rot_p = float2(p.x * c - p.y * s, p.x * s + p.y * c); + sum += sample_yuv(saturate(uv + rot_p * step), texY, texUV) * w; + total += w; } return sum / max(total, 1e-4); } @@ -526,8 +557,10 @@ fragment float4 ps_main(VSOut i [[stage_in]], float2 localp = (i.pout - layer.dst_prev.xy) / layer.dst_prev.zw; float2 uv_prev = layer.src_prev.xy + localp * (layer.src_prev.zw - layer.src_prev.xy); float2 duv = uv_now - uv_prev; + float mb_scale = saturate(layer.mb.y); + float2 duv_blur = duv * mb_scale; int taps = int(layer.mb.x); - if (taps <= 1 || dot(duv, duv) < 1e-9) + if (taps <= 1 || mb_scale <= 0.001 || dot(duv_blur, duv_blur) < 1e-9) { rgb = sample_yuv(uv_now, texY, texUV); } @@ -538,7 +571,7 @@ fragment float4 ps_main(VSOut i [[stage_in]], { if (k >= taps) break; float t = float(k) / float(taps - 1); - acc += sample_yuv(uv_prev + duv * t, texY, texUV); + acc += sample_yuv(uv_now - duv_blur * (1.0 - t), texY, texUV); } rgb = acc / float(taps); } @@ -557,7 +590,7 @@ fragment float4 ps_main(VSOut i [[stage_in]], } else if (effect > 1.5) { - rgb = mix(blur_webcam_bg(uv_now, layer.fx.w, layer.quad_px, texY, texUV), rgb, person); + rgb = mix(blur_webcam_bg(uv_now, layer.fx.w, layer.quad_px, i.local, texY, texUV), rgb, person); } else { diff --git a/crates/compositor/src/vk_shaders/layer.wgsl b/crates/compositor/src/vk_shaders/layer.wgsl index 15315b944..fd7da966d 100644 --- a/crates/compositor/src/vk_shaders/layer.wgsl +++ b/crates/compositor/src/vk_shaders/layer.wgsl @@ -221,19 +221,48 @@ fn quad_inverse_bilinear(P: vec2, c00: vec2, c10: vec2, c11: vec2 return r1; } -// Fond floute du mode "blur" webcam. Miroir de `blur_webcam_bg` cote HLSL et MSL : memes -// 25 taps, memes poids, meme rayon — les trois back-ends doivent rendre le meme pixel. -fn blur_webcam_bg(uv: vec2, intensity: f32, qpx: vec2) -> vec3 { - let step = (max(intensity, 0.0) * 12.0 + 2.0) / max(qpx, vec2(1.0)); +// Fond flouté pour le mode "blur" de la webcam. +// Disque de Vogel (spirale à angle d'or) à 21 échantillons avec pondération gaussienne et +// rotation par pixel via Interleaved Gradient Noise (IGN) pour un bokeh photographique doux, isotrope et rapide. +const VOGEL_TAPS = array, 21>( + vec3( 0.154303, 0.000000, 0.942213), + vec3(-0.197070, 0.180532, 0.836464), + vec3( 0.030165, -0.343712, 0.742584), + vec3( 0.248394, 0.323986, 0.659241), + vec3(-0.455834, -0.080631, 0.585251), + vec3( 0.431806, -0.274679, 0.519566), + vec3(-0.144431, 0.537274, 0.461253), + vec3(-0.275445, -0.530352, 0.409484), + vec3( 0.597605, 0.218244, 0.363526), + vec3(-0.621708, 0.256632, 0.322726), + vec3( 0.299704, -0.640451, 0.286505), + vec3( 0.221474, 0.706094, 0.254349), + vec3(-0.667525, -0.386844, 0.225802), + vec3( 0.783083, -0.172159, 0.200460), + vec3(-0.477903, 0.679768, 0.177961), + vec3(-0.110407, -0.852001, 0.157988), + vec3( 0.677789, 0.571241, 0.140256), + vec3(-0.912091, 0.037718, 0.124514), + vec3( 0.665301, -0.662063, 0.110540), + vec3(-0.044511, 0.962596, 0.098133), + vec3(-0.633036, -0.758588, 0.087119) +); + +fn blur_webcam_bg(uv: vec2, intensity: f32, qpx: vec2, local_px: vec2) -> vec3 { + let max_r_px = max(intensity, 0.0) * 22.0 + 1.5; + let step = max_r_px / max(qpx, vec2(1.0)); + let noise = fract(52.9829189 * fract(0.06711056 * local_px.x + 0.00583715 * local_px.y)); + let angle = noise * 6.2831853; + let s = sin(angle); + let c = cos(angle); var sum = vec3(0.0); var total = 0.0; - for (var dy: i32 = -2; dy <= 2; dy = dy + 1) { - for (var dx: i32 = -2; dx <= 2; dx = dx + 1) { - let d = vec2(f32(dx), f32(dy)); - let w = 1.0 / (1.0 + length(d)); - sum = sum + sample_yuv(clamp(uv + d * step, vec2(0.0), vec2(1.0))) * w; - total = total + w; - } + for (var k: i32 = 0; k < 21; k = k + 1) { + let p = VOGEL_TAPS[k].xy; + let w = VOGEL_TAPS[k].z; + let rot_p = vec2(p.x * c - p.y * s, p.x * s + p.y * c); + sum = sum + sample_yuv(clamp(uv + rot_p * step, vec2(0.0), vec2(1.0))) * w; + total = total + w; } return sum / max(total, 1e-4); } @@ -252,16 +281,18 @@ fn fs_main(i: VsOut) -> @location(0) vec4 { // floute le long de ce segment, ce qui capture la translation ET le zoom // du calque sans avoir à transporter un champ de vitesse. let taps = i32(layer.mb.x); + let mb_scale = clamp(layer.mb.y, 0.0, 1.0); // `taps` d'abord : un draw qui a oublié `dst_prev` le laisse à zéro, et // la division par `dst_prev.zw` produirait des UV infinis. Dégrader vers // le chemin net est le seul échec acceptable pour un effet cosmétique. - if taps <= 1 || layer.dst_prev.z <= 0.0 || layer.dst_prev.w <= 0.0 { + if taps <= 1 || mb_scale <= 0.001 || layer.dst_prev.z <= 0.0 || layer.dst_prev.w <= 0.0 { rgb = sample_yuv(i.uv); } else { let localp = (i.pout - layer.dst_prev.xy) / layer.dst_prev.zw; let uv_prev = layer.src_prev.xy + localp * (layer.src_prev.zw - layer.src_prev.xy); let duv = i.uv - uv_prev; - if dot(duv, duv) < 1e-9 { + let duv_blur = duv * mb_scale; + if dot(duv_blur, duv_blur) < 1e-9 { rgb = sample_yuv(i.uv); } else { // Borne 16 en dur, identique au HLSL et au MSL : `taps` vient d'un @@ -272,7 +303,7 @@ fn fs_main(i: VsOut) -> @location(0) vec4 { let step = 1.0 / f32(taps - 1); for (var k: i32 = 0; k < 16; k = k + 1) { if k >= taps { break; } - acc = acc + sample_yuv(uv_prev + duv * (f32(k) * step)); + acc = acc + sample_yuv(i.uv - duv_blur * (1.0 - f32(k) * step)); } rgb = acc / f32(taps); } @@ -288,7 +319,7 @@ fn fs_main(i: VsOut) -> @location(0) vec4 { if effect > 2.5 { rgb = mix(layer.color.rgb, rgb, person); } else if effect > 1.5 { - rgb = mix(blur_webcam_bg(i.uv, layer.fx.w, layer.quad_px), rgb, person); + rgb = mix(blur_webcam_bg(i.uv, layer.fx.w, layer.quad_px, i.local), rgb, person); } else { alpha_mask = person; } From ef4e0c22c190c36b2d04337636cdf9c71432d690 Mon Sep 17 00:00:00 2001 From: EtienneLescot Date: Sat, 5 Sep 2026 07:58:19 +0200 Subject: [PATCH 2/4] fix(compositor): bound cursor motion blur trail_dt to at most 1 frame Use blur01 / FPS instead of multiplying by 1.25 so the exposure interval stays strictly within one frame at 100% blur. --- crates/compositor/src/frame_geometry.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/crates/compositor/src/frame_geometry.rs b/crates/compositor/src/frame_geometry.rs index 8c1750908..03a838437 100644 --- a/crates/compositor/src/frame_geometry.rs +++ b/crates/compositor/src/frame_geometry.rs @@ -1296,8 +1296,8 @@ pub fn plan_cursor(g: &FrameGeometry, input: &CursorPlanInput) -> Option Date: Sat, 5 Sep 2026 11:01:07 +0200 Subject: [PATCH 3/4] fix(compositor): show the webcam background effects on click, not on the next scrub MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Segmentation is asynchronous: the first compose starts the worker, the second submits the frame, the third uploads the mask. Paused, the render loop recomposed exactly once per change, so the mask never landed — cutout, blur and custom background all stayed inert until a scrub produced the missing composes. Keep recomposing for 500 ms after a paused change, at the segmentation rate rather than the loop's, so the mask arrives on its own. --- crates/compositor/src/live.rs | 59 +++++++++++++++++++++++++++++++++++ 1 file changed, 59 insertions(+) diff --git a/crates/compositor/src/live.rs b/crates/compositor/src/live.rs index 552d2264a..e47130839 100644 --- a/crates/compositor/src/live.rs +++ b/crates/compositor/src/live.rs @@ -1096,6 +1096,22 @@ type PendingPrefetch = (usize, std::sync::mpsc::Receiver> /// 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); + +/// 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, 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 @@ -1348,6 +1364,15 @@ unsafe fn render_thread( let mut last_preview_size: (u32, u32) = (0, 0); let mut last_ip: Option = 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 = 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. @@ -1687,6 +1712,15 @@ 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 = Some(now + SETTLE_WINDOW); + last_settle = 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; } @@ -1905,6 +1939,31 @@ pub fn run_standalone(_screen: &str, _webcam: &str, _cursor_json: &str) -> anyho #[cfg(test)] mod tests { + use super::{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", + ); + } + use super::*; fn multiclip_scene() -> Scene { From 05f22b9ccb1064ee5560b7f4f4a0773d81b87c48 Mon Sep 17 00:00:00 2001 From: EtienneLescot Date: Sat, 5 Sep 2026 11:18:45 +0200 Subject: [PATCH 4/4] fix(compositor): open the settle window after a paused seek too MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A paused seek goes through `present_frame`, which composes exactly once and skipped the window the previous commit added — so the mask of the PREVIOUS position stayed on screen until some other action caused a compose. Same blind spot as the one that commit fixed, one branch over. Both call sites now go through `open_settle_window`, so a forgotten `last_settle` cannot make the loop recompose at its own rate instead of the segmentation's. --- crates/compositor/src/live.rs | 30 +++++++++++++++++++++++++++--- 1 file changed, 27 insertions(+), 3 deletions(-) diff --git a/crates/compositor/src/live.rs b/crates/compositor/src/live.rs index e47130839..9d91fe643 100644 --- a/crates/compositor/src/live.rs +++ b/crates/compositor/src/live.rs @@ -1105,6 +1105,13 @@ const SETTLE_WINDOW: Duration = Duration::from_millis(500); /// 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) { + (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, last_settle: Instant) -> bool { @@ -1600,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) { @@ -1712,8 +1724,7 @@ 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 = Some(now + SETTLE_WINDOW); - last_settle = now; + (settle_until, last_settle) = open_settle_window(now); let _ = player.recompose(&comp, &cfg); stepped = true; } else if should_settle(now, settle_until, last_settle) { @@ -1939,7 +1950,7 @@ pub fn run_standalone(_screen: &str, _webcam: &str, _cursor_json: &str) -> anyho #[cfg(test)] mod tests { - use super::{should_settle, SETTLE_STEP, SETTLE_WINDOW}; + 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 @@ -1964,6 +1975,19 @@ mod tests { ); } + /// 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 {