diff --git a/crates/compositor-view-napi/src/lib.rs b/crates/compositor-view-napi/src/lib.rs index 13ce87614..8701c8bfe 100644 --- a/crates/compositor-view-napi/src/lib.rs +++ b/crates/compositor-view-napi/src/lib.rs @@ -248,7 +248,7 @@ pub fn read_frame(id: i32, since_gen: f64) -> Result> { } /// Param live (inspector). Le type de valeur route vers le bon setter : -/// bool = switch (backgroundBlur…), number = slider (shadow/roundness/motionBlur), +/// bool = switch (webcamMirror…), number = slider (shadow/roundness/motionBlur/backgroundBlur), /// string = sélection (backgroundColor "#rrggbb"). #[napi] pub fn set_param(id: i32, key: String, value: Either3) { diff --git a/crates/compositor/src/compositor_linux.rs b/crates/compositor/src/compositor_linux.rs index 20e0704a6..5a2b63496 100644 --- a/crates/compositor/src/compositor_linux.rs +++ b/crates/compositor/src/compositor_linux.rs @@ -993,13 +993,14 @@ impl Compositor { src: &wgpu::TextureView, dst: &wgpu::TextureView, src_px: [f32; 2], + off: f32, ) { let cb = LayerCB { mode: -1.0, color: [1.0, 1.0, 1.0, 1.0], // Convention HLSL/Metal (`compositor_windows::blur_bg`) : texel de la - // SOURCE en .xy, offset 2.2 en .z. Parite des trois backends. - fx: [1.0 / src_px[0], 1.0 / src_px[1], 2.2, 0.0], + // SOURCE en .xy, offset en .z. Parite des trois backends. + fx: [1.0 / src_px[0], 1.0 / src_px[1], off, 0.0], ..Default::default() }; let uniform = self.gpu.device.create_buffer_init(&wgpu::util::BufferInitDescriptor { @@ -1044,28 +1045,30 @@ impl Compositor { rpass.draw(0..3, 0..1); } - /// Floute le RT (le fond deja dessine) : dual-Kawase 3 down (RT -> 1/2 -> - /// 1/4 -> 1/8) + 3 up (1/8 -> 1/4 -> 1/2 -> RT). ~gaussien a cout constant. - fn blur_bg(&self, encoder: &mut wgpu::CommandEncoder) { - let (rw, rh) = (self.render_w as f32, self.render_h as f32); - let (hw, hh) = ( - (self.render_w / 2).max(1) as f32, - (self.render_h / 2).max(1) as f32, - ); - let (qw, qh) = ( - (self.render_w / 4).max(1) as f32, - (self.render_h / 4).max(1) as f32, - ); - let (ow, oh) = ( - (self.render_w / 8).max(1) as f32, - (self.render_h / 8).max(1) as f32, - ); - self.blur_pass(encoder, &self.blur_down, &self.rt_view, &self.blur_half, [rw, rh]); - self.blur_pass(encoder, &self.blur_down, &self.blur_half, &self.blur_qtr, [hw, hh]); - self.blur_pass(encoder, &self.blur_down, &self.blur_qtr, &self.blur_oct, [qw, qh]); - self.blur_pass(encoder, &self.blur_up, &self.blur_oct, &self.blur_qtr, [ow, oh]); - self.blur_pass(encoder, &self.blur_up, &self.blur_qtr, &self.blur_half, [qw, qh]); - self.blur_pass(encoder, &self.blur_up, &self.blur_half, &self.rt_view, [hw, hh]); + /// Floute le RT (le fond deja dessine) : dual-Kawase, `levels` down (RT -> 1/2 -> + /// 1/4 -> 1/8) puis autant d'up, niveaux et ecart tires de la force + /// (`background_blur_steps`, partage avec D3D/Metal). ~gaussien a cout borne. + fn blur_bg(&self, encoder: &mut wgpu::CommandEncoder, amount: f32) { + let Some((levels, off)) = crate::frame_geometry::background_blur_steps(amount) else { + return; + }; + let dims = |d: u32| { + [(self.render_w / d).max(1) as f32, (self.render_h / d).max(1) as f32] + }; + let pyramid = [ + (&self.rt_view, dims(1)), + (&self.blur_half, dims(2)), + (&self.blur_qtr, dims(4)), + (&self.blur_oct, dims(8)), + ]; + for i in 0..levels { + let (src, src_px) = pyramid[i]; + self.blur_pass(encoder, &self.blur_down, src, pyramid[i + 1].0, src_px, off); + } + for i in (0..levels).rev() { + let (src, src_px) = pyramid[i + 1]; + self.blur_pass(encoder, &self.blur_up, src, pyramid[i].0, src_px, off); + } } /// Dimensions paires (NV12 4:2:0), min 2x2. Symetrie avec les autres backends. @@ -3054,9 +3057,7 @@ impl Compositor { } } // Blur du fond (avant l'ecran), si active par la scene/l'inspector. - if cfg.bg_blur { - self.blur_bg(&mut encoder); - } + self.blur_bg(&mut encoder, cfg.bg_blur); // Passe 2 : avant-plan (ecran + webcam), compose par-dessus le fond // (eventuellement floute) avec `LoadOp::Load`. Les annotations sont dans // une passe a part, cf. plus bas. @@ -4170,8 +4171,9 @@ mod tests { let mut encoder = gpu .device .create_command_encoder(&wgpu::CommandEncoderDescriptor { label: Some("test-blur") }); - comp.blur_pass(&mut encoder, &comp.pipeline_copy, &src_view, &comp.rt_view, [w as f32, h as f32]); - comp.blur_bg(&mut encoder); + comp.blur_pass(&mut encoder, &comp.pipeline_copy, &src_view, &comp.rt_view, [w as f32, h as f32], 0.0); + // 0,5 = l'ancien interrupteur : trois niveaux, ecart 2,2 (`HP` ci-dessous). + comp.blur_bg(&mut encoder, 0.5); gpu.context.submit(std::iter::once(encoder.finish())); let (_, _, px) = unsafe { comp.readback_direct().expect("readback_direct") }; @@ -4549,7 +4551,7 @@ mod tests { let screen = FakeFrame::new(gpu, 128, 128, |_, _| 126); let webcam = FakeFrame::new(gpu, 64, 64, |_, _| Y_WHITE); let mut cfg = Cfg::c8(); - cfg.bg_blur = false; + cfg.bg_blur = 0.0; cfg.zoom = false; cfg.layout_anim = false; cfg.cursor = false; @@ -4895,7 +4897,7 @@ mod tests { let screen = FakeFrame::new(gpu, 640, 360, |_, _| 60); let webcam = FakeFrame::new(gpu, 64, 64, |_, _| 60); let mut cfg = Cfg::c8(); - cfg.bg_blur = false; + cfg.bg_blur = 0.0; cfg.zoom = false; cfg.layout_anim = false; cfg.cursor = false; @@ -4934,7 +4936,7 @@ mod tests { let screen = FakeFrame::new(gpu, 640, 360, |_, _| 60); let webcam = FakeFrame::new(gpu, 64, 64, |_, _| 60); let mut cfg = Cfg::c8(); - cfg.bg_blur = false; + cfg.bg_blur = 0.0; cfg.cursor = false; cfg.mblur_n = 1; cfg.shadow = true; @@ -5026,7 +5028,7 @@ mod tests { let screen = FakeFrame::new(&gpu, 640, 360, |_, _| 60); let webcam = FakeFrame::new(&gpu, 64, 64, |_, _| 60); let mut cfg = Cfg::c8(); - cfg.bg_blur = false; + cfg.bg_blur = 0.0; cfg.zoom = false; cfg.layout_anim = false; cfg.cursor = false; @@ -5236,7 +5238,7 @@ mod tests { fn model_cfg() -> crate::config::Cfg { let mut cfg = crate::config::Cfg::c8(); - cfg.bg_blur = false; + cfg.bg_blur = 0.0; cfg.zoom = false; cfg.layout_anim = false; cfg.cursor = true; diff --git a/crates/compositor/src/compositor_macos.rs b/crates/compositor/src/compositor_macos.rs index c6b1c240c..7973f6c9a 100644 --- a/crates/compositor/src/compositor_macos.rs +++ b/crates/compositor/src/compositor_macos.rs @@ -1228,21 +1228,31 @@ impl Compositor { Ok(()) } - /// Dual-Kawase sur le contenu courant du RT : trois passes DOWN puis trois UP, la - /// dernière réécrivant le RT. Port des six `fs_pass` de `compositor_windows::blur_bg`, - /// mêmes tailles et mêmes texels. - unsafe fn blur_bg(&self, cmd: &metal::CommandBufferRef) -> Result<()> { - let off = 2.2; // spread par passe + /// Dual-Kawase sur le contenu courant du RT : `levels` passes DOWN puis autant d'UP, la + /// dernière réécrivant le RT. Port de `compositor_windows::blur_bg`, mêmes tailles, mêmes + /// texels, même force → niveaux (`background_blur_steps`). + unsafe fn blur_bg(&self, cmd: &metal::CommandBufferRef, amount: f32) -> Result<()> { + let Some((levels, off)) = crate::frame_geometry::background_blur_steps(amount) else { + return Ok(()); + }; let (rw, rh) = (self.render_w as f32, self.render_h as f32); - let (hw, hh) = (rw * 0.5, rh * 0.5); - // DOWN : texel = 1/(dims de la SOURCE échantillonnée) - self.fs_pass(cmd, &self.blur_half, &self.rt, &self.pipeline_kdown, [1.0 / rw, 1.0 / rh, off, 0.0])?; - self.fs_pass(cmd, &self.blur_quarter, &self.blur_half, &self.pipeline_kdown, [1.0 / hw, 1.0 / hh, off, 0.0])?; - self.fs_pass(cmd, &self.blur_eighth, &self.blur_quarter, &self.pipeline_kdown, [2.0 / hw, 2.0 / hh, off, 0.0])?; - // UP - self.fs_pass(cmd, &self.blur_quarter, &self.blur_eighth, &self.pipeline_kup, [4.0 / hw, 4.0 / hh, off, 0.0])?; - self.fs_pass(cmd, &self.blur_half, &self.blur_quarter, &self.pipeline_kup, [2.0 / hw, 2.0 / hh, off, 0.0])?; - self.fs_pass(cmd, &self.rt, &self.blur_half, &self.pipeline_kup, [1.0 / hw, 1.0 / hh, off, 0.0])?; + let pyramid = [ + (&self.rt, rw, rh), + (&self.blur_half, rw * 0.5, rh * 0.5), + (&self.blur_quarter, rw * 0.25, rh * 0.25), + (&self.blur_eighth, rw * 0.125, rh * 0.125), + ]; + // texel = 1/(dims de la SOURCE échantillonnée) + for i in 0..levels { + let (src, sw, sh) = pyramid[i]; + let (dst, ..) = pyramid[i + 1]; + self.fs_pass(cmd, dst, src, &self.pipeline_kdown, [1.0 / sw, 1.0 / sh, off, 0.0])?; + } + for i in (0..levels).rev() { + let (src, sw, sh) = pyramid[i + 1]; + let (dst, ..) = pyramid[i]; + self.fs_pass(cmd, dst, src, &self.pipeline_kup, [1.0 / sw, 1.0 / sh, off, 0.0])?; + } Ok(()) } @@ -2278,8 +2288,8 @@ impl Compositor { // de dessiner, pas la vidéo. No-op visuel sur une couleur plate, effet réel sur un // gradient ou une image. Il lui faut ses propres passes, d'où la coupure ici. enc.end_encoding(); - if scene_ref.as_ref().map(|s| s.effects.blur).unwrap_or(false) { - self.blur_bg(cmd_buf)?; + if let Some(blur) = scene_ref.as_ref().map(|s| s.effects.blur) { + self.blur_bg(cmd_buf, blur)?; } // --- écran : ombre puis vidéo --- let s_px = [g.s_dst[2] * rw, g.s_dst[3] * rh]; @@ -3242,7 +3252,7 @@ mod tests { let screen = FakeFrame::new(128, 128, |_, _| 126); let webcam = FakeFrame::new(64, 64, |_, _| Y_WHITE); let mut cfg = crate::config::Cfg::c8(); - cfg.bg_blur = false; + cfg.bg_blur = 0.0; cfg.zoom = false; cfg.layout_anim = false; cfg.cursor = false; @@ -3863,7 +3873,7 @@ mod tests { comp.set_cursor_time(Some(2.0)); comp.set_timeline_time(Some(2.0)); let mut cfg = crate::config::Cfg::c8(); - cfg.bg_blur = false; + cfg.bg_blur = 0.0; cfg.zoom = false; cfg.layout_anim = false; cfg.cursor = true; diff --git a/crates/compositor/src/compositor_windows.rs b/crates/compositor/src/compositor_windows.rs index ff2543212..6a50ff781 100644 --- a/crates/compositor/src/compositor_windows.rs +++ b/crates/compositor/src/compositor_windows.rs @@ -900,32 +900,37 @@ impl Compositor { } /// Fond flouté (§7), dual-Kawase : suppose le screen déjà dessiné plein écran dans le RT. - /// Chaîne down (RT→960→480→240) puis up (240→480→960→RT). ~6 passes de 5-8 taps - /// à résolution décroissante, vs 2 passes gaussiennes 49-tap. Le RT devient le fond. - pub unsafe fn blur_bg(&self, _sigma: f32) { - let off = 2.2; // spread par passe + /// Chaîne down (RT→960→480→240) puis up (240→480→960→RT), tronquée au nombre de niveaux + /// que demande la force (`background_blur_steps`). ~5-8 taps par passe à résolution + /// décroissante, vs 2 passes gaussiennes 49-tap. Le RT devient le fond. + pub unsafe fn blur_bg(&self, amount: f32) { + let Some((levels, off)) = crate::frame_geometry::background_blur_steps(amount) else { + return; + }; // La pyramide se dérive de la taille de rendu, pas d'une constante : sinon // le rayon effectif du flou changerait avec la résolution de sortie (un // demi de 1080 n'est pas un demi de 2160), et le fond flouté ne serait plus // le même effet d'un format à l'autre. let (rw_i, rh_i) = self.render_dims(); let (half_w, half_h) = (rw_i / 2, rh_i / 2); - let hw = half_w as f32; - let hh = half_h as f32; - // DOWN : texel = 1/(dims de la SOURCE échantillonnée) - self.fs_pass(&self.half_a_rtv, &self.rt_srv, &self.ps_kdown, half_w, half_h, - [1.0 / self.rw(), 1.0 / self.rh(), off, 0.0]); - self.fs_pass(&self.q_rtv, &self.half_a_srv, &self.ps_kdown, half_w / 2, half_h / 2, - [1.0 / hw, 1.0 / hh, off, 0.0]); - self.fs_pass(&self.e_rtv, &self.q_srv, &self.ps_kdown, half_w / 4, half_h / 4, - [2.0 / hw, 2.0 / hh, off, 0.0]); - // UP - self.fs_pass(&self.q_rtv, &self.e_srv, &self.ps_kup, half_w / 2, half_h / 2, - [4.0 / hw, 4.0 / hh, off, 0.0]); - self.fs_pass(&self.half_a_rtv, &self.q_srv, &self.ps_kup, half_w, half_h, - [2.0 / hw, 2.0 / hh, off, 0.0]); - self.fs_pass(&self.rtv, &self.half_a_srv, &self.ps_kup, rw_i, rh_i, - [1.0 / hw, 1.0 / hh, off, 0.0]); + let pyramid = [ + (&self.rtv, &self.rt_srv, rw_i, rh_i), + (&self.half_a_rtv, &self.half_a_srv, half_w, half_h), + (&self.q_rtv, &self.q_srv, half_w / 2, half_h / 2), + (&self.e_rtv, &self.e_srv, half_w / 4, half_h / 4), + ]; + // texel = 1/(dims de la SOURCE échantillonnée) + let fx = |(w, h): (u32, u32)| [1.0 / w.max(1) as f32, 1.0 / h.max(1) as f32, off, 0.0]; + for i in 0..levels { + let (_, src, sw, sh) = pyramid[i]; + let (dst, _, dw, dh) = pyramid[i + 1]; + self.fs_pass(dst, src, &self.ps_kdown, dw, dh, fx((sw, sh))); + } + for i in (0..levels).rev() { + let (_, src, sw, sh) = pyramid[i + 1]; + let (dst, _, dw, dh) = pyramid[i]; + self.fs_pass(dst, src, &self.ps_kup, dw, dh, fx((sw, sh))); + } } /// Remplit la pyramide de profondeur de champ depuis la frame écran et rend sa SRV. @@ -1966,11 +1971,11 @@ impl Compositor { // « Blur BG » (parité web blurredBackgroundLayer) : floute CE wallpaper qu'on vient // de dessiner (dual-Kawase, déjà utilisé pour le fond fixture ci-dessous). No-op // visuel sur une couleur plate, effet réel sur gradient/image. - if blur_wallpaper { - self.blur_bg(18.0); + if blur_wallpaper > 0.0 { + self.blur_bg(blur_wallpaper); self.bind_compose_state(); } - } else if cfg.bg_blur { + } else if cfg.bg_blur > 0.0 { let over = 0.06; self.draw_video( &LayerCB { @@ -1984,7 +1989,7 @@ impl Compositor { &sy, &suv, ); - self.blur_bg(18.0); + self.blur_bg(cfg.bg_blur); self.bind_compose_state(); self.draw_solid(&LayerCB { dst: [0.0, 0.0, 1.0, 1.0], diff --git a/crates/compositor/src/config.rs b/crates/compositor/src/config.rs index 9e74a914e..aad7073a0 100644 --- a/crates/compositor/src/config.rs +++ b/crates/compositor/src/config.rs @@ -7,7 +7,7 @@ pub struct Cfg { pub composite: bool, // C1+ : composite 2 sources (sinon décode+encode seul = C0) pub rounded: bool, // C2+ : coins arrondis (SDF) pub shadow: bool, // C3+ : ombres portées - pub bg_blur: bool, // C4+ : fond flouté (gaussien séparable) + pub bg_blur: f32, // C4+ : fond flouté, force 0..1 (0 = off) pub zoom: bool, // C5+ : zoom animé pub layout_anim: bool, // C6+ : animation de layout A<->B pub cursor: bool, // C7+ : curseur custom + click bounce @@ -40,7 +40,7 @@ pub fn all() -> Vec { composite: false, rounded: false, shadow: false, - bg_blur: false, + bg_blur: 0.0, zoom: false, layout_anim: false, cursor: false, @@ -50,7 +50,7 @@ pub fn all() -> Vec { let c1 = Cfg { name: "C1", composite: true, desc: "+ fond, layout, 2 sources (E1)", ..base.clone() }; let c2 = Cfg { name: "C2", rounded: true, desc: "+ coins arrondis (E2)", ..c1.clone() }; let c3 = Cfg { name: "C3", shadow: true, desc: "+ ombres portées (E4)", ..c2.clone() }; - let c4 = Cfg { name: "C4", bg_blur: true, desc: "+ fond flouté (E3)", ..c3.clone() }; + let c4 = Cfg { name: "C4", bg_blur: 0.5, desc: "+ fond flouté (E3)", ..c3.clone() }; let c5 = Cfg { name: "C5", zoom: true, desc: "+ zoom animé", ..c4.clone() }; let c6 = Cfg { name: "C6", layout_anim: true, desc: "+ animation de layout", ..c5.clone() }; let c7 = Cfg { name: "C7", cursor: true, desc: "+ curseur custom (bounce)", ..c6.clone() }; diff --git a/crates/compositor/src/frame_geometry.rs b/crates/compositor/src/frame_geometry.rs index 79e5f210b..bdf91a31c 100644 --- a/crates/compositor/src/frame_geometry.rs +++ b/crates/compositor/src/frame_geometry.rs @@ -405,6 +405,26 @@ pub fn dof_pyramid_levels(w: u32, h: u32) -> u32 { const DOF_PYRAMID_LEVELS: u32 = 5; DOF_PYRAMID_LEVELS.min(32 - w.max(h).max(1).leading_zeros()) } +/// Flou du fond : force 0..1 → (niveaux de la pyramide dual-Kawase, écart par passe), `None` +/// à 0. Le rayon d'un dual-Kawase croît comme `2^niveaux × écart` : on vise un rayon +/// proportionnel à la force et on prend le moins de niveaux qui l'atteint sans écarter les taps +/// au point de dédoubler l'image. 0,5 retombe exactement sur l'ancien interrupteur « Blur BG » +/// (3 niveaux, écart 2,2). Les trois backends lisent ceci, ils floutent donc pareil. +pub fn background_blur_steps(amount: f32) -> Option<(usize, f32)> { + const RADIUS_AT_FULL: f32 = 2.0 * 8.0 * 2.2; + if !(amount > 0.001) { + return None; + } + let radius = amount.min(1.0) * RADIUS_AT_FULL; + let levels: usize = if radius <= 4.0 { + 1 + } else if radius <= 12.0 { + 2 + } else { + 3 + }; + Some((levels, radius / (1 << levels) as f32)) +} /// Longueurs de style exprimées en FRACTION du petit côté du cadre, et non en pixels. /// /// Elles étaient écrites en px bruts au point d'appel, ce qui voulait dire « px du render @@ -4252,6 +4272,24 @@ pub fn lru_evictions(entries: &[(String, u64, u64)], budget: u64, protect_from: mod tests { use super::lru_evictions; + #[test] + fn background_blur_steps_off_at_zero_and_old_switch_at_half() { + use super::background_blur_steps; + assert_eq!(background_blur_steps(0.0), None); + let (levels, off) = background_blur_steps(0.5).unwrap(); + assert_eq!(levels, 3); + assert!((off - 2.2).abs() < 1e-5); + // Le rayon (2^niveaux × écart) croît avec la force, sans saut en arrière d'un niveau à + // l'autre. + let radius = |a: f32| background_blur_steps(a).map_or(0.0, |(l, o)| (1 << l) as f32 * o); + let mut prev = 0.0; + for i in 1..=100 { + let r = radius(i as f32 / 100.0); + assert!(r > prev, "rayon non croissant à {i}%"); + prev = r; + } + } + /// `(clé, octets, tick)` — le tick croît avec l'usage, donc le plus petit est le plus ancien. fn e(key: &str, mb: u64, tick: u64) -> (String, u64, u64) { (key.to_string(), mb * 1024 * 1024, tick) diff --git a/crates/compositor/src/live.rs b/crates/compositor/src/live.rs index 7254983b0..ebcb46388 100644 --- a/crates/compositor/src/live.rs +++ b/crates/compositor/src/live.rs @@ -639,7 +639,7 @@ pub(crate) fn consume_acc(acc: f64, before: f64, after: f64) -> f64 { /// booléens/taps → reconstruits dans le `Cfg` ; valeurs continues → `set_live_params`. #[derive(Clone, Copy, PartialEq)] struct InspectorParams { - bg_blur: bool, + bg_blur: f32, bg_color: [f32; 4], shadow_scale: f32, radius_scale: f32, @@ -665,7 +665,7 @@ struct InspectorParams { impl Default for InspectorParams { fn default() -> Self { Self { - bg_blur: false, + bg_blur: 0.0, bg_color: [0.10, 0.11, 0.14, 1.0], shadow_scale: 1.0, radius_scale: 1.0, @@ -933,7 +933,6 @@ impl LiveView { pub fn set_param_bool(&self, key: &str, value: bool) { if let Ok(mut p) = self.shared.inspector.lock() { match key { - "backgroundBlur" => p.bg_blur = value, "webcamMirror" => p.webcam_mirror = value, "cursorShow" => p.cursor_show = value, "cursorAutoHide" => p.cursor_auto_hide = value, @@ -945,6 +944,7 @@ impl LiveView { /// Slider inspector (numérique). Conventions : `shadow`/`roundness`/`webcamSize`/ /// `cursorSize`/`cursorClickBounce` = échelle (1 = défaut) ; `padding` = 0..1 ; + /// `backgroundBlur` = force 0..1 du flou du fond (0 = net) ; /// `motionBlur` = 0..1 mappé sur 1..16 taps. pub fn set_param_num(&self, key: &str, value: f64) { if let Ok(mut p) = self.shared.inspector.lock() { @@ -954,6 +954,7 @@ impl LiveView { "roundness" => p.radius_scale = v.max(0.0), "motionBlur" => p.mblur_taps = (1.0 + value.clamp(0.0, 1.0) * 15.0).round() as u32, "padding" => p.padding = v.clamp(0.0, 1.0), + "backgroundBlur" => p.bg_blur = v.clamp(0.0, 1.0), "webcamSize" => p.webcam_size_scale = v.max(0.05), "cursorSize" => p.cursor_size_scale = v.max(0.0), "cursorClickBounce" => p.cursor_bounce_scale = v.max(0.0), @@ -1927,7 +1928,7 @@ pub fn run_standalone(screen: &str, webcam: &str, cursor_json: &str) -> Result<( 0x42 => { // 'B' : bascule le fond flouté via set_param — chemin param → D3D blur = !blur; - view.set_param_bool("backgroundBlur", blur); + view.set_param_num("backgroundBlur", if blur { 0.5 } else { 0.0 }); set_title(blur, playing); } 0x20 => { diff --git a/crates/compositor/src/scene.rs b/crates/compositor/src/scene.rs index 1130b0250..de24278fd 100644 --- a/crates/compositor/src/scene.rs +++ b/crates/compositor/src/scene.rs @@ -168,13 +168,29 @@ pub struct SceneRect { pub height: f32, } +fn blur_amount<'de, D: serde::Deserializer<'de>>(d: D) -> Result { + #[derive(Deserialize)] + #[serde(untagged)] + enum Blur { + Switch(bool), + Amount(f32), + } + Ok(match Blur::deserialize(d)? { + Blur::Switch(on) => if on { 0.5 } else { 0.0 }, + Blur::Amount(a) => a.clamp(0.0, 1.0), + }) +} + /// Effets de cadre (padding, blur, ombre, coins, motion blur). #[derive(Debug, Clone, Copy, Deserialize)] #[serde(rename_all = "camelCase")] pub struct SceneEffects { /// 0..1 inset supplémentaire de l'écran. pub padding: f32, - pub blur: bool, + /// 0..1 force du flou du fond, 0 = net (`background_blur_steps`). Un booléen se lit encore : + /// `true` était l'unique force de l'ancien interrupteur, 0,5 aujourd'hui. + #[serde(deserialize_with = "blur_amount")] + pub blur: f32, /// 0..1 force de l'ombre. pub shadow: f32, /// Slider Roundness, en FRACTION du petit côté du cadre de sortie. @@ -820,6 +836,13 @@ mod tests { assert_eq!(scene.layout.preset, "picture-in-picture"); assert!(scene.layout.webcam_mirror); assert!((scene.effects.roundness_frac - 0.0222).abs() < 1e-6); + // L'ancien interrupteur `true` se lit comme la force qu'il dessinait. + assert_eq!(scene.effects.blur, 0.5); + let amount: SceneEffects = serde_json::from_str( + r#"{"padding":0,"blur":0.3,"shadow":0,"roundnessFrac":0,"motionBlur":0}"#, + ) + .unwrap(); + assert_eq!(amount.blur, 0.3); match scene.background { SceneBackground::Gradient { angle_deg, ref stops, ref offsets, motion } => { assert_eq!(angle_deg, 135.0); diff --git a/electron/ai-edition/style-preset-service.test.ts b/electron/ai-edition/style-preset-service.test.ts index f732f538a..0f2e430f7 100644 --- a/electron/ai-edition/style-preset-service.test.ts +++ b/electron/ai-edition/style-preset-service.test.ts @@ -12,7 +12,7 @@ const APPEARANCE: StylePresetAppearance = { frameTheme: "light", aspectRatio: "16:9", shadowIntensity: 0.2, - showBlur: false, + backgroundBlur: 0, motionBlurAmount: 0.2, depthOfField: true, borderRadius: 40, diff --git a/electron/ipc/nativeBridge.presets.test.ts b/electron/ipc/nativeBridge.presets.test.ts index a048e877b..bd8a7f8b9 100644 --- a/electron/ipc/nativeBridge.presets.test.ts +++ b/electron/ipc/nativeBridge.presets.test.ts @@ -32,7 +32,7 @@ const APPEARANCE: StylePresetAppearance = { frameTheme: "light", aspectRatio: "16:9", shadowIntensity: 0.2, - showBlur: false, + backgroundBlur: 0, motionBlurAmount: 0.2, depthOfField: true, borderRadius: 40, diff --git a/src/components/ai-edition/RightPanes.tsx b/src/components/ai-edition/RightPanes.tsx index 8a92d2cc7..8af3ed28e 100644 --- a/src/components/ai-edition/RightPanes.tsx +++ b/src/components/ai-edition/RightPanes.tsx @@ -422,17 +422,22 @@ function BackgroundSection() { {/* Reads in the order it acts: pick a background, then blur it. Lived under "Effects" while that was a separate facet, which is how a control named "Blur BG" ended up in the tab that doesn't say background. */} -
- {ts("effects.blurBg")} - + { - void set({ showBlur: v }); + setLive({ backgroundBlur: v / 100 }); if (isNativeCompositorActive()) { - setNativeParam("backgroundBlur", v); + setNativeParam("backgroundBlur", v / 100); } }} + onCommit={() => void commit()} />
diff --git a/src/components/ai-edition/WebcamOverlay.test.tsx b/src/components/ai-edition/WebcamOverlay.test.tsx index 8e10dc352..d00f017ab 100644 --- a/src/components/ai-edition/WebcamOverlay.test.tsx +++ b/src/components/ai-edition/WebcamOverlay.test.tsx @@ -96,7 +96,7 @@ const baseLegacyEditor = { shadowIntensity: 0, borderRadius: 0, padding: 0, - showBlur: false, + backgroundBlur: 0, motionBlurAmount: 0, webcamSizePreset: 25, webcamMaskShape: "rectangle", diff --git a/src/components/video-editor/editorDefaults.ts b/src/components/video-editor/editorDefaults.ts index 4c3a2589a..4b2749887 100644 --- a/src/components/video-editor/editorDefaults.ts +++ b/src/components/video-editor/editorDefaults.ts @@ -34,7 +34,7 @@ export const DEFAULT_GIF_OUTPUT_DIMENSIONS = { export const DEFAULT_EDITOR_APPEARANCE_SETTINGS: { shadowIntensity: number; - showBlur: boolean; + backgroundBlur: number; motionBlurAmount: number; borderRadius: number; } = { @@ -42,7 +42,7 @@ export const DEFAULT_EDITOR_APPEARANCE_SETTINGS: { // this is what the CLI and a v2 project file fall back to, and a copy kept "in sync" by hand // was one change away from rendering a different look there. shadowIntensity: DEFAULT_PROJECT_APPEARANCE.shadowIntensity, - showBlur: DEFAULT_PROJECT_APPEARANCE.showBlur, + backgroundBlur: DEFAULT_PROJECT_APPEARANCE.backgroundBlur, motionBlurAmount: DEFAULT_PROJECT_APPEARANCE.motionBlurAmount, borderRadius: DEFAULT_PROJECT_APPEARANCE.borderRadius, }; diff --git a/src/components/video-editor/projectPersistence.test.ts b/src/components/video-editor/projectPersistence.test.ts index f26edec23..f4ed230a6 100644 --- a/src/components/video-editor/projectPersistence.test.ts +++ b/src/components/video-editor/projectPersistence.test.ts @@ -38,7 +38,7 @@ describe("projectPersistence media compatibility", () => { wallpaper: "/wallpapers/wallpaper1.jpg", wallpaperMotion: "none", shadowIntensity: 0, - showBlur: false, + backgroundBlur: 0, motionBlurAmount: 0, depthOfField: true, borderRadius: 0, @@ -250,7 +250,7 @@ it("creates stable snapshots for identical project state", () => { const editor = normalizeProjectEditor({ wallpaper: "/wallpapers/wallpaper1.jpg", shadowIntensity: 0, - showBlur: false, + backgroundBlur: 0, motionBlurAmount: 0, depthOfField: true, borderRadius: 0, diff --git a/src/components/video-editor/projectPersistence.ts b/src/components/video-editor/projectPersistence.ts index 6347822b7..a533be7ab 100644 --- a/src/components/video-editor/projectPersistence.ts +++ b/src/components/video-editor/projectPersistence.ts @@ -11,6 +11,7 @@ import { type FrameTheme, isFrameTheme, type RecordingFrame, + readBackgroundBlur, readRecordingFrame, WEBCAM_SIZE_MAX, WEBCAM_SIZE_MIN, @@ -84,7 +85,7 @@ export interface ProjectEditorState { wallpaper: string; wallpaperMotion: WallpaperMotion; shadowIntensity: number; - showBlur: boolean; + backgroundBlur: number; motionBlurAmount: number; depthOfField: boolean; borderRadius: number; @@ -533,10 +534,7 @@ export function normalizeProjectEditor(editor: Partial): Pro typeof editor.shadowIntensity === "number" ? editor.shadowIntensity : DEFAULT_EDITOR_APPEARANCE_SETTINGS.shadowIntensity, - showBlur: - typeof editor.showBlur === "boolean" - ? editor.showBlur - : DEFAULT_EDITOR_APPEARANCE_SETTINGS.showBlur, + backgroundBlur: readBackgroundBlur(editor, DEFAULT_EDITOR_APPEARANCE_SETTINGS.backgroundBlur), motionBlurAmount: isFiniteNumber(editor.motionBlurAmount) ? clamp(editor.motionBlurAmount, 0, 1) : typeof (editor as { motionBlurEnabled?: unknown }).motionBlurEnabled === "boolean" diff --git a/src/hooks/useEditorHistory.ts b/src/hooks/useEditorHistory.ts index ce8c9e9c7..8dab584ee 100644 --- a/src/hooks/useEditorHistory.ts +++ b/src/hooks/useEditorHistory.ts @@ -39,7 +39,7 @@ export interface EditorState { cropRegion: CropRegion; wallpaper: string; shadowIntensity: number; - showBlur: boolean; + backgroundBlur: number; motionBlurAmount: number; borderRadius: number; padding: number; @@ -63,7 +63,7 @@ export const INITIAL_EDITOR_STATE: EditorState = { cropRegion: DEFAULT_CROP_REGION, wallpaper: DEFAULT_EDITOR_LAYOUT_SETTINGS.wallpaper, shadowIntensity: DEFAULT_EDITOR_APPEARANCE_SETTINGS.shadowIntensity, - showBlur: DEFAULT_EDITOR_APPEARANCE_SETTINGS.showBlur, + backgroundBlur: DEFAULT_EDITOR_APPEARANCE_SETTINGS.backgroundBlur, motionBlurAmount: DEFAULT_EDITOR_APPEARANCE_SETTINGS.motionBlurAmount, borderRadius: DEFAULT_EDITOR_APPEARANCE_SETTINGS.borderRadius, padding: DEFAULT_EDITOR_LAYOUT_SETTINGS.padding, diff --git a/src/lib/ai-edition/document/migrate.test.ts b/src/lib/ai-edition/document/migrate.test.ts index 4d97909c5..385f11a2c 100644 --- a/src/lib/ai-edition/document/migrate.test.ts +++ b/src/lib/ai-edition/document/migrate.test.ts @@ -16,7 +16,7 @@ function makeV2Project(overrides: Partial = {}): EditorProjec wallpaper: "/wallpapers/wallpaper1.jpg", wallpaperMotion: "none", shadowIntensity: 0, - showBlur: false, + backgroundBlur: 0, motionBlurAmount: 0, depthOfField: true, borderRadius: 0, @@ -354,6 +354,16 @@ describe("migrateAxcutDocumentToProjectData", () => { expect(back.editor.webcamMaskShape).toBe("circle"); }); + it("reads a legacyEditor blur switch as the amount it drew", () => { + const doc = migrateProjectDataToAxcutDocument(makeV2Project()); + const { backgroundBlur: _amount, ...legacy } = doc.legacyEditor as Record; + const back = migrateAxcutDocumentToProjectData({ + ...doc, + legacyEditor: { ...legacy, showBlur: true }, + }); + expect(back.editor.backgroundBlur).toBe(0.5); + }); + it("defaults wallpaperMotion to none when legacyEditor lacks it", () => { const doc = migrateProjectDataToAxcutDocument(makeV2Project()); const { wallpaperMotion: _omitted, ...legacy } = doc.legacyEditor as Record; diff --git a/src/lib/ai-edition/document/migrate.ts b/src/lib/ai-edition/document/migrate.ts index d0f7f2ea6..8a387375e 100644 --- a/src/lib/ai-edition/document/migrate.ts +++ b/src/lib/ai-edition/document/migrate.ts @@ -21,6 +21,7 @@ import type { TrimRegion, ZoomRegion, } from "@/components/video-editor/types"; +import { readBackgroundBlur } from "@/lib/projectDefaults"; import type { ProjectMedia } from "@/lib/recordingSession"; import { type AxcutAnnotationRegion, @@ -318,7 +319,7 @@ export function migrateAxcutDocumentToProjectData(input: AxcutDocument): EditorP wallpaper: "", wallpaperMotion: "none", shadowIntensity: 0, - showBlur: false, + backgroundBlur: 0, motionBlurAmount: 0, depthOfField: true, borderRadius: 0, @@ -349,6 +350,9 @@ export function migrateAxcutDocumentToProjectData(input: AxcutDocument): EditorP const legacy = document.legacyEditor; if (legacy && typeof legacy === "object") { Object.assign(editor, legacy); + // A document from before the amount holds only the `showBlur` switch; the default 0 + // above would otherwise win over it once the project is read back. + editor.backgroundBlur = readBackgroundBlur(legacy, 0); } const reverseZoomRegions: ZoomRegion[] = (document.zoomRanges ?? []).map((region) => ({ diff --git a/src/lib/ai-edition/store/editorSettings.test.ts b/src/lib/ai-edition/store/editorSettings.test.ts index b31ca9e93..fb1382898 100644 --- a/src/lib/ai-edition/store/editorSettings.test.ts +++ b/src/lib/ai-edition/store/editorSettings.test.ts @@ -45,7 +45,7 @@ describe("getEditorSettings", () => { // A new project stores no ratio and reads Auto; older documents had 16:9 pinned by v8. expect(snap.aspectRatio).toBe("auto"); expect(snap.shadowIntensity).toBe(DEFAULT_EDITOR_SETTINGS.shadowIntensity); - expect(snap.showBlur).toBe(false); + expect(snap.backgroundBlur).toBe(0); expect(snap.webcamLayoutPreset).toBe(DEFAULT_WEBCAM_LAYOUT_PRESET); expect(snap.webcamMaskShape).toBe(DEFAULT_WEBCAM_MASK_SHAPE); expect(snap.cursor.size).toBe(DEFAULT_CURSOR_SIZE); @@ -85,7 +85,7 @@ describe("getEditorSettings", () => { wallpaper: "linear-gradient(red, blue)", aspectRatio: "9:16", shadowIntensity: 0.5, - showBlur: true, + backgroundBlur: 0.5, webcamLayoutPreset: "side-by-side", webcamMaskShape: "circle", cursorSize: 2.5, @@ -96,7 +96,7 @@ describe("getEditorSettings", () => { expect(snap.wallpaper).toBe("linear-gradient(red, blue)"); expect(snap.aspectRatio).toBe("9:16"); expect(snap.shadowIntensity).toBe(0.5); - expect(snap.showBlur).toBe(true); + expect(snap.backgroundBlur).toBe(0.5); expect(snap.webcamLayoutPreset).toBe("side-by-side"); // An old circle: a square camera, fully round. expect(snap.webcamMaskShape).toBe("square"); @@ -108,10 +108,22 @@ describe("getEditorSettings", () => { it("falls back to defaults for unknown or wrong-type values", () => { const doc: AxcutDocument = { ...baseDoc, - legacyEditor: { showBlur: "not-a-bool" as unknown as boolean }, + legacyEditor: { backgroundBlur: "not-a-number" as unknown as number }, }; const snap = getEditorSettings(doc); - expect(snap.showBlur).toBe(false); + expect(snap.backgroundBlur).toBe(0); + }); + + it("reads the old background blur switch as the amount it drew", () => { + const legacy = (showBlur: boolean): AxcutDocument => ({ + ...baseDoc, + legacyEditor: { showBlur }, + }); + expect(getEditorSettings(legacy(true)).backgroundBlur).toBe(0.5); + expect(getEditorSettings(legacy(false)).backgroundBlur).toBe(0); + // Once the amount is written, the stale switch no longer speaks. + const moved = patchEditorSettings(legacy(true), { backgroundBlur: 0.2 }); + expect(getEditorSettings(moved).backgroundBlur).toBe(0.2); }); it("keeps depth of field on unless the project stored a boolean off", () => { @@ -136,26 +148,26 @@ describe("getEditorSettings", () => { describe("patchEditorSettings", () => { it("writes a single field and leaves others intact", () => { - const next = patchEditorSettings(baseDoc, { showBlur: true }); + const next = patchEditorSettings(baseDoc, { backgroundBlur: 0.5 }); const snap = getEditorSettings(next); - expect(snap.showBlur).toBe(true); + expect(snap.backgroundBlur).toBe(0.5); expect(snap.shadowIntensity).toBe(DEFAULT_EDITOR_SETTINGS.shadowIntensity); expect(snap.cropRegion).toEqual(DEFAULT_CROP_REGION); }); it("merges into an existing legacyEditor envelope", () => { - const seed = patchEditorSettings(baseDoc, { showBlur: true }); + const seed = patchEditorSettings(baseDoc, { backgroundBlur: 0.5 }); const next = patchEditorSettings(seed, { shadowIntensity: 0.7 }); const snap = getEditorSettings(next); - expect(snap.showBlur).toBe(true); + expect(snap.backgroundBlur).toBe(0.5); expect(snap.shadowIntensity).toBe(0.7); }); it("treats an explicitly undefined key as absent, not as a clear", () => { - const seed = patchEditorSettings(baseDoc, { showBlur: true, shadowIntensity: 0.7 }); - const next = patchEditorSettings(seed, { showBlur: undefined, padding: 12 }); + const seed = patchEditorSettings(baseDoc, { backgroundBlur: 0.5, shadowIntensity: 0.7 }); + const next = patchEditorSettings(seed, { backgroundBlur: undefined, padding: 12 }); const snap = getEditorSettings(next); - expect(snap.showBlur).toBe(true); + expect(snap.backgroundBlur).toBe(0.5); expect(snap.shadowIntensity).toBe(0.7); expect(snap.padding).toBe(12); }); @@ -213,7 +225,7 @@ describe("patchEditorSettings", () => { it("does not mutate the source document", () => { const before = getEditorSettings(baseDoc); - patchEditorSettings(baseDoc, { showBlur: true }); + patchEditorSettings(baseDoc, { backgroundBlur: 0.5 }); const after = getEditorSettings(baseDoc); expect(after).toEqual(before); }); diff --git a/src/lib/ai-edition/store/editorSettings.ts b/src/lib/ai-edition/store/editorSettings.ts index f8298e295..7a6b209bf 100644 --- a/src/lib/ai-edition/store/editorSettings.ts +++ b/src/lib/ai-edition/store/editorSettings.ts @@ -27,6 +27,7 @@ import { type FrameTheme, isFrameTheme, type RecordingFrame, + readBackgroundBlur, readBounded, readRecordingFrame, readWebcamAnchor, @@ -101,7 +102,7 @@ export interface EditorSettingsSnapshot { */ formatFollowCursor: boolean | null; shadowIntensity: number; - showBlur: boolean; + backgroundBlur: number; motionBlurAmount: number; depthOfField: boolean; borderRadius: number; @@ -155,6 +156,8 @@ interface LegacyShape { aspectRatio?: AspectRatio; formatFollowCursor?: boolean; shadowIntensity?: number; + backgroundBlur?: number; + /** Before the amount: an on/off switch. Read by `readBackgroundBlur`, never written. */ showBlur?: boolean; motionBlurAmount?: number; depthOfField?: boolean; @@ -275,7 +278,7 @@ export function getEditorSettings(doc: AxcutDocument | null | undefined): Editor "shadowIntensity", defaults.shadowIntensity, ), - showBlur: bool(legacy?.showBlur, DEFAULT_EDITOR_SETTINGS.showBlur), + backgroundBlur: readBackgroundBlur(legacy, defaults.backgroundBlur), motionBlurAmount: readBounded( legacy?.motionBlurAmount, "motionBlurAmount", @@ -337,7 +340,7 @@ export interface EditorSettingsPatch { aspectRatio?: AspectRatio; formatFollowCursor?: boolean; shadowIntensity?: number; - showBlur?: boolean; + backgroundBlur?: number; motionBlurAmount?: number; depthOfField?: boolean; borderRadius?: number; diff --git a/src/lib/ai-edition/stylePresets.test.ts b/src/lib/ai-edition/stylePresets.test.ts index 0bbade597..503718860 100644 --- a/src/lib/ai-edition/stylePresets.test.ts +++ b/src/lib/ai-edition/stylePresets.test.ts @@ -22,7 +22,7 @@ function appearance(overrides: Partial = {}): StylePreset frameTheme: "dark", aspectRatio: "16:9", shadowIntensity: 0.2, - showBlur: false, + backgroundBlur: 0, motionBlurAmount: 0.2, depthOfField: true, borderRadius: 40, @@ -110,12 +110,18 @@ describe("parseStylePresetAppearance", () => { expect([cursor.size, cursor.clickBounce]).toEqual([6, 2]); }); + it("reads a preset saved with the old background blur switch", () => { + const { backgroundBlur: _amount, ...rest } = appearance(); + expect(parseStylePresetAppearance({ ...rest, showBlur: true }).backgroundBlur).toBe(0.5); + expect(parseStylePresetAppearance({ ...rest, showBlur: false }).backgroundBlur).toBe(0); + }); + it("rejects non-numbers, wrong types and unknown enum values", () => { expect(() => parseStylePresetAppearance({ ...appearance(), shadowIntensity: Number.NaN }), ).toThrow(/shadowIntensity/); - expect(() => parseStylePresetAppearance({ ...appearance(), showBlur: "yes" })).toThrow( - /showBlur/, + expect(() => parseStylePresetAppearance({ ...appearance(), backgroundBlur: "yes" })).toThrow( + /backgroundBlur/, ); expect(() => parseStylePresetAppearance({ ...appearance(), webcamMaskShape: "star" })).toThrow( /webcamMaskShape/, diff --git a/src/lib/ai-edition/stylePresets.ts b/src/lib/ai-edition/stylePresets.ts index 2c9ea2a1f..f7cbe68e9 100644 --- a/src/lib/ai-edition/stylePresets.ts +++ b/src/lib/ai-edition/stylePresets.ts @@ -30,6 +30,7 @@ import { DEFAULT_PROJECT_APPEARANCE, type FrameTheme, isFrameTheme, + LEGACY_BACKGROUND_BLUR_ON, type RecordingFrame, readRecordingFrame, readWebcamMask, @@ -56,7 +57,7 @@ export interface StylePresetAppearance { frameTheme: FrameTheme; aspectRatio: AspectRatio; shadowIntensity: number; - showBlur: boolean; + backgroundBlur: number; motionBlurAmount: number; depthOfField: boolean; borderRadius: number; @@ -310,7 +311,13 @@ export function parseStylePresetAppearance(value: unknown): StylePresetAppearanc ...readFrame(value), aspectRatio: value.aspectRatio, shadowIntensity: readNumber(value, "shadowIntensity", "shadowIntensity"), - showBlur: readBoolean(value, "showBlur"), + // Presets saved before the amount carry the old `showBlur` switch instead. + backgroundBlur: + value.backgroundBlur === undefined && value.showBlur !== undefined + ? readBoolean(value, "showBlur") + ? LEGACY_BACKGROUND_BLUR_ON + : 0 + : readNumber(value, "backgroundBlur", "backgroundBlur"), motionBlurAmount: readNumber(value, "motionBlurAmount", "motionBlurAmount"), depthOfField: value.depthOfField === undefined ? true : readBoolean(value, "depthOfField"), borderRadius: readNumber(value, "borderRadius", "borderRadius"), @@ -406,7 +413,7 @@ export const LOOK_LEGACY_EDITOR_KEYS = [ "frame", "frameTheme", "shadowIntensity", - "showBlur", + "backgroundBlur", "motionBlurAmount", "depthOfField", "borderRadius", @@ -453,12 +460,17 @@ export function stylePresetLegacyEditor(appearance: StylePresetAppearance): Fiel * audio, format and everything else that belongs to that footage stay behind. */ export function lookFromLegacyEditor(legacyEditor: unknown): Fields { if (!isRecord(legacyEditor)) return {}; - return Object.fromEntries( + const look: Fields = Object.fromEntries( LOOK_LEGACY_EDITOR_KEYS.filter((key) => legacyEditor[key] !== undefined).map((key) => [ key, legacyEditor[key], ]), ); + // A project from before the amount holds only the old switch, under a key the list drops. + if (look.backgroundBlur === undefined && typeof legacyEditor.showBlur === "boolean") { + look.backgroundBlur = legacyEditor.showBlur ? LEGACY_BACKGROUND_BLUR_ON : 0; + } + return look; } /** Validates a parsed preset file (the JSON value, not the text). */ diff --git a/src/lib/ai-edition/stylePresetsEditor.test.ts b/src/lib/ai-edition/stylePresetsEditor.test.ts index 4f29e457f..0308dc39f 100644 --- a/src/lib/ai-edition/stylePresetsEditor.test.ts +++ b/src/lib/ai-edition/stylePresetsEditor.test.ts @@ -38,7 +38,7 @@ function styledSettings(): EditorSettingsSnapshot { frameTheme: "light", aspectRatio: "9:16", shadowIntensity: 0.8, - showBlur: true, + backgroundBlur: 0.5, motionBlurAmount: 0.6, borderRadius: 12, padding: 20, @@ -169,6 +169,12 @@ describe("new-project look (main-process side)", () => { expect(lookFromLegacyEditor(null)).toEqual({}); }); + it("carries the old background blur switch as the amount it drew", () => { + expect(lookFromLegacyEditor({ showBlur: true }).backgroundBlur).toBe(0.5); + expect(lookFromLegacyEditor({ showBlur: false }).backgroundBlur).toBe(0); + expect(lookFromLegacyEditor({ showBlur: true, backgroundBlur: 0.2 }).backgroundBlur).toBe(0.2); + }); + it("carries the old always-arrow switch of a project saved before the cursor kinds", () => { const look = lookFromLegacyEditor({ cursorAlwaysArrow: true, cropRegion: { x: 0.2 } }); expect(getEditorSettings(docWith(look)).cursor.asArrow).toEqual(CURSOR_KIND_IDS); diff --git a/src/lib/ai-edition/stylePresetsEditor.ts b/src/lib/ai-edition/stylePresetsEditor.ts index c8a2a217c..f15c27e8a 100644 --- a/src/lib/ai-edition/stylePresetsEditor.ts +++ b/src/lib/ai-edition/stylePresetsEditor.ts @@ -20,7 +20,7 @@ export function stylePresetAppearanceFromSettings( frameTheme: settings.frameTheme, aspectRatio: settings.aspectRatio, shadowIntensity: settings.shadowIntensity, - showBlur: settings.showBlur, + backgroundBlur: settings.backgroundBlur, motionBlurAmount: settings.motionBlurAmount, depthOfField: settings.depthOfField, borderRadius: settings.borderRadius, @@ -90,7 +90,7 @@ export function stylePresetPatch(appearance: StylePresetAppearance): EditorSetti frame: appearance.frame, frameTheme: appearance.frameTheme, shadowIntensity: appearance.shadowIntensity, - showBlur: appearance.showBlur, + backgroundBlur: appearance.backgroundBlur, motionBlurAmount: appearance.motionBlurAmount, depthOfField: appearance.depthOfField, borderRadius: appearance.borderRadius, diff --git a/src/lib/projectDefaults.ts b/src/lib/projectDefaults.ts index a016dba85..9e4b7699d 100644 --- a/src/lib/projectDefaults.ts +++ b/src/lib/projectDefaults.ts @@ -173,7 +173,8 @@ export interface ProjectAppearanceDefaults { frameTheme: FrameTheme; aspectRatio: `${number}:${number}` | "auto" | "native"; shadowIntensity: number; - showBlur: boolean; + /** 0 sharp to 1, how much the wallpaper behind the recording is blurred. */ + backgroundBlur: number; motionBlurAmount: number; /** Defocus a 3D-tilted screen by its depth; inert on flat zooms. */ depthOfField: boolean; @@ -219,7 +220,7 @@ export const DEFAULT_PROJECT_APPEARANCE: ProjectAppearanceDefaults = { // 0.2 peaked at 9% opacity: a shadow nobody could see. 0.6 is 27%, the card lifts off the // wallpaper without a halo. shadowIntensity: 0.6, - showBlur: false, + backgroundBlur: 0, motionBlurAmount: 0.2, // On: it only acts on tilted zooms, where the blur already scales with the real angle. depthOfField: true, @@ -267,6 +268,7 @@ export const DEFAULT_PROJECT_APPEARANCE: ProjectAppearanceDefaults = { */ export const SETTING_BOUNDS = { shadowIntensity: [0, 1], + backgroundBlur: [0, 1], motionBlurAmount: [0, 1], // `ROUNDNESS_SLIDER_MAX_PX` (src/native/paramUnits.ts); a test holds the two together. borderRadius: [0, 64], @@ -296,6 +298,23 @@ export function clampToBound(value: number, bound: SettingBound): number { return Math.min(max, Math.max(min, value)); } +/** + * The background blur was an on/off switch (`showBlur`) before it was an amount. On reads as the + * one amount it had: the compositor draws exactly the old blur at this value. + */ +export const LEGACY_BACKGROUND_BLUR_ON = 0.5; + +/** `backgroundBlur`, or the old `showBlur` switch it replaced when only that one is stored. */ +export function readBackgroundBlur( + stored: { backgroundBlur?: unknown; showBlur?: unknown } | null | undefined, + fallback: number, +): number { + if (stored?.backgroundBlur === undefined && typeof stored?.showBlur === "boolean") { + return stored.showBlur ? LEGACY_BACKGROUND_BLUR_ON : 0; + } + return readBounded(stored?.backgroundBlur, "backgroundBlur", fallback); +} + /** A stored value read into its bound: anything but a finite number reads as `fallback`. */ export function readBounded(value: unknown, bound: SettingBound, fallback: number): number { return typeof value === "number" && Number.isFinite(value) diff --git a/src/native/browserShim.presets.test.ts b/src/native/browserShim.presets.test.ts index d69a11368..481e57c2c 100644 --- a/src/native/browserShim.presets.test.ts +++ b/src/native/browserShim.presets.test.ts @@ -16,7 +16,7 @@ const APPEARANCE: StylePresetAppearance = { frameTheme: "light", aspectRatio: "16:9", shadowIntensity: 0.2, - showBlur: false, + backgroundBlur: 0, motionBlurAmount: 0.2, depthOfField: true, borderRadius: 40, diff --git a/src/native/nativeCompositorStore.ts b/src/native/nativeCompositorStore.ts index d66390637..3fe33a18e 100644 --- a/src/native/nativeCompositorStore.ts +++ b/src/native/nativeCompositorStore.ts @@ -127,7 +127,7 @@ export function subscribeNativeCompositor(listener: () => void): () => void { * value from here would scale the whole preview by 10 or 100 on load. */ export function pushAllNativeParams(settings: EditorSettingsSnapshot): void { - setNativeParam("backgroundBlur", settings.showBlur); + setNativeParam("backgroundBlur", settings.backgroundBlur); setNativeParam("motionBlur", settings.motionBlurAmount); setNativeParam("shadow", settings.shadowIntensity); setNativeParam("roundness", settings.borderRadius / NATIVE_SCREEN_BASE_RADIUS_PX); diff --git a/src/native/sceneDescription.ts b/src/native/sceneDescription.ts index 21584e602..f2bf77818 100644 --- a/src/native/sceneDescription.ts +++ b/src/native/sceneDescription.ts @@ -379,8 +379,8 @@ export interface ResolvedClipLayout { export interface SceneEffects { /** 0..1 extra inset of the screen (padding). */ padding: number; - /** Blur the background (screen used as bg). */ - blur: boolean; + /** 0..1 blur of the wallpaper, 0 = sharp (`background_blur_steps` in frame_geometry.rs). */ + blur: number; /** 0..1 drop-shadow strength. */ shadow: number; /** @@ -1228,7 +1228,7 @@ export function buildSceneDescription( }, effects: { padding: settings.padding / 100, - blur: settings.showBlur, + blur: settings.backgroundBlur, shadow: settings.shadowIntensity, // The slider is in pixels of a 1080 reference, whatever the source resolution — // see `roundnessFrac`. No padding, no background to round against: the corners go