Skip to content

Commit baadc06

Browse files
committed
fix(compositor): mask the tilted blur trail, trail from the previous camera
- privacy_mask: with a mode-8 trail, cover the secret at both frames (upright bounding box), as the flat path already did. The trail leaked 418 red px at 1.1 s of an iso 2x ramp with motion blur 1; now 0. - camera_prev comes from the previous frame's own camera state, so a follow-cursor to fixed-angle handover trails from the previous camera view instead of an upright plane. - docs: roll-free preset values, projective warp, mode-8 motion blur.
1 parent 6017a59 commit baadc06

3 files changed

Lines changed: 159 additions & 35 deletions

File tree

‎crates/compositor/src/frame_geometry.rs‎

Lines changed: 95 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -1691,7 +1691,8 @@ pub struct FrameGeometry {
16911691
/// La rotation de base une frame d'écran plus tôt : avec `s_dst_prev` et `camera_prev`, le
16921692
/// zoom d'AVANT, que le mode 8 floute (`tilt_trail`).
16931693
pub zoom_rotation_prev: [f32; 3],
1694-
/// La caméra réelle une frame plus tôt : son poids et son zoom d'avant, sa visée d'à présent.
1694+
/// La caméra réelle une frame plus tôt (poids, visée, orbite, zoom), `None` si elle n'y était
1695+
/// pas active — même quand elle l'est à cette frame-ci, ou l'inverse.
16951696
pub camera_prev: Option<crate::camera::CameraPose>,
16961697
pub padding_scale: f32,
16971698
/// Coupe source de l'écran en UV texture (crop utilisateur + zoom).
@@ -1843,9 +1844,9 @@ impl FrameGeometry {
18431844
/// floute (`tilted_screen_cb`). `None` quand l'écran est droit ou le flou coupé.
18441845
///
18451846
/// La frame d'avant est celle du mode 0 : la boîte au zoom d'avant (`s_dst_prev`), et ici la
1846-
/// rotation de base d'avant (un angle fixe entre avec le zoom) ou le poids de la caméra réelle.
1847-
/// Parallaxe, impact et visée restent ceux de la frame, comme le focus du mode 0 : le flou suit
1848-
/// le zoom, borné à une frame.
1847+
/// rotation de base d'avant (un angle fixe entre avec le zoom) ou la caméra réelle d'avant
1848+
/// (`camera_prev`). Parallaxe et impact restent ceux de la frame, comme le focus du mode 0 : le
1849+
/// flou suit le zoom et la caméra, borné à une frame.
18491850
pub fn tilt_trail(&self, render_px: [f32; 2]) -> Option<TiltTrail> {
18501851
if !self.tilted() || self.mb_taps < 2.0 || self.mb_amount <= 0.001 {
18511852
return None;
@@ -2390,8 +2391,7 @@ impl FrameGeometry {
23902391
return Some(mask);
23912392
}
23922393

2393-
// Écran incliné (le mode 8 n'a pas de flou de mouvement : pas de trace à couvrir).
2394-
// Même quad et même centre que le dessin de l'écran et que `plan_cursor`.
2394+
// Écran incliné. Même quad et même centre que le dessin de l'écran et que `plan_cursor`.
23952395
let s_px = [self.s_dst[2] * rw, self.s_dst[3] * rh];
23962396
let quad = self.screen_tilt(s_px)?;
23972397
let centre = [
@@ -2411,6 +2411,42 @@ impl FrameGeometry {
24112411
[centre[0] + px, centre[1] + py]
24122412
};
24132413
let pts = [at(x0, y0), at(x1, y0), at(x1, y1), at(x0, y1)];
2414+
// Le mode 8 étale aussi chaque pixel vers là où le plan était une frame plus tôt
2415+
// (`tilt_trail`) : une copie traînée du secret sort du quad courant. Le masque couvre alors
2416+
// la boîte englobante du secret aux deux frames, en rect droit, comme le chemin droit.
2417+
// Même warp que le shader sur les coins d'avant (le drapeau projectif est celui du plan).
2418+
// La perspective grossit localement le côté proche : la force suit l'arête la plus
2419+
// agrandie par rapport au rect droit zoomé, pour que nulle part le masque ne soit plus
2420+
// fin, rapporté au contenu, qu'au repos.
2421+
let (ew, eh) = ((x1 - x0) * s_px[0], (y1 - y0) * s_px[1]);
2422+
let len = |a: [f32; 2], b: [f32; 2]| (b[0] - a[0]).hypot(b[1] - a[1]);
2423+
let tilt_k = (len(pts[0], pts[1]) / ew)
2424+
.max(len(pts[3], pts[2]) / ew)
2425+
.max(len(pts[0], pts[3]) / eh)
2426+
.max(len(pts[1], pts[2]) / eh);
2427+
let strength = (zoom_k * tilt_k).max(1.0);
2428+
// Sans mouvement, la traînée EST le quad (au bit près) : rien de plus à couvrir.
2429+
if let Some(trail) = self.tilt_trail(render_px).filter(|t| t.corners != quad.corners) {
2430+
let before = crate::regions::TiltedQuad { corners: trail.corners, ..quad };
2431+
let then = |fx: f32, fy: f32| {
2432+
let (px, py) = before.point_px(fx, fy);
2433+
[centre[0] + px, centre[1] + py]
2434+
};
2435+
let (mut lo, mut hi) = ([f32::MAX; 2], [f32::MIN; 2]);
2436+
for [px, py] in pts.into_iter().chain([then(x0, y0), then(x1, y0), then(x1, y1), then(x0, y1)]) {
2437+
(lo, hi) = ([lo[0].min(px), lo[1].min(py)], [hi[0].max(px), hi[1].max(py)]);
2438+
}
2439+
let rect =
2440+
[lo[0] / rw, lo[1] / rh, (hi[0] - lo[0]).max(1.0) / rw, (hi[1] - lo[1]).max(1.0) / rh];
2441+
let rect = match self.screen_mask {
2442+
Some(m) => m.clip(rect)?,
2443+
None => rect,
2444+
};
2445+
let mut mask = PrivacyMask::upright(rect, render_px, strength);
2446+
// Un ovale inscrit dans la boîte élargie ne couvrirait plus l'ovale courant.
2447+
mask.oval_ok = false;
2448+
return Some(mask);
2449+
}
24142450
let (mut min_x, mut min_y) = (f32::MAX, f32::MAX);
24152451
let (mut max_x, mut max_y) = (f32::MIN, f32::MIN);
24162452
for [px, py] in pts {
@@ -2432,21 +2468,12 @@ impl FrameGeometry {
24322468
quad_px = [c[2] * rw, c[3] * rh];
24332469
dst = c;
24342470
}
2435-
// La perspective grossit localement le côté proche : la force suit l'arête la plus
2436-
// agrandie par rapport au rect droit zoomé, pour que nulle part le masque ne soit plus
2437-
// fin, rapporté au contenu, qu'au repos.
2438-
let (ew, eh) = ((x1 - x0) * s_px[0], (y1 - y0) * s_px[1]);
2439-
let len = |a: [f32; 2], b: [f32; 2]| (b[0] - a[0]).hypot(b[1] - a[1]);
2440-
let tilt_k = (len(pts[0], pts[1]) / ew)
2441-
.max(len(pts[3], pts[2]) / ew)
2442-
.max(len(pts[0], pts[3]) / eh)
2443-
.max(len(pts[1], pts[2]) / eh);
24442471
Some(PrivacyMask {
24452472
dst,
24462473
quad_px,
24472474
warp: Some(local),
24482475
projective: quad.projective,
2449-
strength: (zoom_k * tilt_k).max(1.0),
2476+
strength,
24502477
oval_ok: true,
24512478
})
24522479
}
@@ -2620,6 +2647,7 @@ pub fn plan_frame(input: &FrameGeometryInput) -> FrameGeometry {
26202647
let mut zoom_camera_prev = 0.0f32;
26212648
let mut zoom_aim = [0.5f32; 2];
26222649
let mut zoom_orbit = [0.5f32; 2];
2650+
let (mut zoom_aim_prev, mut zoom_orbit_prev) = ([0.5f32; 2], [0.5f32; 2]);
26232651
// Curseur masqué → pas de piste pour ce qui anime le plan (parallaxe, impact, caméra
26242652
// `follow-cursor`) : l'export ne charge la piste que si le curseur est affiché
26252653
// (`timeline_walk`), la preview toujours. Sans cette porte, la preview pencherait un
@@ -2649,17 +2677,21 @@ pub fn plan_frame(input: &FrameGeometryInput) -> FrameGeometry {
26492677
zoom_camera = zs.camera;
26502678
zoom_aim = zs.aim;
26512679
zoom_orbit = zs.orbit;
2680+
// Même `CameraFrame` qu'à la frame courante : la caméra réelle d'avant (poids, visée,
2681+
// orbite) sert au flou du mode 8. Échelle et focus n'en dépendent pas.
26522682
let zs_p = crate::regions::zoom_state_in(
26532683
zoom_regions,
26542684
source_t_prev,
26552685
cursor_for_zoom,
2656-
&crate::regions::CameraFrame::NONE,
2686+
&camera,
26572687
&clock,
26582688
);
26592689
pp.zoom = zs_p.scale;
26602690
pp.focus = zs_p.focus;
26612691
zoom_rotation_prev = zs_p.rotation;
26622692
zoom_camera_prev = zs_p.camera;
2693+
zoom_aim_prev = zs_p.aim;
2694+
zoom_orbit_prev = zs_p.orbit;
26632695
}
26642696
// Full Camera ignore le rétrécissement réactif de la webcam (design web : mélanger
26652697
// "rétrécit pour le zoom" et "grandit en plein cadre" dans la même frame n'a pas de sens).
@@ -2872,10 +2904,15 @@ pub fn plan_frame(input: &FrameGeometryInput) -> FrameGeometry {
28722904
zoom: p.zoom,
28732905
press,
28742906
});
2875-
let camera_prev = camera.map(|pose| crate::camera::CameraPose {
2907+
// La caméra réelle de la frame d'avant, dès qu'elle y était active — y compris quand elle
2908+
// ne l'est plus à celle-ci (fin de sa moitié d'une transition vers un angle fixe) ; `None`
2909+
// quand elle ne l'était pas, et le mode 8 reprend alors la rotation d'avant.
2910+
let camera_prev = (zoom_camera_prev > 0.0).then(|| crate::camera::CameraPose {
28762911
weight: zoom_camera_prev,
2912+
aim: focus_in_cut(u_max, v_max, active_crop, zoom_aim_prev, cut),
2913+
orbit: focus_in_cut(u_max, v_max, active_crop, zoom_orbit_prev, cut),
28772914
zoom: pp.zoom,
2878-
..pose
2915+
press,
28792916
});
28802917
let focus_plane = match camera {
28812918
Some(pose) => pose.orbit,
@@ -5714,6 +5751,45 @@ mod tests {
57145751
assert_eq!(at(&ramp(zoomed_golden_scene_json()), 1.6).tilt_trail(render), None);
57155752
}
57165753

5754+
/// Transition chaînée caméra réelle → angle fixe : la frame d'avant peut encore être dans la
5755+
/// moitié `follow-cursor` quand celle-ci est dans la moitié fixe. La traînée doit alors partir
5756+
/// du plan vu par la caméra d'AVANT, pas d'un plan droit.
5757+
#[test]
5758+
fn the_trail_keeps_the_previous_camera_across_a_follow_to_fixed_handover() {
5759+
let cfg = crate::config::all().pop().expect("au moins une config");
5760+
let render = [1170.0, 658.0];
5761+
let track: &'static crate::cursor::CursorTrack = Box::leak(Box::new(
5762+
crate::cursor::CursorTrack::new((0..=300).map(|i| (i as f32 / 30.0, 0.8, 0.3)).collect(), vec![], vec![]),
5763+
));
5764+
let json = zoomed_golden_scene_json().replace(
5765+
r#"[{"clipIndex":0,"startSec":0.0,"endSec":5.0,"scale":2.0,"focusX":0.5,"focusY":0.3,"rotation":"none"}]"#,
5766+
r#"[{"clipIndex":0,"startSec":1.0,"endSec":4.0,"scale":2.0,"focusX":0.5,"focusY":0.5,"rotation":"follow-cursor"},
5767+
{"clipIndex":0,"startSec":4.5,"endSec":8.0,"scale":2.0,"focusX":0.5,"focusY":0.5,"rotation":"iso"}]"#,
5768+
);
5769+
assert_ne!(json, zoomed_golden_scene_json(), "la substitution doit poser les deux régions");
5770+
let scene = Scene::from_json(&json).expect("scène");
5771+
let mut handover = None;
5772+
for k in 0..=600 {
5773+
let t = 4.0 + k as f32 / 600.0;
5774+
let g = plan_frame(&FrameGeometryInput { cursor: Some(track), timeline_t_override: Some(t), ..golden_input(&scene, &cfg) });
5775+
assert_eq!(g.camera_prev.is_some(), g.camera_prev.is_some_and(|p| p.weight > 0.0));
5776+
if g.camera.is_none() && g.camera_prev.is_some() {
5777+
handover = Some(g);
5778+
break;
5779+
}
5780+
}
5781+
let g = handover.expect("une frame où seule la frame d'avant a la caméra");
5782+
let trail = g.tilt_trail(render).expect("traînée");
5783+
let d = g.s_dst_prev;
5784+
let px = [d[2] * render[0], d[3] * render[1]];
5785+
let seen = crate::camera::View::new(px, g.camera_prev.unwrap()).quad(px);
5786+
let upright = crate::regions::rotated_quad_corners_px(px[0], px[1], g.zoom_rotation_prev, g.zoom_rotation_dyn);
5787+
let shape = |c: [(f32, f32); 4]| c.map(|(x, y)| (x - c[0].0, y - c[0].1));
5788+
let close = |a: [(f32, f32); 4], b: [(f32, f32); 4]| a.iter().zip(b).all(|(p, q)| (p.0 - q.0).abs() < 1e-3 && (p.1 - q.1).abs() < 1e-3);
5789+
assert!(close(shape(trail.corners), shape(seen.corners)), "la traînée n'est pas le plan de la caméra d'avant");
5790+
assert!(!close(shape(trail.corners), shape(upright.corners)), "la traînée est retombée sur un plan droit");
5791+
}
5792+
57175793
/// Le calque du mode 8 porte la traînée dans le repère de ses propres coins, y compris
57185794
/// reporté dans le slot d'un layout en bloc ; sans elle, rien de ce qu'il portait ne change.
57195795
#[test]

‎crates/compositor/tests/privacy_blur_under_zoom.rs‎

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -135,3 +135,47 @@ fn a_privacy_mask_keeps_covering_its_content_under_zoom_and_tilt() {
135135
}
136136
assert!(leaks.is_empty(), "le masque laisse voir le secret :\n{}", leaks.join("\n"));
137137
}
138+
139+
/// Pendant la rampe d'un zoom, le flou de mouvement étale chaque pixel vers là où il était une
140+
/// frame plus tôt : une copie traînée du secret sort de la position courante. Le masque doit la
141+
/// couvrir aussi, sur l'écran droit (mode 0) comme incliné (mode 8). Cas rapporté en revue : un
142+
/// `iso` ×2 de 2 à 4 s visant (0,3 ; 0,3), flou au maximum, à 1,1 s en pleine montée.
143+
#[test]
144+
fn a_privacy_mask_covers_the_motion_blur_trail_of_a_zoom() {
145+
let Ok(secret) = std::env::var("OPENSCREEN_PRIVACY_SECRET") else {
146+
println!("SKIP: definir OPENSCREEN_PRIVACY_SECRET (voir l'en-tete du fichier).");
147+
return;
148+
};
149+
let gpu = Gpu::create(false).expect("device d3d11");
150+
let render_at = |masked: bool, rotation: &str, blur: f32, t: f64| {
151+
let json = scene_json(&secret, masked, Some((2.0, 0.3, 0.3, rotation)))
152+
.replace(r#""startSec":0,"endSec":6,"scale""#, r#""startSec":2,"endSec":4,"scale""#)
153+
.replace(r#""motionBlur":0.0}"#, &format!(r#""motionBlur":{blur}}}"#));
154+
let mut cfg = config::all().pop().expect("au moins une config");
155+
cfg.zoom = false;
156+
cfg.layout_anim = false;
157+
let comp = Compositor::new_sized(&gpu, W, H).expect("compositor");
158+
let scene = Scene::from_json(&json).expect("scene valide");
159+
comp.set_live_params(live_params_from_scene(&scene));
160+
comp.set_scene(Some(scene));
161+
comp.clear_cursor();
162+
unsafe {
163+
let mut player = Player::open(&secret, "", &gpu).expect("ouvrir la source");
164+
player.present_frame(&comp, &cfg, t).expect("composer la frame");
165+
comp.readback_resized(W, H).expect("readback")
166+
}
167+
};
168+
let mut leaks = Vec::new();
169+
for rotation in [r#""iso""#, "null"] {
170+
for t in [1.1, 1.3, 1.6, 4.3] {
171+
let clear = red_pixels(&render_at(false, rotation, 1.0, t));
172+
let exposed = red_pixels(&render_at(true, rotation, 1.0, t));
173+
println!("{rotation:<6} t={t:<4} flou 1 : rouge sans masque {clear:>6} avec masque {exposed:>4}");
174+
assert!(clear > 500, "{rotation} t={t} : le pave rouge n'est pas visible ({clear} px)");
175+
if exposed > 0 {
176+
leaks.push(format!("{rotation} t={t} : {exposed} px rouges visibles"));
177+
}
178+
}
179+
}
180+
assert!(leaks.is_empty(), "la traînée du flou sort du masque :\n{}", leaks.join("\n"));
181+
}

0 commit comments

Comments
 (0)