From a95beed3e916596f973bf40b3ac7988156a0a63d Mon Sep 17 00:00:00 2001 From: EtienneLescot Date: Wed, 16 Sep 2026 14:27:18 +0200 Subject: [PATCH 1/3] feat(frames): draw a window chrome around the screen plane Add a project appearance setting, `frame`: "none" (default), "window-light" or "window-dark". A frame draws a title bar with three dots and a hairline border around the recording, as a new SDF shader mode (14) in HLSL, MSL and WGSL. Geometry lives in frame_geometry.rs, once for all three backends: - the screen box shrinks so screen + frame fit where the screen used to be, so padding keeps its meaning and nothing leaves the canvas; - the frame quad is the screen quad dilated by the frame margins; under a 3D preset it extrapolates the same TiltedQuad, so it tilts with the screen; - the drop shadow (mode 2 / mode 12) is carried by the frame quad; - Roundness rounds the frame; the screen keeps square top corners under the title bar (mode 0 `mb.w`, mode 8 `dst_prev.z`, both zero without a frame). Without a frame the render is byte-identical to before: the new flags read zero, `SceneFrame` defaults on the Rust side, and the app omits the key. The setting flows through editorSettings, style presets (optional in v1 preset files), the scene contract and a "Window" menu in the Composition panel, translated in all 14 locales. --- crates/compositor/src/compositor_linux.rs | 120 ++++- crates/compositor/src/compositor_macos.rs | 35 +- crates/compositor/src/compositor_windows.rs | 34 +- crates/compositor/src/frame_geometry.rs | 412 +++++++++++++++++- crates/compositor/src/scene.rs | 36 ++ crates/compositor/src/shaders.hlsl | 58 ++- crates/compositor/src/shaders.metal | 53 ++- crates/compositor/src/vk_shaders/layer.wgsl | 62 ++- .../compositor/tests/window_frame_render.rs | 121 +++++ .../ai-edition/style-preset-service.test.ts | 1 + electron/ipc/nativeBridge.presets.test.ts | 1 + src/components/ai-edition/RightPanes.tsx | 59 +++ src/i18n/locales/ar/settings.json | 7 +- src/i18n/locales/cs/settings.json | 7 +- src/i18n/locales/en/settings.json | 7 +- src/i18n/locales/es/settings.json | 7 +- src/i18n/locales/fr/settings.json | 7 +- src/i18n/locales/it/settings.json | 7 +- src/i18n/locales/ja-JP/settings.json | 7 +- src/i18n/locales/ko-KR/settings.json | 7 +- src/i18n/locales/pt-BR/settings.json | 7 +- src/i18n/locales/ru/settings.json | 7 +- src/i18n/locales/tr/settings.json | 7 +- src/i18n/locales/vi/settings.json | 7 +- src/i18n/locales/zh-CN/settings.json | 7 +- src/i18n/locales/zh-TW/settings.json | 7 +- .../ai-edition/store/editorSettings.test.ts | 8 + src/lib/ai-edition/store/editorSettings.ts | 12 +- src/lib/ai-edition/stylePresets.test.ts | 9 + src/lib/ai-edition/stylePresets.ts | 22 +- src/lib/ai-edition/stylePresetsEditor.test.ts | 1 + src/lib/ai-edition/stylePresetsEditor.ts | 2 + src/lib/projectDefaults.ts | 18 + src/native/browserShim.presets.test.ts | 1 + src/native/sceneDescription.test.ts | 15 + src/native/sceneDescription.ts | 8 + 36 files changed, 1107 insertions(+), 79 deletions(-) create mode 100644 crates/compositor/tests/window_frame_render.rs diff --git a/crates/compositor/src/compositor_linux.rs b/crates/compositor/src/compositor_linux.rs index 7e08ebcad..edad720fc 100644 --- a/crates/compositor/src/compositor_linux.rs +++ b/crates/compositor/src/compositor_linux.rs @@ -44,7 +44,7 @@ pub use crate::frame_geometry::{ }; use crate::frame_geometry::{ cursor_sprite_dst, parse_hex, plan_cursor, plan_frame, CursorPlacement, CursorPlanInput, - FrameGeometryInput, + FrameGeometryInput, ShadowCaster, }; use crate::scene::{Scene, SceneBackground}; @@ -1976,6 +1976,9 @@ impl Compositor { // `plane_px` dans `dst_prev`). Les deux sens ne peuvent pas cohabiter dans // un meme draw. macOS et Windows sautent egalement le flou sur le chemin // incline, pour la meme raison. + // Sous un cadre de fenetre, l'ecran garde ses coins HAUTS carres (`mb.w` au mode 0, + // `dst_prev.z` au mode 8) ; 0 sans cadre, soit le rendu d'avant. + let square_top = g.screen_square_top(); let screen_layer = match tilt.as_ref() { None => LayerCB { dst: g.s_dst, @@ -1986,10 +1989,14 @@ 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, g.mb_amount, 1.0, 0.0], + mb: [g.mb_taps, g.mb_amount, 1.0, square_top], ..Default::default() }, - Some(quad) => self.tilted_screen_cb(quad, s_px, quad_center_px, g.cut, g.s_radius), + Some(quad) => { + let mut cb = self.tilted_screen_cb(quad, s_px, quad_center_px, g.cut, g.s_radius); + cb.dst_prev[2] = square_top; + cb + } }; // Bind group construit AVANT le pass (doit vivre pendant tout le pass) ; // `_screen_uniform` garde le buffer uniforme en vie (reference par le bind). @@ -2007,24 +2014,26 @@ impl Compositor { // plateformes quelle que soit la resolution de sortie. // // L'ombre suit la silhouette REELLEMENT affichee : rect arrondi (mode 2) - // quand l'ecran est droit, quadrilatere projete (mode 12) quand il penche. + // quand l'ecran est droit, quadrilatere projete (mode 12) quand il penche. Avec un + // cadre de fenetre, c'est le CADRE qui la porte (`shadow_caster`), sinon la barre de + // titre flotterait au-dessus de l'ombre. let screen_shadow = cfg.shadow.then(|| { let spread = crate::frame_geometry::SCREEN_SHADOW_SPREAD_FRAC * g.frame_min_px; let offset = [0.0, crate::frame_geometry::SCREEN_SHADOW_OFFSET_FRAC * g.frame_min_px]; let opacity = 0.45 * lp.shadow_scale; - let cb = match tilt.as_ref() { - None => self.shadow_cb(g.s_dst, s_px, g.s_radius, spread, offset, opacity), - Some(quad) => self.quad_shadow_cb( - &quad.corners, - quad_center_px, - g.s_radius * quad.scale, - spread, - offset, - opacity, - ), + let cb = match g.shadow_caster([rw, rh]) { + ShadowCaster::Upright { dst, size_px, radius } => { + self.shadow_cb(dst, size_px, radius, spread, offset, opacity) + } + ShadowCaster::Tilted { corners, center_px, radius } => { + self.quad_shadow_cb(&corners, center_px, radius, spread, offset, opacity) + } }; self.make_bind(&cb, None, &dummy) }); + // Le cadre (mode 14), dessine entre l'ombre et l'ecran : l'ecran le recouvre et ne + // laisse voir que la barre de titre et le filet. + let window_frame = g.window_frame_cb([rw, rh]).map(|cb| self.make_bind(&cb, None, &dummy)); // Fond (gradient mode 5 OU image mode 6), dessine dans la passe de fond. let bg_draw = bg_layer.and_then(|bl| match bl { @@ -2780,6 +2789,10 @@ impl Compositor { rpass.set_bind_group(0, bind, &[]); rpass.draw(0..4, 0..1); } + if let Some((_buf, bind)) = &window_frame { + rpass.set_bind_group(0, bind, &[]); + rpass.draw(0..4, 0..1); + } rpass.set_bind_group(0, &screen_bind, &[]); rpass.draw(0..4, 0..1); if let Some((_buf, bind)) = &webcam_shadow { @@ -4532,6 +4545,85 @@ mod tests { rgba } } + + /// Ecran gris sur fond gris moyen, avec ou sans cadre de fenetre (`frame` est insere tel + /// quel dans `effects`), droit ou incline (`rotation`, JSON). + fn compose_framed(comp: &Compositor, gpu: &Gpu, frame: &str, rotation: &str) -> Vec { + let json = format!( + r##"{{"clips":[{{"screenPath":"/s.mp4","webcamPath":"","sourceStartSec":0,"sourceEndSec":10,"webcamOffsetSec":0,"hasAudio":false}}], + "layout":{{"preset":"no-webcam","webcamSize":1,"webcamShape":"rounded", + "webcamMirror":false,"webcamPosition":null,"webcamReactiveZoom":false, + "screenRect":{{"x":0.1,"y":0.1,"width":0.8,"height":0.8}}}}, + "effects":{{"padding":0.2,"blur":false,"shadow":0.6,"roundnessFrac":0.03,"motionBlur":0{frame}}}, + "background":{{"kind":"color","color":"#6070a0"}}, + "zoomRegions":[{{"clipIndex":0,"startSec":0,"endSec":10,"scale":1,"focusX":0.5,"focusY":0.5,"rotation":{rotation}}}], + "annotations":[], + "cursor":{{"show":false,"size":1,"smoothing":0,"motionBlur":0,"clickBounce":0, + "clipToBounds":false,"theme":"default"}}, + "cropByClip":[null], + "output":{{"width":1280,"height":720,"fps":30}}}}"## + ); + let scene = Scene::from_json(&json).expect("scene json"); + comp.set_live_params(live_params_from_scene(&scene)); + comp.set_has_webcam(false); + comp.set_scene(Some(scene)); + 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.zoom = false; + cfg.layout_anim = false; + cfg.cursor = false; + cfg.mblur_n = 1; + cfg.shadow = true; + unsafe { + comp.set_timeline_time(Some(2.0)); + comp.compose_frame(screen.as_ptr(), webcam.as_ptr(), 0.0, &cfg) + .expect("compose_frame"); + let (_, _, rgba) = comp.readback_direct().expect("readback_direct"); + rgba + } + } + + /// Le cadre de fenetre (mode 14) se dessine sur Linux comme ailleurs : `"none"` rend + /// l'image d'avant a l'octet, le theme clair ajoute une barre claire, droite comme + /// inclinee, et les deux themes different. `OPENSCREEN_FRAME_OUT` recoit les PNG. + #[test] + fn the_window_frame_draws_its_title_bar_flat_and_tilted() { + let Some(gpu) = gpu() else { return }; + let comp = Compositor::new_sized(&gpu, 1280, 720).expect("Compositor::new_sized"); + let out_dir = std::env::var("OPENSCREEN_FRAME_OUT").ok(); + let bright = |rgba: &[u8]| { + rgba.chunks_exact(4).filter(|p| p[0] > 215 && p[1] > 215 && p[2] > 215).count() + }; + let differing = |a: &[u8], b: &[u8]| { + a.chunks_exact(4) + .zip(b.chunks_exact(4)) + .filter(|(p, q)| p.iter().zip(q.iter()).take(3).any(|(x, y)| x.abs_diff(*y) > 8)) + .count() + }; + for (name, rotation) in [("flat", "null"), ("iso", r#""iso""#)] { + let absent = compose_framed(&comp, &gpu, "", rotation); + let none = compose_framed(&comp, &gpu, r#","frame":"none""#, rotation); + let light = compose_framed(&comp, &gpu, r#","frame":"window-light""#, rotation); + let dark = compose_framed(&comp, &gpu, r#","frame":"window-dark""#, rotation); + if let Some(dir) = &out_dir { + for (theme, rgba) in [("light", &light), ("dark", &dark)] { + let path = format!("{dir}/linux-{name}-{theme}.png"); + image::RgbaImage::from_raw(1280, 720, rgba.clone()) + .expect("dimensions du readback") + .save(&path) + .unwrap_or_else(|e| panic!("ecriture {path} : {e}")); + } + } + assert_eq!(absent, none, "{name}: frame none a change des pixels"); + assert_eq!(bright(&none), 0, "{name}: le temoin a deja des pixels clairs"); + // Barre de titre : ~0.04 x 576 px de haut sur ~1000 px de large, moins l'inclinaison. + assert!(bright(&light) > 10_000, "{name}: barre claire absente ({} px)", bright(&light)); + assert!(differing(&none, &dark) > 10_000, "{name}: cadre sombre absent"); + assert!(differing(&light, &dark) > 10_000, "{name}: les deux themes se confondent"); + } + } } // --------------------------------------------------------------------------- diff --git a/crates/compositor/src/compositor_macos.rs b/crates/compositor/src/compositor_macos.rs index b4acfba47..366a5e423 100644 --- a/crates/compositor/src/compositor_macos.rs +++ b/crates/compositor/src/compositor_macos.rs @@ -34,7 +34,7 @@ use crate::ffi::AVFrame; pub use crate::frame_geometry::{ live_params_from_scene, webcam_shape_code, FIXTURE_FRAMES, LayerCB, LiveParams, OUT_H, OUT_W, }; -use crate::frame_geometry::{parse_hex, FrameGeometryInput, SCREEN_SHADOW_OFFSET_FRAC, +use crate::frame_geometry::{parse_hex, FrameGeometryInput, ShadowCaster, SCREEN_SHADOW_OFFSET_FRAC, SCREEN_SHADOW_SPREAD_FRAC, WEBCAM_SHADOW_OFFSET_FRAC, WEBCAM_SHADOW_OPACITY, WEBCAM_SHADOW_SPREAD_FRAC}; use crate::scene::{Scene, SceneBackground}; @@ -1132,6 +1132,7 @@ impl Compositor { /// Écran incliné (mode 8) : warp bilinéaire inverse dans la bbox du quad projeté. /// Pas de motion blur sur ce chemin — le tilt est bref, la simplification ne se voit pas. + #[allow(clippy::too_many_arguments)] unsafe fn draw_tilted_screen( &self, enc: &metal::RenderCommandEncoderRef, @@ -1140,6 +1141,8 @@ impl Compositor { center_px: [f32; 2], cut: [f32; 4], radius: f32, + // 1 : coins hauts carrés, l'écran est sous la barre d'un cadre (`dst_prev.z`). + square_top: f32, y: &metal::Texture, uv: &metal::Texture, ) { @@ -1175,7 +1178,7 @@ impl Compositor { mode: 8.0, fx: [tl0, tl1, tr0, tr1], src_prev: [br0, br1, bl0, bl1], - dst_prev: [plane_px[0], plane_px[1], 0.0, 0.0], + dst_prev: [plane_px[0], plane_px[1], square_top, 0.0], ..Default::default() }, y, @@ -2084,20 +2087,22 @@ impl Compositor { let opacity = 0.45 * lp.shadow_scale; // L'ombre suit la silhouette réellement affichée : rect arrondi quand l'écran est // droit, quadrilatère projeté quand il est penché. Un rect droit derrière un écran - // incliné se lit comme une seconde surface, pas comme son ombre. - match tilt.as_ref() { - None => self.draw_shadow(enc, g.s_dst, s_px, g.s_radius, spread, offset, opacity), - Some(quad) => self.draw_quad_shadow( - enc, - &quad.corners, - quad_center_px, - g.s_radius * quad.scale, - spread, - offset, - opacity, + // incliné se lit comme une seconde surface, pas comme son ombre. Avec un cadre de + // fenêtre, c'est le CADRE qui la porte (`shadow_caster`). + match g.shadow_caster([rw, rh]) { + ShadowCaster::Upright { dst, size_px, radius } => { + self.draw_shadow(enc, dst, size_px, radius, spread, offset, opacity) + } + ShadowCaster::Tilted { corners, center_px, radius } => self.draw_quad_shadow( + enc, &corners, center_px, radius, spread, offset, opacity, ), } } + // Le cadre (mode 14) passe SOUS l'écran, qui ne laisse voir que la barre et le filet. + if let Some(cb) = g.window_frame_cb([rw, rh]) { + self.draw_solid(enc, &cb); + } + let square_top = g.screen_square_top(); let [su0, sv0, su1, sv1] = g.cut; match tilt.as_ref() { None => self.draw_video( @@ -2111,14 +2116,14 @@ 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, g.mb_amount, 1.0, 0.0], + mb: [g.mb_taps, g.mb_amount, 1.0, square_top], ..Default::default() }, &sy, &suv, ), Some(quad) => self.draw_tilted_screen( - enc, quad, s_px, quad_center_px, g.cut, g.s_radius, &sy, &suv, + enc, quad, s_px, quad_center_px, g.cut, g.s_radius, square_top, &sy, &suv, ), } diff --git a/crates/compositor/src/compositor_windows.rs b/crates/compositor/src/compositor_windows.rs index da8d12021..0fef63725 100644 --- a/crates/compositor/src/compositor_windows.rs +++ b/crates/compositor/src/compositor_windows.rs @@ -11,7 +11,7 @@ pub use crate::frame_geometry::{live_params_from_scene, webcam_shape_code, Layer use crate::frame_geometry::{ cover_crop_uv, cover_uv_rect, cursor_sprite_dst, decode_data_uri, ease_in_out_cubic, lerp, lerp4, parse_hex, preset_placements, remap_box, screen_source_rect, timeline, CursorPlacement, - FrameParams, Placement, CURSOR_BASE_SIZE_FRAC, FPS, SCREEN_SHADOW_OFFSET_FRAC, + FrameParams, Placement, ShadowCaster, CURSOR_BASE_SIZE_FRAC, FPS, SCREEN_SHADOW_OFFSET_FRAC, SCREEN_SHADOW_SPREAD_FRAC, SHADOW_TUNING_REF_PX, WEBCAM_SHADOW_OFFSET_FRAC, WEBCAM_SHADOW_OPACITY, WEBCAM_SHADOW_SPREAD_FRAC, }; @@ -1776,23 +1776,29 @@ impl Compositor { // penché ne se lisait pas comme son ombre mais comme une seconde surface. Elle suit // aussi la croissance de la boîte pendant un zoom (issue #179) : quand la boîte sort // du cadre, l'ombre en sort avec elle, sans jamais se lire comme une bande noire. + // Avec un cadre de fenêtre, c'est le CADRE qui porte l'ombre (`shadow_caster`), sinon + // elle tomberait sous l'écran seul et la barre de titre flotterait au-dessus. + let render_px = [self.rw(), self.rh()]; if cfg.shadow { let spread = SCREEN_SHADOW_SPREAD_FRAC * frame_min_px; let offset = [0.0, SCREEN_SHADOW_OFFSET_FRAC * frame_min_px]; let opacity = 0.45 * lp.shadow_scale; - match tilt.as_ref() { - None => self.draw_shadow(s_dst, s_px, s_radius, spread, offset, opacity), - Some(quad) => self.draw_quad_shadow( - &quad.corners, - quad_center_px, - // Même rayon que le plan incliné lui-même (cf. le dessin du mode 8). - s_radius * quad.scale, - spread, - offset, - opacity, - ), + match g.shadow_caster(render_px) { + ShadowCaster::Upright { dst, size_px, radius } => { + self.draw_shadow(dst, size_px, radius, spread, offset, opacity) + } + // Même rayon que le plan incliné lui-même (cf. le dessin du mode 8). + ShadowCaster::Tilted { corners, center_px, radius } => { + self.draw_quad_shadow(&corners, center_px, radius, spread, offset, opacity) + } } } + // Le cadre (mode 14) passe SOUS l'écran : l'écran le recouvre, ne laissant voir que la + // barre de titre, le filet et les coins bas entre les deux arrondis. + if let Some(cb) = g.window_frame_cb(render_px) { + self.draw_solid(&cb); + } + let square_top = g.screen_square_top(); if crate::regions::is_identity_rotation(zoom_rotation) { self.draw_video( &LayerCB { @@ -1804,7 +1810,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, mb_amount, 1.0, 0.0], + mb: [mb_taps, mb_amount, 1.0, square_top], ..Default::default() }, &sy, @@ -1857,7 +1863,7 @@ impl Compositor { mode: 8.0, fx: [tl0, tl1, tr0, tr1], src_prev: [br0, br1, bl0, bl1], - dst_prev: [plane_px[0], plane_px[1], 0.0, 0.0], + dst_prev: [plane_px[0], plane_px[1], square_top, 0.0], ..Default::default() }, &sy, diff --git a/crates/compositor/src/frame_geometry.rs b/crates/compositor/src/frame_geometry.rs index 8973425ee..4b01fdaf0 100644 --- a/crates/compositor/src/frame_geometry.rs +++ b/crates/compositor/src/frame_geometry.rs @@ -362,6 +362,65 @@ pub(crate) const WEBCAM_SHADOW_OFFSET_FRAC: f32 = 12.0 / SHADOW_TUNING_REF_PX; pub(crate) const WEBCAM_SHADOW_OPACITY: f32 = 0.35; /// Taille de base du curseur, même convention (34 px réglés contre un cadre 1080). pub(crate) const CURSOR_BASE_SIZE_FRAC: f32 = 34.0 / SHADOW_TUNING_REF_PX; + +/// Hauteur de la barre de titre du cadre de fenêtre, en fraction du petit côté de la boîte où +/// l'écran tenait sans cadre. Environ 35 px sur une boîte de 864 px (1080p, padding 50 %) : la +/// proportion d'une barre de titre de bureau à cette échelle. +pub(crate) const WINDOW_FRAME_BAR_FRAC: f32 = 0.04; +/// Épaisseur du filet qui borde le cadre, même référence : environ un pixel sur la même boîte. +/// Pas de plancher en px — il rendrait le filet plus épais, en proportion, dans la petite +/// preview qu'à l'export ; le shader l'estompe plutôt que de le faire disparaître. +pub(crate) const WINDOW_FRAME_LINE_FRAC: f32 = 0.0012; + +/// Le cadre de fenêtre posé autour de l'écran (`effects.frame`). +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct WindowFrame { + pub dark: bool, + /// Marges du cadre autour de `s_dst`, en FRACTION de cette boîte : gauche, haut, droite, bas + /// (le haut est la barre de titre, les trois autres le filet). Des fractions et non des px : + /// `remap_box` agrandit la boîte sous un zoom (#179) et le cadre doit grandir avec elle. Ce + /// sont aussi, prises en négatif ou au-delà de 1, les coordonnées du plan où + /// `TiltedQuad::point_px` extrapole les coins du cadre sous un préset 3D. + pub margins: [f32; 4], + /// Rayon extérieur du cadre, en px de la boîte droite : c'est lui que règle Roundness quand + /// il y a un cadre. L'écran n'arrondit plus que ses coins bas, de ce rayon moins le filet. + pub radius: f32, +} + +/// Rétrécit la boîte écran `a` (fractions de sortie) pour que l'écran ET son cadre tiennent là +/// où l'écran seul tenait. Rend la nouvelle boîte écran et les marges du cadre (cf. +/// `WindowFrame::margins`). +/// +/// Sans ça le cadre déborderait de la boîte, donc du canvas à petit padding. La boîte garde son +/// ratio (celui du crop, déjà cuit dans `a`) et se centre dans `a` ; sous `cover` (layouts en +/// bloc) l'écran prend au contraire tout ce que le cadre laisse, le cover rognant la source. +pub(crate) fn fit_in_window_frame(a: [f32; 4], render_px: [f32; 2], cover: bool) -> ([f32; 4], [f32; 4]) { + let [rw, rh] = render_px; + let (aw, ah) = (a[2] * rw, a[3] * rh); + let m = aw.min(ah); + let bar = WINDOW_FRAME_BAR_FRAC * m; + let line = WINDOW_FRAME_LINE_FRAC * m; + let (iw, ih) = ((aw - 2.0 * line).max(1.0), (ah - bar - line).max(1.0)); + let (sw, sh) = if cover { + (iw, ih) + } else { + let ar = aw / ah.max(1e-4); + if iw / ih > ar { (ih * ar, ih) } else { (iw, iw / ar) } + }; + let (fw, fh) = (sw + 2.0 * line, sh + bar + line); + let (cx, cy) = ((a[0] + a[2] * 0.5) * rw, (a[1] + a[3] * 0.5) * rh); + let (x0, y0) = (cx - fw * 0.5 + line, cy - fh * 0.5 + bar); + ([x0 / rw, y0 / rh, sw / rw, sh / rh], [line / sw, bar / sh, line / sw, line / sh]) +} + +/// Qui porte l'ombre portée de l'écran, et avec quelle silhouette. +#[derive(Clone, Copy, Debug, PartialEq)] +pub enum ShadowCaster { + /// Rect droit (mode 2) : `dst` en fractions de sortie, `size_px` sa taille, `radius` en px. + Upright { dst: [f32; 4], size_px: [f32; 2], radius: f32 }, + /// Quad incliné (mode 12) : coins TL, TR, BR, BL relatifs à `center_px`, rayon du plan. + Tilted { corners: [(f32, f32); 4], center_px: [f32; 2], radius: f32 }, +} /// Rect [x,y,w,h] normalisé d'un sprite de curseur de taille `w`×`h` dont le pivot `hotspot` /// (fraction 0..1 de l'image) doit tomber exactement sur `center`. /// @@ -764,6 +823,10 @@ pub struct FrameGeometry { pub w_px: [f32; 2], pub w_radius: f32, pub shape_fade: f32, + /// Cadre de fenêtre autour de l'écran. `None` : aucun, et le rendu est celui d'avant le + /// cadre, à l'octet. `Some` : `s_dst` est déjà la boîte rétrécie, et `s_radius` le rayon des + /// seuls coins BAS de l'écran (les coins hauts sont carrés, sous la barre de titre). + pub window_frame: Option, } /// Rect de destination d'une annotation dans un rect d'ancrage, en fractions de la sortie. @@ -805,6 +868,130 @@ impl FrameGeometry { self.s_ann[3] * rh } + /// 1 quand l'écran doit garder ses coins HAUTS carrés (il est sous la barre de titre d'un + /// cadre), 0 sinon. Le mode 0 le lit dans `mb.w`, le mode 8 dans `dst_prev.z` — deux + /// emplacements que ces modes laissaient à zéro, d'où un rendu inchangé sans cadre. + pub fn screen_square_top(&self) -> f32 { + if self.window_frame.is_some() { 1.0 } else { 0.0 } + } + + /// Le plan incliné de l'écran, `None` quand il est droit. Même appel que les backends : un + /// calcul déterministe, donc le même quadrilatère au bit près. + fn screen_tilt(&self, render_px: [f32; 2]) -> Option { + let s_px = [self.s_dst[2] * render_px[0], self.s_dst[3] * render_px[1]]; + (!crate::regions::is_identity_rotation(self.zoom_rotation)) + .then(|| crate::regions::rotated_quad_corners_px(s_px[0], s_px[1], self.zoom_rotation)) + } + + fn screen_center_px(&self, render_px: [f32; 2]) -> [f32; 2] { + [ + (self.s_dst[0] + self.s_dst[2] * 0.5) * render_px[0], + (self.s_dst[1] + self.s_dst[3] * 0.5) * render_px[1], + ] + } + + /// Les coins TL, TR, BR, BL du cadre, en px relatifs au centre de l'écran, plus l'échelle du + /// plan (1 à plat). Incliné, c'est le MÊME `TiltedQuad` que l'écran, prolongé au-delà de + /// 0..1 : un warp bilinéaire est entièrement fixé par ses quatre coins, donc le prolonger + /// donne exactement le plan que le mode 8 dessine, et le cadre penche avec l'écran sans + /// aucune trigonométrie de plus. + fn window_frame_corners(&self, frame: &WindowFrame, render_px: [f32; 2]) -> ([(f32, f32); 4], f32) { + let [ml, mt, mr, mb] = frame.margins; + let quad = self.screen_tilt(render_px).unwrap_or_else(|| { + let (hw, hh) = (self.s_dst[2] * render_px[0] * 0.5, self.s_dst[3] * render_px[1] * 0.5); + crate::regions::TiltedQuad { + corners: [(-hw, -hh), (hw, -hh), (hw, hh), (-hw, hh)], + scale: 1.0, + } + }); + let corners = [ + quad.point_px(-ml, -mt), + quad.point_px(1.0 + mr, -mt), + quad.point_px(1.0 + mr, 1.0 + mb), + quad.point_px(-ml, 1.0 + mb), + ]; + (corners, quad.scale) + } + + /// Ce qui porte l'ombre portée : le cadre quand il y en a un — sinon l'ombre tomberait sous + /// l'écran seul et la barre de titre flotterait au-dessus d'elle —, l'écran sinon, avec + /// exactement l'arithmétique que les backends faisaient avant le cadre. + pub fn shadow_caster(&self, render_px: [f32; 2]) -> ShadowCaster { + let center_px = self.screen_center_px(render_px); + match (&self.window_frame, self.screen_tilt(render_px)) { + (None, None) => ShadowCaster::Upright { + dst: self.s_dst, + size_px: [self.s_dst[2] * render_px[0], self.s_dst[3] * render_px[1]], + radius: self.s_radius, + }, + (None, Some(quad)) => ShadowCaster::Tilted { + corners: quad.corners, + center_px, + radius: self.s_radius * quad.scale, + }, + (Some(frame), None) => { + let [ml, mt, mr, mb] = frame.margins; + let d = self.s_dst; + let dst = [ + d[0] - ml * d[2], + d[1] - mt * d[3], + d[2] * (1.0 + ml + mr), + d[3] * (1.0 + mt + mb), + ]; + ShadowCaster::Upright { + dst, + size_px: [dst[2] * render_px[0], dst[3] * render_px[1]], + radius: frame.radius, + } + } + (Some(frame), Some(_)) => { + let (corners, scale) = self.window_frame_corners(frame, render_px); + ShadowCaster::Tilted { corners, center_px, radius: frame.radius * scale } + } + } + } + + /// Le calque du cadre (mode 14), à dessiner après l'ombre et AVANT l'écran. `None` sans cadre. + /// + /// Une seule forme pour le cas droit et le cas incliné : le mode 14 fait toujours le warp + /// inverse du mode 8, et sur un rect ce warp est l'identité exacte (le terme quadratique est + /// nul). Le calque se construit donc ici, une fois, pour les trois backends. + pub fn window_frame_cb(&self, render_px: [f32; 2]) -> Option { + let frame = self.window_frame.as_ref()?; + let [rw, rh] = render_px; + let (corners, scale) = self.window_frame_corners(frame, render_px); + let center = self.screen_center_px(render_px); + let (min_x, max_x) = + corners.iter().fold((f32::MAX, f32::MIN), |(mn, mx), &(x, _)| (mn.min(x), mx.max(x))); + let (min_y, max_y) = + corners.iter().fold((f32::MAX, f32::MIN), |(mn, mx), &(_, y)| (mn.min(y), mx.max(y))); + let bbox = [(max_x - min_x).max(1.0), (max_y - min_y).max(1.0)]; + let local = |(x, y): (f32, f32)| [x - min_x, y - min_y]; + let [tl, tr, br, bl] = corners.map(local); + let [ml, mt, mr, mb] = frame.margins; + let (s_w, s_h) = (self.s_dst[2] * rw, self.s_dst[3] * rh); + // Dimensions dans le repère du plan, avant projection, comme `plane_px` au mode 8. + let plane_px = [s_w * (1.0 + ml + mr) * scale, s_h * (1.0 + mt + mb) * scale]; + let (bar_px, line_px) = (mt * s_h * scale, ml * s_w * scale); + let (fill, line) = if frame.dark { + ([0.165, 0.165, 0.180, 1.0], [1.0, 1.0, 1.0, 0.14]) + } else { + ([0.925, 0.925, 0.935, 1.0], [0.0, 0.0, 0.0, 0.16]) + }; + Some(LayerCB { + dst: [(center[0] + min_x) / rw, (center[1] + min_y) / rh, bbox[0] / rw, bbox[1] / rh], + quad_px: bbox, + radius_px: frame.radius * scale, + mode: 14.0, + color: fill, + fx: [tl[0], tl[1], tr[0], tr[1]], + src_prev: [br[0], br[1], bl[0], bl[1]], + dst_prev: [plane_px[0], plane_px[1], bar_px, line_px], + mb: line, + ..Default::default() + }) + } + /// Où dessiner le masque d'une annotation « flou » pour qu'il couvre le CONTENU qu'il /// cachait au repos, et à quelle force. `None` si le rect est dégénéré. /// @@ -1204,8 +1391,24 @@ pub fn plan_frame(input: &FrameGeometryInput) -> FrameGeometry { // d'atteindre les bords du cadre. On rend le zoom à la BOÎTE (cf. `remap_box`) : // la coupe dessinée redevient le crop nu, la boîte porte le grossissement et // déborde le padding — c'est la géométrie de `applyZoomTransform` (TS). - let s_base = fit_screen(p.screen.dst); - let s_base_prev = fit_screen(pp.screen.dst); + let s_box = fit_screen(p.screen.dst); + let s_box_prev = fit_screen(pp.screen.dst); + // Cadre de fenêtre : l'écran rétrécit pour que lui ET son cadre tiennent dans la boîte + // où il tenait seul — le padding garde donc son sens, et rien ne sort du canvas. Le reste + // du calcul (cover, zoom, rayon) part de cette boîte rétrécie. + let frame_kind = scene.map(|s| s.effects.frame).unwrap_or_default(); + let screen_cover = scene.map(|s| s.layout.screen_cover).unwrap_or(false); + let (s_base, frame_margins) = match frame_kind { + crate::scene::SceneFrame::None => (s_box, None), + _ => { + let (b, m) = fit_in_window_frame(s_box, [rw, rh], screen_cover); + (b, Some(m)) + } + }; + let s_base_prev = match frame_margins { + None => s_box_prev, + Some(_) => fit_in_window_frame(s_box_prev, [rw, rh], screen_cover).0, + }; // Layouts "bloc" (side-by-side / top-bottom) : la boîte écran est un SLOT au ratio // arbitraire, et le web y fait tenir l'image en `cover` (`computeCompositeLayout` // renvoie `screenCover: true`, honoré par `frameRenderer`). Le natif l'ignorait, donc @@ -1310,15 +1513,34 @@ pub fn plan_frame(input: &FrameGeometryInput) -> FrameGeometry { // Le rayon suit la boîte : quand le zoom l'agrandit (issue #179), les coins grandissent // avec elle puis sortent du cadre — comme le masque de la référence, qui porte le même // `br: maskBorderRadius * camS` et quitte l'étage au même moment. - let s_radius = match (cfg.rounded, app_screen_radius_frac, scene_roundness_frac) { + // Avec un cadre, le rayon appartient au CADRE : c'est sa boîte extérieure qui sert de + // référence, et Roundness arrondit le cadre au lieu de doubler un arrondi d'écran. + let rounded_box_min_px = match frame_margins { + None => s_min_px, + Some([ml, mt, mr, mb]) => { + ((s_dst[2] * rw) * (1.0 + ml + mr)).min((s_dst[3] * rh) * (1.0 + mt + mb)) + } + }; + let outer_radius = match (cfg.rounded, app_screen_radius_frac, scene_roundness_frac) { (false, _, _) => 0.0, // Preset en bloc : le rayon appartient à la boîte écran (parité exacte avec la caméra). - (true, Some(f), _) => f * s_min_px, + (true, Some(f), _) => f * rounded_box_min_px, // Scène sans rayon imposé : slider Roundness, relatif au cadre. (true, None, Some(f)) => f * frame_min_px, // Fixture/bench (pas de scène) : chemin inspector historique, inchangé. (true, None, None) => p.screen.radius * lp.radius_scale, }; + // Sous un cadre, l'écran est rentré du filet : ses coins bas restent concentriques à + // ceux du cadre en perdant l'épaisseur du filet ; ses coins hauts sont carrés (shader). + let window_frame = frame_margins.map(|margins| WindowFrame { + dark: frame_kind == crate::scene::SceneFrame::WindowDark, + margins, + radius: outer_radius, + }); + let s_radius = match frame_margins { + None => outer_radius, + Some([ml, ..]) => (outer_radius - ml * s_dst[2] * rw).max(0.0), + }; let w_px = [w_dst[2] * rw, w_dst[3] * rh]; // Rayon caméra. Le slider Roundness ne s'y applique jamais (il ne vaut que pour l'ÉCRAN). // Quand l'app le résout (`computeCompositeLayout`, source unique), on le prend : c'est la @@ -1357,8 +1579,10 @@ pub fn plan_frame(input: &FrameGeometryInput) -> FrameGeometry { cut, s_dst, s_dst_prev, - // La boîte écran telle qu'elle serait sans zoom : `remap_box` n'est PAS appliqué. - s_ann: s_base, + // La boîte écran telle qu'elle serait sans zoom : `remap_box` n'est PAS appliqué. Ni le + // cadre : c'est le rect que l'overlay web reçoit (`layout.screenRect`), et les + // annotations doivent rester sous les poignées qui les déplacent. + s_ann: s_box, s_radius, frame_min_px, w_dst, @@ -1366,6 +1590,7 @@ pub fn plan_frame(input: &FrameGeometryInput) -> FrameGeometry { w_px, w_radius, shape_fade, + window_frame, } } @@ -1715,6 +1940,180 @@ mod tests { assert_eq!(a.s_ann, a.s_dst, "sans zoom, ancre et boîte écran coïncident"); } + /// Scène à boîte écran résolue par l'app, pour le cadre de fenêtre. `frame` est inséré tel + /// quel dans `effects` (chaîne vide = clé absente). + fn framed_scene(frame: &str, rotation: &str, zoom: f32, cover: bool) -> Scene { + Scene::from_json(&format!( + r##"{{ + "clips":[{{"screenPath":"/s.mp4","webcamPath":"","sourceStartSec":0,"sourceEndSec":10,"webcamOffsetSec":0,"hasAudio":false}}], + "layout":{{"preset":"no-webcam","webcamSize":1,"webcamShape":"rounded","webcamMirror":false,"webcamPosition":null, + "webcamReactiveZoom":false,"screenRect":{{"x":0.1,"y":0.1,"width":0.8,"height":0.8}},"screenCover":{cover}}}, + "effects":{{"padding":0.2,"blur":false,"shadow":0.5,"roundnessFrac":0.03,"motionBlur":0{frame}}}, + "background":{{"kind":"color","color":"#1e1e2e"}}, + "zoomRegions":[{{"clipIndex":0,"startSec":0.0,"endSec":5.0,"scale":{zoom},"focusX":0.5,"focusY":0.5,"rotation":{rotation}}}], + "cursor":{{"show":false,"size":1,"smoothing":0,"motionBlur":0,"clickBounce":1,"clipToBounds":false,"theme":"default"}}, + "cropByClip":[null], + "output":{{"width":1920,"height":1080,"fps":60}} + }}"## + )) + .expect("framed scene") + } + + fn framed_plan(scene: &Scene) -> FrameGeometry { + let cfg = crate::config::all().pop().expect("au moins une config"); + let mut input = golden_input(scene, &cfg); + input.render_px = [1920.0, 1080.0]; + plan_frame(&input) + } + + const RENDER: [f32; 2] = [1920.0, 1080.0]; + + fn contains(outer: [f32; 4], inner: [f32; 4], eps: f32) -> bool { + inner[0] >= outer[0] - eps + && inner[1] >= outer[1] - eps + && inner[0] + inner[2] <= outer[0] + outer[2] + eps + && inner[1] + inner[3] <= outer[1] + outer[3] + eps + } + + /// Sans cadre — clé absente ou `"none"` —, la géométrie est celle d'avant le cadre, champ + /// pour champ, et l'ombre reprend exactement l'arithmétique des backends. + #[test] + fn no_frame_leaves_the_geometry_untouched() { + for rotation in ["null", r#""iso""#] { + let absent = framed_plan(&framed_scene("", rotation, 1.5, false)); + let none = framed_plan(&framed_scene(r#","frame":"none""#, rotation, 1.5, false)); + assert_eq!(absent.s_dst, none.s_dst); + assert_eq!(absent.s_ann, none.s_ann); + assert_eq!(absent.s_radius.to_bits(), none.s_radius.to_bits()); + assert!(none.window_frame.is_none()); + assert!(none.window_frame_cb(RENDER).is_none()); + assert_eq!(none.screen_square_top(), 0.0); + let s_px = [none.s_dst[2] * RENDER[0], none.s_dst[3] * RENDER[1]]; + let expected = match none.screen_tilt(RENDER) { + None => ShadowCaster::Upright { dst: none.s_dst, size_px: s_px, radius: none.s_radius }, + Some(q) => ShadowCaster::Tilted { + corners: q.corners, + center_px: none.screen_center_px(RENDER), + radius: none.s_radius * q.scale, + }, + }; + assert_eq!(none.shadow_caster(RENDER), expected); + // Sans zoom, l'ancre des annotations reste la boîte écran. + let rest = framed_plan(&framed_scene(r#","frame":"none""#, rotation, 1.0, false)); + assert_eq!(rest.s_ann, rest.s_dst); + } + } + + /// À plat : le cadre contient l'écran, porte l'ombre, et le tout tient dans la boîte où + /// l'écran tenait seul — au ratio près, l'écran garde celui de sa source. + #[test] + fn the_flat_frame_wraps_the_screen_inside_the_old_box() { + for (frame, dark) in [(r#","frame":"window-light""#, false), (r#","frame":"window-dark""#, true)] { + for cover in [false, true] { + let old = framed_plan(&framed_scene("", "null", 1.0, cover)); + let g = framed_plan(&framed_scene(frame, "null", 1.0, cover)); + let wf = g.window_frame.expect("un cadre"); + assert_eq!(wf.dark, dark); + assert_eq!(g.screen_square_top(), 1.0); + let ShadowCaster::Upright { dst: outer, size_px, radius } = g.shadow_caster(RENDER) else { + panic!("un cadre droit porte une ombre droite"); + }; + assert!(contains(outer, g.s_dst, 1e-6), "cadre {outer:?} / écran {:?}", g.s_dst); + assert!(contains(old.s_dst, outer, 1e-6), "boîte {:?} / cadre {outer:?}", old.s_dst); + // Il remplit la boîte sur au moins un axe : le padding garde son sens. + let fills = (outer[2] - old.s_dst[2]).abs() < 1e-5 || (outer[3] - old.s_dst[3]).abs() < 1e-5; + assert!(fills, "le cadre ne remplit pas la boîte : {outer:?} dans {:?}", old.s_dst); + // La barre de titre est en haut, le filet ailleurs. + assert!(wf.margins[1] * g.s_dst[3] > 10.0 * wf.margins[0] * g.s_dst[2]); + if !cover { + let ar = |r: [f32; 4]| (r[2] * RENDER[0]) / (r[3] * RENDER[1]); + assert!((ar(g.s_dst) - ar(old.s_dst)).abs() < 1e-3, "l'écran a changé de ratio"); + } + // Roundness arrondit le cadre ; l'écran perd l'épaisseur du filet. + assert!((radius - old.s_radius).abs() < old.s_radius * 0.05, "rayon {radius} vs {}", old.s_radius); + let line_px = wf.margins[0] * g.s_dst[2] * RENDER[0]; + assert!((g.s_radius - (radius - line_px)).abs() < 1e-3); + // Le calque du cadre : un rect exact, bbox comprise. + let cb = g.window_frame_cb(RENDER).expect("calque"); + assert_eq!(cb.mode, 14.0); + assert!((cb.quad_px[0] - size_px[0]).abs() < 1e-2 && (cb.quad_px[1] - size_px[1]).abs() < 1e-2); + assert_eq!([cb.fx[0], cb.fx[1]], [0.0, 0.0]); + assert!((cb.src_prev[0] - size_px[0]).abs() < 1e-2 && (cb.src_prev[1] - size_px[1]).abs() < 1e-2); + assert!((cb.dst_prev[0] - size_px[0]).abs() < 1e-2); + assert_eq!(cb.radius_px, radius); + } + } + } + + /// Incliné : le cadre est le même plan que l'écran, prolongé. Les coins de l'écran sont + /// l'image bilinéaire des coins du cadre aux fractions de ses marges — la propriété qui + /// garantit que le cadre penche exactement comme le contenu —, le cadre contient l'écran, + /// porte l'ombre (mode 12), et tient dans la boîte d'avant. + #[test] + fn the_tilted_frame_is_the_screen_plane_extended() { + for preset in [r#""iso""#, r#""left""#, r#""right""#] { + let old = framed_plan(&framed_scene("", preset, 1.0, false)); + let g = framed_plan(&framed_scene(r#","frame":"window-light""#, preset, 1.0, false)); + let wf = g.window_frame.expect("un cadre"); + let quad = g.screen_tilt(RENDER).expect("incliné"); + let ShadowCaster::Tilted { corners: frame, center_px, radius } = g.shadow_caster(RENDER) else { + panic!("un cadre incliné porte une ombre inclinée"); + }; + assert_eq!(center_px, g.screen_center_px(RENDER)); + assert!((radius - wf.radius * quad.scale).abs() < 1e-4); + + let [ml, mt, mr, mb] = wf.margins; + let frame_quad = crate::regions::TiltedQuad { corners: frame, scale: quad.scale }; + let (u0, v0) = (ml / (1.0 + ml + mr), mt / (1.0 + mt + mb)); + let (u1, v1) = ((1.0 + ml) / (1.0 + ml + mr), (1.0 + mt) / (1.0 + mt + mb)); + for (i, (u, v)) in [(u0, v0), (u1, v0), (u1, v1), (u0, v1)].into_iter().enumerate() { + let (x, y) = frame_quad.point_px(u, v); + let (sx, sy) = quad.corners[i]; + assert!((x - sx).abs() < 0.05 && (y - sy).abs() < 0.05, "{preset} coin {i}: {x},{y} vs {sx},{sy}"); + } + + // Le cadre contient l'écran : chaque coin de l'écran est du bon côté des 4 arêtes. + for &(px, py) in &quad.corners { + for k in 0..4 { + let (ax, ay) = frame[k]; + let (bx, by) = frame[(k + 1) % 4]; + let cross = (bx - ax) * (py - ay) - (by - ay) * (px - ax); + assert!(cross > 0.0, "{preset}: coin d'écran hors du cadre (arête {k})"); + } + } + + // Il tient dans la boîte où l'écran tenait seul. Le prolongement bilinéaire n'est pas + // une vraie projection et le containment ne porte que sur l'écran : on tolère 1 %. + let (min_x, max_x) = frame.iter().fold((f32::MAX, f32::MIN), |(a, b), &(x, _)| (a.min(x), b.max(x))); + let (min_y, max_y) = frame.iter().fold((f32::MAX, f32::MIN), |(a, b), &(_, y)| (a.min(y), b.max(y))); + let bbox = [ + (center_px[0] + min_x) / RENDER[0], + (center_px[1] + min_y) / RENDER[1], + (max_x - min_x) / RENDER[0], + (max_y - min_y) / RENDER[1], + ]; + assert!(contains(old.s_dst, bbox, 0.01), "{preset}: cadre {bbox:?} hors de {:?}", old.s_dst); + + let cb = g.window_frame_cb(RENDER).expect("calque"); + assert!((cb.dst_prev[2] - mt * g.s_dst[3] * RENDER[1] * quad.scale).abs() < 1e-3); + assert_eq!(g.screen_square_top(), 1.0); + } + } + + /// Sous un zoom (#179) le cadre grandit avec la boîte : ses marges sont des fractions de + /// `s_dst`, qui porte le grossissement. L'ancre des annotations, elle, ne bouge pas. + #[test] + fn the_frame_zooms_with_the_box() { + let frame = r#","frame":"window-dark""#; + let rest = framed_plan(&framed_scene(frame, "null", 1.0, false)); + let zoomed = framed_plan(&framed_scene(frame, "null", 2.0, false)); + assert_eq!(rest.window_frame.unwrap().margins, zoomed.window_frame.unwrap().margins); + assert!((zoomed.s_dst[2] / rest.s_dst[2] - 2.0).abs() < 1e-4); + assert_eq!(rest.s_ann, zoomed.s_ann); + let ShadowCaster::Upright { dst, .. } = zoomed.shadow_caster(RENDER) else { panic!() }; + assert!(contains(dst, zoomed.s_dst, 1e-6)); + } + /// La même scène, zoomée ET inclinée par un préset de rotation 3D. fn tilted_golden_scene() -> Scene { Scene::from_json( @@ -2433,6 +2832,7 @@ mod tests { w_px: [0.0, 0.0], w_radius: 0.0, shape_fade: 0.0, + window_frame: None, } } diff --git a/crates/compositor/src/scene.rs b/crates/compositor/src/scene.rs index 3371aa8cd..8f8df8ec1 100644 --- a/crates/compositor/src/scene.rs +++ b/crates/compositor/src/scene.rs @@ -162,6 +162,26 @@ pub struct SceneEffects { pub roundness_frac: f32, /// 0..1 flou de mouvement. pub motion_blur: f32, + /// Cadre dessiné autour de l'enregistrement. `#[serde(default)]` : l'app omet la clé quand + /// il n'y en a pas, et tout payload d'avant le cadre se lit « sans cadre ». + #[serde(default)] + pub frame: SceneFrame, +} + +/// Le cadre autour de l'écran (`effects.frame` côté TS). Une valeur inconnue — un cadre ajouté +/// par une version plus récente de l'app — retombe sur `None` au lieu de faire échouer toute la +/// scène : l'enregistrement reste rendu, sans cadre. +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Deserialize)] +#[serde(rename_all = "kebab-case")] +pub enum SceneFrame { + /// Chrome de fenêtre : barre de titre, trois pastilles, filet. Thème clair. + WindowLight, + /// Le même chrome, thème sombre. + WindowDark, + /// Dernier : serde n'accepte `other` que sur la dernière variante. + #[default] + #[serde(other)] + None, } /// Fond derrière l'écran (parsé depuis `settings.wallpaper`). @@ -663,6 +683,22 @@ mod tests { assert_eq!(scene.output.width, 1920); } + #[test] + fn the_frame_defaults_to_none_and_tolerates_an_unknown_value() { + let effects = |frame: &str| -> SceneEffects { + serde_json::from_str(&format!( + r#"{{"padding":0,"blur":false,"shadow":0,"roundnessFrac":0,"motionBlur":0{frame}}}"# + )) + .expect("parse effects") + }; + assert_eq!(effects("").frame, SceneFrame::None); + assert_eq!(effects(r#","frame":"none""#).frame, SceneFrame::None); + assert_eq!(effects(r#","frame":"window-light""#).frame, SceneFrame::WindowLight); + assert_eq!(effects(r#","frame":"window-dark""#).frame, SceneFrame::WindowDark); + // Un cadre d'une version plus récente de l'app : pas de cadre, mais la scène se lit. + assert_eq!(effects(r#","frame":"browser""#).frame, SceneFrame::None); + } + #[test] fn parses_color_and_image_backgrounds() { let color = r##"{"clips":[],"layout":{"preset":"no-webcam","webcamSize":1,"webcamShape":"rectangle","webcamMirror":false,"webcamPosition":null,"webcamReactiveZoom":false},"effects":{"padding":0,"blur":false,"shadow":0,"roundnessFrac":0,"motionBlur":0},"background":{"kind":"color","color":"#123456"},"zoomRegions":[],"cursor":{"show":false,"size":1,"smoothing":0,"motionBlur":0,"clickBounce":0,"clipToBounds":false,"theme":"default"},"cropByClip":[],"output":{"width":1280,"height":720,"fps":30}}"##; diff --git a/crates/compositor/src/shaders.hlsl b/crates/compositor/src/shaders.hlsl index 245069d41..90ea14a6b 100644 --- a/crates/compositor/src/shaders.hlsl +++ b/crates/compositor/src/shaders.hlsl @@ -174,6 +174,19 @@ float3 quad_inverse_bilinear(float2 P, float2 c00, float2 c10, float2 c11, float return (r0.z > 0.5) ? r0 : r1; } +// Couverture d'une pastille (disque) adoucie sur ~1.5 px, pour la barre de titre du mode 14. +float disc_cov(float2 p, float2 c, float r) +{ + return 1.0 - smoothstep(r - 0.75, r + 0.75, length(p - c)); +} + +// Couverture d'un trait centré sur `x = 0`, de demi-épaisseur `half_w`, sur ~1 px. Un trait plus +// fin qu'un pixel s'estompe au lieu de disparaître (même forme que la flèche du mode 9). +float band_cov(float x, float half_w) +{ + return saturate(half_w + 0.5 - abs(x)); +} + // 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. @@ -225,6 +238,45 @@ float3 blur_webcam_bg(float2 uv, float intensity, float2 qpx, float2 local_px) float4 ps_main(VSOut i) : SV_Target { + // mode 14 : CADRE DE FENÊTRE autour de l'écran (barre de titre, trois pastilles, filet), + // dessiné SOUS lui. Testé en premier : la branche du mode 13 n'a pas de borne haute. + // Même warp que le mode 8 — le cadre est le quad de l'écran prolongé, il penche donc avec + // lui ; à plat, le quad est un rect et le warp l'identité exacte. + // fx.xy/fx.zw = coins TL/TR, src_prev.xy/.zw = BR/BL (px locaux) ; dst_prev.xy = taille du + // cadre dans son plan, dst_prev.z = hauteur de la barre, dst_prev.w = épaisseur du filet + // (px du plan) ; radius_px = rayon extérieur (les coins hauts plafonnent à la barre) ; + // color = fond de la barre, mb = couleur du filet (alpha droit). + if (mode > 13.5) + { + float3 r = quad_inverse_bilinear(i.local, fx.xy, fx.zw, src_prev.xy, src_prev.zw); + if (r.z < 0.5) + { + return float4(0.0, 0.0, 0.0, 0.0); // hors du cadre projeté + } + float2 plane_px = dst_prev.xy; + float bar = dst_prev.z; + float line_w = dst_prev.w; + float2 q = float2(r.x, r.y) * plane_px; // px du plan depuis le coin haut-gauche + float2 p = q - plane_px * 0.5; + // Coins hauts plafonnés à la barre : au-delà, l'arrondi descendrait sous la barre et + // les coins carrés de l'écran en dépasseraient. + float rad = max(radius_px, 0.0); + float d = sd_round_rect(p, plane_px * 0.5, (p.y < 0.0) ? min(rad, bar) : rad); + float cov = 1.0 - smoothstep(0.0, 1.5, d); + // Filet intérieur le long du contour, et séparation entre la barre et le contenu. + float stroke = max(band_cov(-d - line_w * 0.5, line_w * 0.5), + band_cov(q.y - (bar - line_w * 0.5), line_w * 0.5)); + float3 rgb = lerp(color.rgb, mb.rgb, stroke * mb.a); + // Pastilles : proportions d'une barre de 28 px (rayon 6, pas de 20). + float dr = bar * 0.214; + float dx = bar * 0.714; + rgb = lerp(rgb, float3(1.000, 0.373, 0.341), disc_cov(q, float2(dx, bar * 0.5), dr)); + rgb = lerp(rgb, float3(0.996, 0.737, 0.180), disc_cov(q, float2(2.0 * dx, bar * 0.5), dr)); + rgb = lerp(rgb, float3(0.157, 0.784, 0.251), disc_cov(q, float2(3.0 * dx, bar * 0.5), dr)); + float a = cov * color.a; + return float4(rgb * a, a); // prémultiplié + } + // mode 13 : SPRITE DE CURSEUR posé sur l'écran incliné. Même warp que le mode 8, mais // échantillonnant la texture du curseur en alpha DROIT (comme le mode 7) au lieu de la // vidéo NV12. Le curseur remplace un pointeur qui faisait partie de l'image capturée : il @@ -410,9 +462,10 @@ float4 ps_main(VSOut i) : SV_Target // en escalier franc, soit la troncature même que cette branche existe pour éviter (d'où le // symptôme « le tilt 3D est tronqué, mais pas au-dessus d'un certain arrondi »). L'ombre du // mode 12 applique déjà son `max(radius_px, 0.0)` sans garde, pour la même raison. + // dst_prev.z = 1 : écran sous un cadre de fenêtre, coins HAUTS carrés (sous la barre). float2 plane_px = dst_prev.xy; float2 p = float2(r.x, r.y) * plane_px - plane_px * 0.5; - float d = sd_round_rect(p, plane_px * 0.5, max(radius_px, 0.0)); + float d = sd_round_rect(p, plane_px * 0.5, (dst_prev.z > 0.5 && p.y < 0.0) ? 0.0 : max(radius_px, 0.0)); float tilt_a = 1.0 - smoothstep(0.0, 1.5, d); return float4(sample_yuv(uv) * tilt_a, tilt_a); // prémultiplié, comme les autres modes } @@ -572,9 +625,10 @@ float4 ps_main(VSOut i) : SV_Target // même espace, le coin est donc rond par construction. (Avant, le canvas figé en 16:9 // était étiré en fin de pipeline et il fallait pré-déformer par `mb.yz` pour que le // cercle ne ressorte pas elliptique.) + // mb.w = 1 : écran sous un cadre de fenêtre, coins HAUTS carrés (sous la barre de titre). float2 halfsz = quad_px * 0.5; float2 p = i.local - quad_px * 0.5; - float d = sd_round_rect(p, halfsz, radius_px); + float d = sd_round_rect(p, halfsz, (mb.w > 0.5 && p.y < 0.0) ? 0.0 : radius_px); alpha *= 1.0 - smoothstep(0.0, 1.5, d); // ~1.5px feather (§7 fwidth-like) } return float4(rgb * alpha, alpha); // prémultiplié diff --git a/crates/compositor/src/shaders.metal b/crates/compositor/src/shaders.metal index 1ab91d5f7..22fbf431c 100644 --- a/crates/compositor/src/shaders.metal +++ b/crates/compositor/src/shaders.metal @@ -230,8 +230,20 @@ inline float3 quad_inverse_bilinear(float2 P, float2 c00, float2 c10, float2 c11 return (r0.z > 0.5) ? r0 : r1; } +// Couverture d'une pastille (disque) adoucie sur ~1.5 px, pour la barre de titre du mode 14. +inline float disc_cov(float2 p, float2 c, float r) +{ + return 1.0 - smoothstep(r - 0.75, r + 0.75, length(p - c)); +} + +// Couverture d'un trait centré sur `x = 0`, de demi-épaisseur `half_w`, sur ~1 px. +inline float band_cov(float x, float half_w) +{ + return clamp(half_w + 0.5 - abs(x), 0.0, 1.0); +} + // ================================================================================= -// Pixel shader principal : un seul `ps_main` qui gère 14 modes via `layer.mode`. +// Pixel shader principal : un seul `ps_main` qui gère 15 modes via `layer.mode`. // Identique à `ps_main` côté HLSL ligne pour ligne (à la syntaxe MSL près). // ================================================================================= @@ -297,6 +309,38 @@ fragment float4 ps_main(VSOut i [[stage_in]], // puisque la branche n'est prise que si layer.fx.z > 0.5. texture2d texMask [[texture(3)]]) { + // mode 14 : CADRE DE FENÊTRE autour de l'écran, dessiné SOUS lui. Cf. commentaires HLSL. + // Testé en premier : la branche du mode 13 n'a pas de borne haute. + // fx/src_prev = coins du cadre projeté ; dst_prev = (taille du plan, barre, filet) ; + // radius_px = rayon extérieur ; color = fond de la barre ; mb = couleur du filet. + if (layer.mode > 13.5) + { + float3 r = quad_inverse_bilinear(i.local, layer.fx.xy, layer.fx.zw, + layer.src_prev.xy, layer.src_prev.zw); + if (r.z < 0.5) + { + return float4(0.0, 0.0, 0.0, 0.0); // hors du cadre projeté + } + float2 plane_px = layer.dst_prev.xy; + float bar = layer.dst_prev.z; + float line_w = layer.dst_prev.w; + float2 q = float2(r.x, r.y) * plane_px; + float2 p = q - plane_px * 0.5; + float rad = max(layer.radius_px, 0.0); + float d = sd_round_rect(p, plane_px * 0.5, (p.y < 0.0) ? min(rad, bar) : rad); + float cov = 1.0 - smoothstep(0.0, 1.5, d); + float stroke = max(band_cov(-d - line_w * 0.5, line_w * 0.5), + band_cov(q.y - (bar - line_w * 0.5), line_w * 0.5)); + float3 rgb = mix(layer.color.rgb, layer.mb.rgb, stroke * layer.mb.a); + float dr = bar * 0.214; + float dx = bar * 0.714; + rgb = mix(rgb, float3(1.000, 0.373, 0.341), disc_cov(q, float2(dx, bar * 0.5), dr)); + rgb = mix(rgb, float3(0.996, 0.737, 0.180), disc_cov(q, float2(2.0 * dx, bar * 0.5), dr)); + rgb = mix(rgb, float3(0.157, 0.784, 0.251), disc_cov(q, float2(3.0 * dx, bar * 0.5), dr)); + float a = cov * layer.color.a; + return float4(rgb * a, a); + } + // mode 13 : SPRITE DE CURSEUR posé sur l'écran incliné. Cf. commentaires HLSL. if (layer.mode > 12.5) { @@ -380,9 +424,11 @@ fragment float4 ps_main(VSOut i [[stage_in]], // Inconditionnel, rayon 0 compris — `sd_round_rect` dégénère en SDF de rectangle et // le feather de 1,5 px subsiste, ce qui fait lire une arête inclinée COMME une arête // plutôt que comme une troncature en marches d'escalier. + // dst_prev.z = 1 : écran sous un cadre de fenêtre, coins HAUTS carrés (sous la barre). float2 plane_px = layer.dst_prev.xy; float2 p = float2(r.x, r.y) * plane_px - plane_px * 0.5; - float d = sd_round_rect(p, plane_px * 0.5, max(layer.radius_px, 0.0)); + float d = sd_round_rect(p, plane_px * 0.5, + (layer.dst_prev.z > 0.5 && p.y < 0.0) ? 0.0 : max(layer.radius_px, 0.0)); float tilt_a = 1.0 - smoothstep(0.0, 1.5, d); // L'alpha est cette couverture, pas `color.a` : les draws du mode 8 laissent `color` // à zéro, donc le port rendait de toute façon un plan totalement transparent. @@ -619,9 +665,10 @@ fragment float4 ps_main(VSOut i [[stage_in]], float alpha = layer.color.a * alpha_mask; if (layer.radius_px > 0.0) { + // mb.w = 1 : écran sous un cadre de fenêtre, coins HAUTS carrés (sous la barre de titre). float2 halfsz = layer.quad_px * 0.5; float2 p = i.local - layer.quad_px * 0.5; - float d = sd_round_rect(p, halfsz, layer.radius_px); + float d = sd_round_rect(p, halfsz, (layer.mb.w > 0.5 && p.y < 0.0) ? 0.0 : layer.radius_px); alpha *= 1.0 - smoothstep(0.0, 1.5, d); } return float4(rgb * alpha, alpha); diff --git a/crates/compositor/src/vk_shaders/layer.wgsl b/crates/compositor/src/vk_shaders/layer.wgsl index bc778f474..971071f5e 100644 --- a/crates/compositor/src/vk_shaders/layer.wgsl +++ b/crates/compositor/src/vk_shaders/layer.wgsl @@ -22,12 +22,12 @@ struct Layer { src: vec4, // u0,v0,u1,v1 source 0..1 quad_px: vec2, // taille du quad en px de sortie (pour la SDF isotrope) radius_px: f32, - mode: f32, // 0 = vidéo NV12, 1 = couleur pleine, 2 = ombre, 8 = écran tilté, 9 = flèche, 10 = flou/mosaïque, 12 = ombre du quad tilté, 13 = curseur tilté - color: vec4, - fx: vec4, // mode 2 : spread ombre en px ; modes 8/12/13 : coins TL,TR du quad projeté ; mode 9 : hampe de la flèche ; mode 10 : (flou?, rayon/bloc px, ovale?, teinté?) - src_prev: vec4, // modes 8/12/13 : coins BR,BL du quad projeté ; mode 9 : barbe 1 ; mode 10 incliné : coins BR,BL du masque - dst_prev: vec4, // mode 8 : taille du plan en px AVANT projection (le rayon y vit) ; mode 13 : rect de clip ; mode 9 : barbe 2 ; mode 10 incliné : coins TL,TR du masque - mb: vec4, // mode 12 : mb.y = spread de la pénombre en px ; mode 9 : mb.y = demi-épaisseur du trait en px ; mode 10 : mb.z = 1 si masque incliné + mode: f32, // 0 = vidéo NV12, 1 = couleur pleine, 2 = ombre, 8 = écran tilté, 9 = flèche, 10 = flou/mosaïque, 12 = ombre du quad tilté, 13 = curseur tilté, 14 = cadre de fenetre + color: vec4, // mode 14 : fond de la barre de titre + fx: vec4, // mode 2 : spread ombre en px ; modes 8/12/13/14 : coins TL,TR du quad projeté ; mode 9 : hampe de la flèche ; mode 10 : (flou?, rayon/bloc px, ovale?, teinté?) + src_prev: vec4, // modes 8/12/13/14 : coins BR,BL du quad projeté ; mode 9 : barbe 1 ; mode 10 incliné : coins BR,BL du masque + dst_prev: vec4, // mode 8 : .xy = taille du plan en px AVANT projection (le rayon y vit), .z = 1 si coins hauts carres (sous un cadre) ; mode 14 : .xy = taille du plan du cadre, .z = hauteur de la barre, .w = epaisseur du filet (px du plan) ; mode 13 : rect de clip ; mode 9 : barbe 2 ; mode 10 incliné : coins TL,TR du masque + mb: vec4, // mode 0 : .x taps, .y force du flou, .w = 1 si coins hauts carres (sous un cadre) ; mode 12 : mb.y = spread de la pénombre en px ; mode 9 : mb.y = demi-épaisseur du trait en px ; mode 10 : mb.z = 1 si masque incliné ; mode 14 : couleur du filet (alpha droit) } @group(0) @binding(0) var layer: Layer; @@ -221,6 +221,16 @@ fn quad_inverse_bilinear(P: vec2, c00: vec2, c10: vec2, c11: vec2 return r1; } +// Couverture d'une pastille (disque) adoucie sur ~1.5 px, pour la barre de titre du mode 14. +fn disc_cov(p: vec2, c: vec2, r: f32) -> f32 { + return 1.0 - smoothstep(r - 0.75, r + 0.75, length(p - c)); +} + +// Couverture d'un trait centre sur `x = 0`, de demi-epaisseur `half_w`, sur ~1 px. +fn band_cov(x: f32, half_w: f32) -> f32 { + return clamp(half_w + 0.5 - abs(x), 0.0, 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. @@ -482,9 +492,11 @@ fn fs_main(i: VsOut) -> @location(0) vec4 { // Inconditionnel, rayon 0 compris -- `sd_round_rect` degenere en SDF de // rectangle et le feather de 1,5 px subsiste, ce qui fait lire une arete // inclinee COMME une arete plutot que comme un escalier. + // dst_prev.z = 1 : ecran sous un cadre de fenetre, coins HAUTS carres (sous la barre). let plane_px = layer.dst_prev.xy; let p = vec2(r.x, r.y) * plane_px - plane_px * 0.5; - let d = sd_round_rect(p, plane_px * 0.5, max(layer.radius_px, 0.0)); + let d = sd_round_rect(p, plane_px * 0.5, + select(max(layer.radius_px, 0.0), 0.0, layer.dst_prev.z > 0.5 && p.y < 0.0)); let tilt_a = 1.0 - smoothstep(0.0, 1.5, d); // L'alpha est cette couverture, pas `color.a` : les draws du mode 8 laissent // `color` a zero, donc s'en servir rendrait un plan totalement transparent. @@ -533,6 +545,39 @@ fn fs_main(i: VsOut) -> @location(0) vec4 { let s = textureSample(texY, samp, clamp(vec2(r.x, r.y), vec2(0.0), vec2(1.0))); let ca = s.a * layer.color.a; return vec4(s.rgb * ca, ca); + } else if layer.mode > 13.5 && layer.mode < 14.5 { + // Mode 14 -- CADRE DE FENETRE autour de l'ecran (barre de titre, trois pastilles, + // filet), dessine SOUS lui. Meme warp que le mode 8 : le cadre est le quad de l'ecran + // prolonge, il penche donc avec lui ; a plat le quad est un rect et le warp l'identite. + let r = quad_inverse_bilinear( + i.local, layer.fx.xy, layer.fx.zw, layer.src_prev.xy, layer.src_prev.zw, + ); + if r.z < 0.5 { + return vec4(0.0, 0.0, 0.0, 0.0); // hors du cadre projete + } + let plane_px = layer.dst_prev.xy; + let bar = layer.dst_prev.z; + let line_w = layer.dst_prev.w; + let q = vec2(r.x, r.y) * plane_px; // px du plan depuis le coin haut-gauche + let p = q - plane_px * 0.5; + // Coins hauts plafonnes a la barre : au-dela, l'arrondi descendrait sous la barre et + // les coins carres de l'ecran en depasseraient. + let rad = max(layer.radius_px, 0.0); + let d = sd_round_rect(p, plane_px * 0.5, select(rad, min(rad, bar), p.y < 0.0)); + let cov = 1.0 - smoothstep(0.0, 1.5, d); + // Filet interieur le long du contour, et separation entre la barre et le contenu. + let stroke = max(band_cov(-d - line_w * 0.5, line_w * 0.5), + band_cov(q.y - (bar - line_w * 0.5), line_w * 0.5)); + var frame_rgb = mix(layer.color.rgb, layer.mb.rgb, stroke * layer.mb.a); + // Pastilles : proportions d'une barre de 28 px (rayon 6, pas de 20). Deroulees a la + // main, comme `sd_convex_quad` : pas de tableau local indexe. + let dr = bar * 0.214; + let dx = bar * 0.714; + frame_rgb = mix(frame_rgb, vec3(1.000, 0.373, 0.341), disc_cov(q, vec2(dx, bar * 0.5), dr)); + frame_rgb = mix(frame_rgb, vec3(0.996, 0.737, 0.180), disc_cov(q, vec2(2.0 * dx, bar * 0.5), dr)); + frame_rgb = mix(frame_rgb, vec3(0.157, 0.784, 0.251), disc_cov(q, vec2(3.0 * dx, bar * 0.5), dr)); + let fa = cov * layer.color.a; + return vec4(frame_rgb * fa, fa); // premultiplie } else { // Mode 2 — ombre portée (SDF d'un quad arrondi élargi de `fx.x`). let spread = layer.fx.x; @@ -549,9 +594,10 @@ fn fs_main(i: VsOut) -> @location(0) vec4 { // Feather ~1.5 px sur le bord du quad — parité exacte avec le HLSL // (`smoothstep(0.0, 1.5, d)`). Le shader HLSL inclut `quad_px` en px de // SORTIE ; on reproduit la même chose ici. + // mb.w = 1 : ecran sous un cadre de fenetre, coins HAUTS carres (sous la barre de titre). let halfsz = layer.quad_px * 0.5; let p = i.local - layer.quad_px * 0.5; - let d = sd_round_rect(p, halfsz, layer.radius_px); + let d = sd_round_rect(p, halfsz, select(layer.radius_px, 0.0, layer.mb.w > 0.5 && p.y < 0.0)); alpha *= 1.0 - smoothstep(0.0, 1.5, d); } diff --git a/crates/compositor/tests/window_frame_render.rs b/crates/compositor/tests/window_frame_render.rs new file mode 100644 index 000000000..1144e860b --- /dev/null +++ b/crates/compositor/tests/window_frame_render.rs @@ -0,0 +1,121 @@ +//! Le cadre de fenêtre (`effects.frame`) rendu par le vrai compositeur D3D11 : droit et incliné, +//! clair et sombre, plus le témoin sans cadre. +//! +//! Piloté par l'environnement, comme `privacy_blur_under_zoom.rs` : il faut une source vidéo +//! quelconque (celle de ce test-là convient). +//! +//! ```powershell +//! $env:OPENSCREEN_FRAME_SOURCE = "...\secret.mp4" +//! $env:OPENSCREEN_FRAME_OUT = "...\renders" # facultatif : un PPM par cas +//! cargo test -p openscreen-compositor --test window_frame_render -- --nocapture +//! ``` + +#![cfg(windows)] + +use openscreen_compositor::compositor::Compositor; +use openscreen_compositor::config; +use openscreen_compositor::d3d::Gpu; +use openscreen_compositor::frame_geometry::live_params_from_scene; +use openscreen_compositor::live::Player; +use openscreen_compositor::scene::Scene; + +const W: u32 = 1280; +const H: u32 = 720; +const AT_SEC: f64 = 3.0; + +fn write_ppm(path: &std::path::Path, rgba: &[u8], w: u32, h: u32) -> std::io::Result<()> { + let mut out = Vec::with_capacity(rgba.len() / 4 * 3 + 32); + out.extend_from_slice(format!("P6\n{w} {h}\n255\n").as_bytes()); + for px in rgba.chunks_exact(4) { + out.extend_from_slice(&px[..3]); + } + std::fs::write(path, out) +} + +/// `frame` : `None` = clé absente du JSON (payload d'avant le cadre), sinon sa valeur. +fn scene_json(source: &str, frame: Option<&str>, rotation: &str) -> String { + let s = source.replace('\\', "/"); + let frame = frame.map(|f| format!(r#","frame":"{f}""#)).unwrap_or_default(); + // Zoom 1 : la région ne sert qu'à porter le préset 3D, sans grossir la boîte. + format!( + r##"{{ + "clips": [{{"screenPath":"{s}","webcamPath":"","sourceStartSec":0,"sourceEndSec":6,"webcamOffsetSec":0,"hasAudio":false}}], + "layout": {{"preset":"no-webcam","webcamSize":1.0,"webcamShape":"rounded","webcamMirror":false,"webcamPosition":null,"webcamReactiveZoom":false, + "screenRect":{{"x":0.1,"y":0.1,"width":0.8,"height":0.8}}}}, + "effects": {{"padding":0.5,"blur":false,"shadow":0.6,"roundnessFrac":0.03,"motionBlur":0.0{frame}}}, + "background": {{"kind":"gradient","angleDeg":135,"stops":["#5b6ee1","#e8a0bf"]}}, + "zoomRegions": [{{"id":"z","startSec":0,"endSec":6,"scale":1.0,"focusX":0.5,"focusY":0.5,"focusMode":"manual","rotation":{rotation}}}], + "annotations": [], + "speedRegions": [], + "cursor": {{"show":false,"size":1,"smoothing":0,"motionBlur":0,"clickBounce":0,"clipToBounds":false,"theme":"default"}}, + "cropByClip": [null], + "output": {{"width":{W},"height":{H},"fps":null}} + }}"## + ) +} + +fn render(gpu: &Gpu, source: &str, frame: Option<&str>, rotation: &str) -> Vec { + let mut cfg = config::all().pop().expect("au moins une config"); + cfg.zoom = false; + cfg.layout_anim = false; + let comp = Compositor::new_sized(gpu, W, H).expect("compositor"); + let scene = Scene::from_json(&scene_json(source, frame, rotation)).expect("scene valide"); + comp.set_live_params(live_params_from_scene(&scene)); + comp.set_scene(Some(scene)); + comp.clear_cursor(); + unsafe { + let mut player = Player::open(source, "", gpu).expect("ouvrir la source"); + player.present_frame(&comp, &cfg, AT_SEC).expect("composer la frame"); + comp.readback_resized(W, H).expect("readback") + } +} + +/// Pixels qui diffèrent de plus de `tol` sur un canal. +fn differing(a: &[u8], b: &[u8], tol: u8) -> usize { + a.chunks_exact(4) + .zip(b.chunks_exact(4)) + .filter(|(p, q)| p.iter().zip(q.iter()).take(3).any(|(x, y)| x.abs_diff(*y) > tol)) + .count() +} + +#[test] +fn the_window_frame_renders_flat_and_tilted_in_both_themes() { + let Ok(source) = std::env::var("OPENSCREEN_FRAME_SOURCE") else { + println!("SKIP: definir OPENSCREEN_FRAME_SOURCE (voir l'en-tete du fichier)."); + return; + }; + let out_dir = std::env::var("OPENSCREEN_FRAME_OUT").ok().map(std::path::PathBuf::from); + if let Some(dir) = &out_dir { + std::fs::create_dir_all(dir).expect("creer le dossier de sortie"); + } + let gpu = Gpu::create(false).expect("device d3d11"); + + let mut renders = Vec::new(); + for (tilt_name, rotation) in [("flat", "null"), ("iso", r#""iso""#), ("left", r#""left""#)] { + for (frame_name, frame) in + [("absent", None), ("none", Some("none")), ("light", Some("window-light")), ("dark", Some("window-dark"))] + { + let rgba = render(&gpu, &source, frame, rotation); + if let Some(dir) = &out_dir { + write_ppm(&dir.join(format!("{tilt_name}-{frame_name}.ppm")), &rgba, W, H) + .expect("ecrire le ppm"); + } + renders.push(((tilt_name, frame_name), rgba)); + } + } + let get = |t: &str, f: &str| &renders.iter().find(|((a, b), _)| *a == t && *b == f).unwrap().1; + + for tilt in ["flat", "iso", "left"] { + // `"none"` et la clé absente : le même rendu, à l'octet. + assert!(get(tilt, "none") == get(tilt, "absent"), "{tilt}: frame none != payload sans cadre"); + // Le cadre se voit, et ses deux thèmes aussi. + let light = differing(get(tilt, "none"), get(tilt, "light"), 8); + let dark = differing(get(tilt, "none"), get(tilt, "dark"), 8); + let themes = differing(get(tilt, "light"), get(tilt, "dark"), 8); + println!("{tilt:<5} clair {light:>7} sombre {dark:>7} clair/sombre {themes:>7}"); + let area = (W * H) as usize; + assert!(light > area / 50, "{tilt}: le cadre clair ne se voit pas ({light} px)"); + assert!(dark > area / 50, "{tilt}: le cadre sombre ne se voit pas ({dark} px)"); + assert!(themes > area / 200, "{tilt}: clair et sombre se confondent ({themes} px)"); + } +} diff --git a/electron/ai-edition/style-preset-service.test.ts b/electron/ai-edition/style-preset-service.test.ts index 4a7158dca..a2c5a9bfd 100644 --- a/electron/ai-edition/style-preset-service.test.ts +++ b/electron/ai-edition/style-preset-service.test.ts @@ -7,6 +7,7 @@ import { StylePresetError, StylePresetService } from "./style-preset-service"; const APPEARANCE: StylePresetAppearance = { wallpaper: "#000000", + frame: "none", aspectRatio: "16:9", shadowIntensity: 0.2, showBlur: false, diff --git a/electron/ipc/nativeBridge.presets.test.ts b/electron/ipc/nativeBridge.presets.test.ts index a04ec169c..2455222b4 100644 --- a/electron/ipc/nativeBridge.presets.test.ts +++ b/electron/ipc/nativeBridge.presets.test.ts @@ -27,6 +27,7 @@ vi.mock("../native-bridge/services/aiEditionService", () => ({ const APPEARANCE: StylePresetAppearance = { wallpaper: "#000000", + frame: "none", aspectRatio: "16:9", shadowIntensity: 0.2, showBlur: false, diff --git a/src/components/ai-edition/RightPanes.tsx b/src/components/ai-edition/RightPanes.tsx index bfa10f273..bbd7f9e2d 100644 --- a/src/components/ai-edition/RightPanes.tsx +++ b/src/components/ai-edition/RightPanes.tsx @@ -93,6 +93,7 @@ import { themePickerPreviewAssets, } from "@/lib/cursor/cursorThemes"; import { buildGradientFromEditor } from "@/lib/gradientBuilder"; +import { RECORDING_FRAMES, type RecordingFrame } from "@/lib/projectDefaults"; import { classifyWallpaper, resolveImageWallpaperUrl, @@ -2333,6 +2334,12 @@ function pluralKey(locale: string, count: number): string { // ─── Video Effects ───────────────────────────────────────────────── +const RECORDING_FRAME_LABEL_KEYS: Record = { + none: "effects.windowNone", + "window-light": "effects.windowLight", + "window-dark": "effects.windowDark", +}; + /** * One pane for everything that shapes the composition. * @@ -2357,6 +2364,7 @@ export function VideoEffectsPane() { const nativeFormats = useMemo(() => (document ? collectNativeFormats(document) : []), [document]); const [fitMenuOpen, setFitMenuOpen] = useState(false); const [ratioMenuOpen, setRatioMenuOpen] = useState(false); + const [frameMenuOpen, setFrameMenuOpen] = useState(false); const { locale } = useI18n(); const clipCountLabel = (count: number) => ts(pluralKey(locale, count), { count }); @@ -2541,6 +2549,57 @@ export function VideoEffectsPane() { + {/* The window chrome drawn around the recording. A menu like Format above it, and + for the same reason: it picks one project-wide look among a few. With a frame + on, Roundness rounds the frame and Shadow falls under it — both still move what + they name. */} +
+ + {ts("effects.window")} + + + + + + +
+ {RECORDING_FRAMES.map((frame) => ( + + ))} +
+
+
+
{ expect(getEditorSettings(doc).webcamBackgroundMode).toBe("none"); }); + it("round-trips the recording frame and reads an unknown one as no frame", () => { + expect(getEditorSettings(baseDoc).frame).toBe("none"); + const patched = patchEditorSettings(baseDoc, { frame: "window-dark" }); + expect(getEditorSettings(patched).frame).toBe("window-dark"); + const unknown = { ...baseDoc, legacyEditor: { frame: "browser" } } as typeof baseDoc; + expect(getEditorSettings(unknown).frame).toBe("none"); + }); + it("clamps a stored webcam blur intensity into 0..1", () => { const tooHigh = { ...baseDoc, legacyEditor: { webcamBlurIntensity: 1000 } } as typeof baseDoc; expect(getEditorSettings(tooHigh).webcamBlurIntensity).toBe(1); diff --git a/src/lib/ai-edition/store/editorSettings.ts b/src/lib/ai-edition/store/editorSettings.ts index 5f2b28bca..c2af10bb7 100644 --- a/src/lib/ai-edition/store/editorSettings.ts +++ b/src/lib/ai-edition/store/editorSettings.ts @@ -20,7 +20,11 @@ import { type WebcamPosition, type WebcamSizePreset, } from "@/components/video-editor/types"; -import { DEFAULT_PROJECT_APPEARANCE } from "@/lib/projectDefaults"; +import { + DEFAULT_PROJECT_APPEARANCE, + isRecordingFrame, + type RecordingFrame, +} from "@/lib/projectDefaults"; import type { AspectRatio } from "@/utils/aspectRatioUtils"; import { clamp01 } from "@/utils/math"; import type { AxcutDocument } from "../schema"; @@ -72,6 +76,8 @@ const DEFAULT_CROP_PAN: CropPan = { x: 0.5, y: 0.5 }; export interface EditorSettingsSnapshot { wallpaper: string; + /** The frame drawn around the recording (window chrome), or "none". */ + frame: RecordingFrame; aspectRatio: AspectRatio; shadowIntensity: number; showBlur: boolean; @@ -115,6 +121,7 @@ export const DEFAULT_EDITOR_SETTINGS: EditorSettingsSnapshot = { interface LegacyShape { wallpaper?: string; + frame?: RecordingFrame; aspectRatio?: AspectRatio; shadowIntensity?: number; showBlur?: boolean; @@ -187,6 +194,8 @@ export function getEditorSettings(doc: AxcutDocument | null | undefined): Editor return { wallpaper: str(legacy?.wallpaper, DEFAULT_EDITOR_SETTINGS.wallpaper), + // An unknown value (a frame a newer build added) reads as no frame, like the compositor. + frame: isRecordingFrame(legacy?.frame) ? legacy.frame : DEFAULT_EDITOR_SETTINGS.frame, aspectRatio: legacy?.aspectRatio ?? DEFAULT_EDITOR_SETTINGS.aspectRatio, shadowIntensity: num(legacy?.shadowIntensity, DEFAULT_EDITOR_SETTINGS.shadowIntensity), showBlur: bool(legacy?.showBlur, DEFAULT_EDITOR_SETTINGS.showBlur), @@ -230,6 +239,7 @@ export function getEditorSettings(doc: AxcutDocument | null | undefined): Editor } export interface EditorSettingsPatch { wallpaper?: string; + frame?: RecordingFrame; aspectRatio?: AspectRatio; shadowIntensity?: number; showBlur?: boolean; diff --git a/src/lib/ai-edition/stylePresets.test.ts b/src/lib/ai-edition/stylePresets.test.ts index 7ba0a6492..0585f2483 100644 --- a/src/lib/ai-edition/stylePresets.test.ts +++ b/src/lib/ai-edition/stylePresets.test.ts @@ -15,6 +15,7 @@ import { function appearance(overrides: Partial = {}): StylePresetAppearance { return { wallpaper: "/wallpapers/wallpaper3.jpg", + frame: "window-dark", aspectRatio: "16:9", shadowIntensity: 0.2, showBlur: false, @@ -77,6 +78,14 @@ describe("parseStylePresetAppearance", () => { ).toThrow(/cursor\.size/); }); + it("reads a preset saved before the frame existed as frameless, and rejects an unknown frame", () => { + const { frame: _frame, ...older } = appearance(); + expect(parseStylePresetAppearance(older).frame).toBe("none"); + expect(() => parseStylePresetAppearance({ ...appearance(), frame: "window-sepia" })).toThrow( + /frame/, + ); + }); + it("falls back to the default cursor theme for an id this build does not ship", () => { expect( parseStylePresetAppearance(appearance({ cursorTheme: "theme-from-the-future" })).cursorTheme, diff --git a/src/lib/ai-edition/stylePresets.ts b/src/lib/ai-edition/stylePresets.ts index 1b46ce44d..eeec16d79 100644 --- a/src/lib/ai-edition/stylePresets.ts +++ b/src/lib/ai-edition/stylePresets.ts @@ -19,6 +19,7 @@ import type { } from "../../components/video-editor/types"; import { type AspectRatio, isAspectRatio } from "../../utils/aspectRatioUtils"; import { CURSOR_THEME_IDS, DEFAULT_CURSOR_THEME_ID } from "../cursor/cursorThemes"; +import { isRecordingFrame, type RecordingFrame } from "../projectDefaults"; export const STYLE_PRESET_FILE_EXTENSION = ".openscreenpreset"; export const STYLE_PRESET_FORMAT = "openscreen-style-preset"; @@ -34,6 +35,7 @@ const CSS_WALLPAPER_MAX_LENGTH = 10_000; /** The appearance fields of `EditorSettingsSnapshot`, in the snapshot's own shape. */ export interface StylePresetAppearance { wallpaper: string; + frame: RecordingFrame; aspectRatio: AspectRatio; shadowIntensity: number; showBlur: boolean; @@ -146,6 +148,20 @@ function readEnum(source: Fields, key: string, allowed: readon return value as T; } +/** + * The one field a version-1 preset may omit: every preset written before the frame existed + * lacks it, and "no frame" is exactly what those presets looked like. A value that IS there + * must be one this build knows. + */ +function readFrame(source: Fields): RecordingFrame { + const value = source.frame; + if (value === undefined) return "none"; + if (!isRecordingFrame(value)) { + throw new TypeError("Style preset frame must be one of: none, window-light, window-dark."); + } + return value; +} + const HEX_COLOR_RE = /^#(?:[0-9a-f]{3,4}|[0-9a-f]{6}|[0-9a-f]{8})$/i; const COLOR_FUNCTION_RE = /^(?:rgba?|hsla?|hwb|lab|lch|oklab|oklch|color)\(.*\)$/i; const GRADIENT_RE = /^(?:repeating-)?(?:linear|radial|conic)-gradient\(.*\)$/i; @@ -203,8 +219,9 @@ export function parseStylePresetWallpaper(value: unknown, key = "wallpaper"): st * guessing a factory value for it would apply something the author never chose. The one * lenient field is `cursorTheme`: themes come and go between builds, so an id this build * does not ship falls back to the default cursor instead of rejecting a preset that is - * otherwise sound (the editor does the same when it renders one). Unknown extra keys are - * dropped. + * otherwise sound (the editor does the same when it renders one). `frame` may be absent: it + * postdates version 1, and a preset saved before it had no frame (see `readFrame`). Unknown + * extra keys are dropped. */ export function parseStylePresetAppearance(value: unknown): StylePresetAppearance { if (!isRecord(value)) { @@ -222,6 +239,7 @@ export function parseStylePresetAppearance(value: unknown): StylePresetAppearanc } return { wallpaper: parseStylePresetWallpaper(value.wallpaper), + frame: readFrame(value), aspectRatio: value.aspectRatio, shadowIntensity: readNumber(value, "shadowIntensity", NUMBER_RANGES.shadowIntensity), showBlur: readBoolean(value, "showBlur"), diff --git a/src/lib/ai-edition/stylePresetsEditor.test.ts b/src/lib/ai-edition/stylePresetsEditor.test.ts index 338aaa339..97192bd51 100644 --- a/src/lib/ai-edition/stylePresetsEditor.test.ts +++ b/src/lib/ai-edition/stylePresetsEditor.test.ts @@ -26,6 +26,7 @@ function styledSettings(): EditorSettingsSnapshot { return { ...DEFAULT_EDITOR_SETTINGS, wallpaper: "linear-gradient(90deg, #000, #fff)", + frame: "window-light", aspectRatio: "9:16", shadowIntensity: 0.8, showBlur: true, diff --git a/src/lib/ai-edition/stylePresetsEditor.ts b/src/lib/ai-edition/stylePresetsEditor.ts index 898c63fab..b2c0a6500 100644 --- a/src/lib/ai-edition/stylePresetsEditor.ts +++ b/src/lib/ai-edition/stylePresetsEditor.ts @@ -15,6 +15,7 @@ export function stylePresetAppearanceFromSettings( ): StylePresetAppearance { return { wallpaper: settings.wallpaper, + frame: settings.frame, aspectRatio: settings.aspectRatio, shadowIntensity: settings.shadowIntensity, showBlur: settings.showBlur, @@ -57,6 +58,7 @@ export function factoryStylePresetAppearance(): StylePresetAppearance { export function stylePresetPatch(appearance: StylePresetAppearance): EditorSettingsPatch { return { wallpaper: appearance.wallpaper, + frame: appearance.frame, aspectRatio: appearance.aspectRatio, shadowIntensity: appearance.shadowIntensity, showBlur: appearance.showBlur, diff --git a/src/lib/projectDefaults.ts b/src/lib/projectDefaults.ts index 2deb8687d..1167fd69f 100644 --- a/src/lib/projectDefaults.ts +++ b/src/lib/projectDefaults.ts @@ -1,5 +1,22 @@ +/** + * The frame drawn around the recording, a project setting like the wallpaper. "none" draws + * nothing and renders exactly as before the setting existed. + */ +export type RecordingFrame = "none" | "window-light" | "window-dark"; + +export const RECORDING_FRAMES = [ + "none", + "window-light", + "window-dark", +] as const satisfies readonly RecordingFrame[]; + +export function isRecordingFrame(value: unknown): value is RecordingFrame { + return typeof value === "string" && (RECORDING_FRAMES as readonly string[]).includes(value); +} + export interface ProjectAppearanceDefaults { wallpaper: string; + frame: RecordingFrame; aspectRatio: `${number}:${number}` | "native"; shadowIntensity: number; showBlur: boolean; @@ -32,6 +49,7 @@ export interface ProjectAppearanceDefaults { /** The factory appearance every new project starts from. */ export const DEFAULT_PROJECT_APPEARANCE: ProjectAppearanceDefaults = { wallpaper: "/wallpapers/wallpaper1.jpg", + frame: "none", aspectRatio: "16:9", shadowIntensity: 0.2, showBlur: false, diff --git a/src/native/browserShim.presets.test.ts b/src/native/browserShim.presets.test.ts index 35172982f..f4a1d9aa8 100644 --- a/src/native/browserShim.presets.test.ts +++ b/src/native/browserShim.presets.test.ts @@ -11,6 +11,7 @@ import { const APPEARANCE: StylePresetAppearance = { wallpaper: "#000000", + frame: "none", aspectRatio: "16:9", shadowIntensity: 0.2, showBlur: false, diff --git a/src/native/sceneDescription.test.ts b/src/native/sceneDescription.test.ts index a581b5439..4db9fad13 100644 --- a/src/native/sceneDescription.test.ts +++ b/src/native/sceneDescription.test.ts @@ -178,6 +178,21 @@ describe("buildSceneDescription.background", () => { }); }); +describe("buildSceneDescription.effects.frame", () => { + // Omitted for "none", like `webcamEffect`: `SceneFrame` defaults on the Rust side, so a + // frameless project serializes exactly as it did before the setting existed. + it("omits the frame when there is none", () => { + expect(buildSceneDescription(makeDoc()).effects).not.toHaveProperty("frame"); + const none = makeDoc({ legacyEditor: { frame: "none" } }); + expect(buildSceneDescription(none).effects).not.toHaveProperty("frame"); + }); + + it("carries the chosen window frame", () => { + const doc = makeDoc({ legacyEditor: { frame: "window-light" } }); + expect(buildSceneDescription(doc).effects.frame).toBe("window-light"); + }); +}); + describe("buildSceneDescription.webcamEffect", () => { // Omitted rather than sent as {mode:"none"}: the Rust side defaults the field, so every // project without an effect would otherwise carry it for nothing. diff --git a/src/native/sceneDescription.ts b/src/native/sceneDescription.ts index 4fdbeae76..7c6b6db41 100644 --- a/src/native/sceneDescription.ts +++ b/src/native/sceneDescription.ts @@ -52,6 +52,7 @@ import { webcamSizeToFraction, } from "@/lib/compositeLayout"; import { parseCssGradient, resolveLinearGradientAngle } from "@/lib/exporter/gradientParser"; +import type { RecordingFrame } from "@/lib/projectDefaults"; import type { CompositorClipInput } from "./contracts"; /** Background behind the screen. Parsed from `settings.wallpaper`. */ @@ -359,6 +360,12 @@ export interface SceneEffects { roundnessFrac: number; /** 0..1 motion blur. */ motionBlur: number; + /** + * The frame drawn around the recording. Omitted for "none", like `webcamEffect`: the + * Rust side defaults the field (`SceneFrame::None`), and a scene without a frame then + * serializes exactly as it did before the field existed. + */ + frame?: Exclude; } /** Cursor rendering, from the editor settings. */ @@ -1025,6 +1032,7 @@ export function buildSceneDescription( roundnessFrac: settings.borderRadius / Math.max(1, Math.min(outputDims.width, outputDims.height)), motionBlur: settings.motionBlurAmount, + ...(settings.frame !== "none" ? { frame: settings.frame } : {}), }, cursor: { show: settings.cursorShow, From 9369fb3e92361c583fd959a3904a8ce4e7ec49a8 Mon Sep 17 00:00:00 2001 From: EtienneLescot Date: Wed, 16 Sep 2026 14:51:11 +0200 Subject: [PATCH 2/3] fix(frames): anchor annotations and privacy masks on the framed content rect With a window frame on, the preview overlay placed its handles on the unshrunk screen box while the compositor drew the content, and the blur mask, inside the shrunk box: a blur drawn over a secret landed a title bar lower and left a readable strip. - s_ann is now the frame-shrunk box, so text, arrows, blur and zoom_k all use the content rect; - fitInWindowFrame ports fit_in_window_frame to TS, and the preview stage uses it (the native payload keeps the unshrunk rect); - tests pin the shared rect in Rust and TS, check that the framed privacy mask contains what the overlay shows, and pin the no-frame screen box bit for bit against the scene input. --- crates/compositor/src/frame_geometry.rs | 49 +++++++++++++++++-- .../compositor/tests/window_frame_render.rs | 4 +- src/components/ai-edition/PreviewCanvas.tsx | 21 ++++++-- src/lib/compositeLayout.test.ts | 20 ++++++++ src/lib/compositeLayout.ts | 34 +++++++++++++ 5 files changed, 118 insertions(+), 10 deletions(-) diff --git a/crates/compositor/src/frame_geometry.rs b/crates/compositor/src/frame_geometry.rs index 4b01fdaf0..9e7a43338 100644 --- a/crates/compositor/src/frame_geometry.rs +++ b/crates/compositor/src/frame_geometry.rs @@ -808,7 +808,8 @@ pub struct FrameGeometry { /// (`privacy_mask`). /// /// C'est `s_dst` avant le `remap_box` du zoom, donc le rect que l'app a résolu - /// (`layout.screenRect`) et que l'overlay web reçoit comme conteneur. Le contrat de + /// (`layout.screenRect`, rétréci par `fitInWindowFrame` sous un cadre) et que l'overlay web + /// reçoit comme conteneur. Le contrat de /// `SceneAnnotation` est explicite : « deliberately NOT affected by the zoom crop — the /// overlay is a sibling of the element carrying the zoom transform, so annotations hold /// still while the content zooms underneath them ». Tant que le zoom vivait dans la @@ -1579,10 +1580,11 @@ pub fn plan_frame(input: &FrameGeometryInput) -> FrameGeometry { cut, s_dst, s_dst_prev, - // La boîte écran telle qu'elle serait sans zoom : `remap_box` n'est PAS appliqué. Ni le - // cadre : c'est le rect que l'overlay web reçoit (`layout.screenRect`), et les - // annotations doivent rester sous les poignées qui les déplacent. - s_ann: s_box, + // La boîte écran telle qu'elle serait sans zoom : `remap_box` n'est PAS appliqué. Le + // cadre, lui, l'est : c'est le rect du CONTENU, celui où l'overlay web pose ses poignées + // (`fitInWindowFrame` côté TS, même calcul), et sans quoi un flou tracé sur un secret + // tombait une barre de titre plus haut que ce qu'il devait couvrir. + s_ann: s_base, s_radius, frame_min_px, w_dst, @@ -2001,6 +2003,43 @@ mod tests { // Sans zoom, l'ancre des annotations reste la boîte écran. let rest = framed_plan(&framed_scene(r#","frame":"none""#, rotation, 1.0, false)); assert_eq!(rest.s_ann, rest.s_dst); + // Et cette boîte est celle que la scène a résolue, au bit près : sans cadre, rien ne + // la rétrécit. Épinglé sur l'entrée, pas sur une mesure du code. + let want: [f32; 4] = [0.1, 0.1, 0.8, 0.8]; + assert_eq!(rest.s_dst.map(f32::to_bits), want.map(f32::to_bits)); + assert_eq!(rest.s_ann.map(f32::to_bits), want.map(f32::to_bits)); + assert_eq!(rest.s_radius.to_bits(), (0.03f32 * 1080.0).to_bits()); + } + } + + /// Sous un cadre, le masque de confidentialité couvre le rect que l'overlay web montre à + /// l'utilisateur : l'overlay pose ses poignées sur `fitInWindowFrame(screenRect)` (TS), le + /// portage de `fit_in_window_frame`. Ancré sur la boîte non rétrécie, le masque tombait une + /// barre de titre plus bas que le secret tracé, et en laissait une bande lisible. + #[test] + fn a_framed_privacy_mask_covers_what_the_overlay_shows() { + // A plat seulement : incliné, le contenu ne tombe plus dans un rect droit, et l'overlay + // web ne s'incline pas non plus (limite antérieure au cadre). + { + let rotation = "null"; + let g = framed_plan(&framed_scene(r#","frame":"window-light""#, rotation, 1.0, false)); + // Valeurs épinglées aussi dans `compositeLayout.test.ts` : les deux portages + // doivent rendre ce rect-là. + let overlay = fit_in_window_frame([0.1, 0.1, 0.8, 0.8], RENDER, false).0; + let want = [223.6416 / 1920.0, 142.56 / 1080.0, 1472.7168 / 1920.0, 828.4032 / 1080.0]; + for k in 0..4 { + assert!((overlay[k] - want[k]).abs() < 1e-5, "{overlay:?} au lieu de {want:?}"); + } + assert_eq!(g.s_ann, overlay, "{rotation}: l'ancre n'est pas le rect de l'overlay"); + for (x, y) in [(0.0, 0.0), (0.62, 0.18), (0.8, 0.9)] { + let mut a = blur_annotation(""); + (a.x, a.y) = (x, y); + let drawn = annotation_dst_in(overlay, a.x, a.y, a.w, a.h); + let m = g.privacy_mask(&a, RENDER).expect("masque"); + assert!(contains(m.dst, drawn, 1e-6), "{rotation}: masque {:?} / tracé {drawn:?}", m.dst); + // Et il ne déborde que de sa marge d'un pixel. + assert!((m.dst[1] - drawn[1]).abs() * RENDER[1] < 1.01); + } } } diff --git a/crates/compositor/tests/window_frame_render.rs b/crates/compositor/tests/window_frame_render.rs index 1144e860b..ea1a3b881 100644 --- a/crates/compositor/tests/window_frame_render.rs +++ b/crates/compositor/tests/window_frame_render.rs @@ -106,7 +106,9 @@ fn the_window_frame_renders_flat_and_tilted_in_both_themes() { let get = |t: &str, f: &str| &renders.iter().find(|((a, b), _)| *a == t && *b == f).unwrap().1; for tilt in ["flat", "iso", "left"] { - // `"none"` et la clé absente : le même rendu, à l'octet. + // `"none"` et la clé absente : le même rendu, à l'octet. Les deux se lisent + // `SceneFrame::None`, donc ce n'est qu'un garde-fou de désérialisation : la preuve que le + // chemin sans cadre n'a pas bougé est `no_frame_leaves_the_geometry_untouched`. assert!(get(tilt, "none") == get(tilt, "absent"), "{tilt}: frame none != payload sans cadre"); // Le cadre se voit, et ses deux thèmes aussi. let light = differing(get(tilt, "none"), get(tilt, "light"), 8); diff --git a/src/components/ai-edition/PreviewCanvas.tsx b/src/components/ai-edition/PreviewCanvas.tsx index 55e9ada15..cc1ebb756 100644 --- a/src/components/ai-edition/PreviewCanvas.tsx +++ b/src/components/ai-edition/PreviewCanvas.tsx @@ -49,6 +49,7 @@ import { locateVirtualPosition } from "@/lib/ai-edition/timeline/virtual-preview import { computeCameraFullscreenRect, computeCompositeLayout, + fitInWindowFrame, resolveWebcamLayoutPreset, type WebcamCompositeLayout, } from "@/lib/compositeLayout"; @@ -313,10 +314,22 @@ export function PreviewCanvas(props: PreviewCanvasProps) { }; }, [layout, cameraFullscreenProgress, frameSize]); + // The stage hosting the interactive overlays is the CONTENT rect: with a window frame the + // compositor shrinks the screen under the title bar, and every handle (annotations, privacy + // blur, zoom focus) must sit on the pixels it edits. `layout` itself stays unshrunk: the + // native scene receives it and applies the same fit. + const stageLayout = useMemo(() => { + if (!layout?.screenRect || settings.frame === "none") return layout; + return { + ...layout, + screenRect: fitInWindowFrame(layout.screenRect, layout.screenCover ?? false), + }; + }, [layout, settings.frame]); + const frameStyle = useMemo(() => buildFrameStyle(settings), [settings]); const screenStyle = useMemo( - () => buildScreenStyle(layout, settings, frameSize), - [layout, settings, frameSize], + () => buildScreenStyle(stageLayout, settings, frameSize), + [stageLayout, settings, frameSize], ); const webcamStyle = useMemo( () => buildWebcamStyle(effectiveLayout, settings, frameSize), @@ -454,8 +467,8 @@ export function PreviewCanvas(props: PreviewCanvasProps) { annotations={props.annotationRegions} selectedAnnotationId={props.selectedAnnotationId ?? null} currentTimeSec={props.currentTimeSec} - containerWidth={layout.screenRect.width} - containerHeight={layout.screenRect.height} + containerWidth={(stageLayout ?? layout).screenRect.width} + containerHeight={(stageLayout ?? layout).screenRect.height} onSelectAnnotation={props.onSelectAnnotation} onPositionChange={props.onAnnotationPositionChange} onSizeChange={props.onAnnotationSizeChange} diff --git a/src/lib/compositeLayout.test.ts b/src/lib/compositeLayout.test.ts index 0a100a679..29618f3df 100644 --- a/src/lib/compositeLayout.test.ts +++ b/src/lib/compositeLayout.test.ts @@ -2,6 +2,7 @@ import { describe, expect, it } from "vitest"; import { computeCameraFullscreenRect, computeCompositeLayout, + fitInWindowFrame, isWebcamBlockLayout, resolveWebcamReactiveZoom, type StyledRenderRect, @@ -528,3 +529,22 @@ describe("computeCameraFullscreenRect", () => { ); }); }); + +describe("fitInWindowFrame", () => { + // Same box and numbers as `a_framed_privacy_mask_covers_what_the_overlay_shows` + // (frame_geometry.rs): the preview overlay and the compositor must agree on this rect. + it("matches the native content rect of a framed screen", () => { + const r = fitInWindowFrame({ x: 192, y: 108, width: 1536, height: 864 }, false); + expect(r.x).toBeCloseTo(223.6416, 3); + expect(r.y).toBeCloseTo(142.56, 3); + expect(r.width).toBeCloseTo(1472.7168, 3); + expect(r.height).toBeCloseTo(828.4032, 3); + }); + + it("fills what the frame leaves under cover", () => { + const r = fitInWindowFrame({ x: 0, y: 0, width: 1000, height: 500 }, true); + expect(r.width).toBeCloseTo(1000 - 2 * 0.6, 6); + expect(r.y).toBeCloseTo(20, 6); + expect(r.y + r.height).toBeCloseTo(500 - 0.6, 6); + }); +}); diff --git a/src/lib/compositeLayout.ts b/src/lib/compositeLayout.ts index 11eb35a50..6959267a6 100644 --- a/src/lib/compositeLayout.ts +++ b/src/lib/compositeLayout.ts @@ -5,6 +5,40 @@ export interface RenderRect { height: number; } +/** + * Window-frame proportions, as fractions of the short side of the screen box the frame is + * fitted into. Must match `WINDOW_FRAME_BAR_FRAC` / `WINDOW_FRAME_LINE_FRAC` in + * crates/compositor/src/frame_geometry.rs. + */ +export const WINDOW_FRAME_BAR_FRAC = 0.04; +export const WINDOW_FRAME_LINE_FRAC = 0.0012; + +/** + * The content rect of the screen once a window frame is drawn around it: the screen shrinks + * so screen + frame fit in `box`. Port of `fit_in_window_frame` (frame_geometry.rs). The + * native compositor still receives the unshrunk `box` and shrinks it itself; the preview + * overlay uses this rect so annotation, blur and zoom-focus handles sit on the content the + * compositor draws, not on the title bar. + */ +export function fitInWindowFrame(box: RenderRect, cover: boolean): RenderRect { + const m = Math.min(box.width, box.height); + const bar = WINDOW_FRAME_BAR_FRAC * m; + const line = WINDOW_FRAME_LINE_FRAC * m; + const iw = Math.max(1, box.width - 2 * line); + const ih = Math.max(1, box.height - bar - line); + let sw = iw; + let sh = ih; + if (!cover) { + const ar = box.width / Math.max(1e-4, box.height); + if (iw / ih > ar) sw = ih * ar; + else sh = iw / ar; + } + const fh = sh + bar + line; + const cx = box.x + box.width / 2; + const cy = box.y + box.height / 2; + return { x: cx - sw / 2, y: cy - fh / 2 + bar, width: sw, height: sh }; +} + /** Floor for the reactive webcam multiplier so the camera never shrinks below ~35% at deep zoom. */ export const WEBCAM_REACTIVE_ZOOM_MIN_SCALE = 0.35; From fdc22fc6cb1b85f4ee07f29ce25740222a202c08 Mon Sep 17 00:00:00 2001 From: EtienneLescot Date: Thu, 17 Sep 2026 19:17:13 +0200 Subject: [PATCH 3/3] test(frames): cover the window frame menu persisting each pick --- .../ai-edition/fitClipShapes.test.tsx | 20 +++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/src/components/ai-edition/fitClipShapes.test.tsx b/src/components/ai-edition/fitClipShapes.test.tsx index f1317b023..d25245ffd 100644 --- a/src/components/ai-edition/fitClipShapes.test.tsx +++ b/src/components/ai-edition/fitClipShapes.test.tsx @@ -216,3 +216,23 @@ describe("fitting a clip is an action, and a choice only when there is one", () expect(screen.queryByRole("note")).not.toBeInTheDocument(); }); }); + +describe("the window frame menu persists the pick", () => { + it("writes each frame to the document", async () => { + mount(documentWithShapes([[1920, 1080]])); + // Starts at "none", so each pick below moves the stored value. + for (const [label, frame] of [ + ["Light", "window-light"], + ["Dark", "window-dark"], + ["None", "none"], + ] as const) { + fireEvent.click(screen.getByRole("button", { name: "Window" })); + fireEvent.click(within(screen.getByRole("menu")).getByRole("menuitem", { name: label })); + await waitFor(() => + expect( + (useProjectStore.getState().document?.legacyEditor as Record)?.frame, + ).toBe(frame), + ); + } + }); +});