diff --git a/crates/compositor/src/compositor_linux.rs b/crates/compositor/src/compositor_linux.rs index 03a0aa6b9..5c4396c0d 100644 --- a/crates/compositor/src/compositor_linux.rs +++ b/crates/compositor/src/compositor_linux.rs @@ -33,7 +33,8 @@ use crate::ffi::AVFrame; // Re-exports que le code partage (live.rs, compositor-view-napi) consomme via // `crate::compositor::…`, a l'identique de `compositor_macos`. pub use crate::frame_geometry::{ - live_params_from_scene, webcam_shape_code, FIXTURE_FRAMES, LayerCB, LiveParams, OUT_H, OUT_W, + chroma_key_uniform, live_params_from_scene, webcam_shape_code, FIXTURE_FRAMES, LayerCB, + LiveParams, OUT_H, OUT_W, }; use crate::frame_geometry::{ cursor_sprite_dst, parse_hex, plan_cursor, plan_frame, CursorPlacement, CursorPlanInput, @@ -44,10 +45,22 @@ use crate::scene::{Scene, SceneBackground}; const LAYER_WGSL: &str = include_str!("vk_shaders/layer.wgsl"); const BLUR_WGSL: &str = include_str!("vk_shaders/blur.wgsl"); -/// `&LayerCB` -> `&[u8; 128]`. `LayerCB` est `#[repr(C, align(16))]`, son layout -/// EST le buffer uniforme WGSL (16 vec4 + 1 vec2 + 2 f32 = 128 octets). +/// `&LayerCB` -> ses octets bruts. `LayerCB` est `#[repr(C, align(16))]`, son layout EST le +/// buffer uniforme WGSL. +/// +/// La taille est DÉRIVÉE, pas écrite : elle l'était en dur (128), ce qui rendait tout ajout de +/// champ silencieusement tronquant — le struct grandit, la copie non, et le shader lit du +/// vieux contenu sans que rien ne lève. `min_binding_size` plus bas suit la même règle. fn layer_bytes(cb: &LayerCB) -> &[u8] { - unsafe { std::slice::from_raw_parts(cb as *const LayerCB as *const u8, 128) } + unsafe { + std::slice::from_raw_parts(cb as *const LayerCB as *const u8, std::mem::size_of::()) + } +} + +/// Taille du buffer uniforme de calque, pour `min_binding_size`. `unwrap` : `size_of` d'un +/// struct non vide n'est jamais 0, la seule condition d'échec de `BufferSize::new`. +fn layer_uniform_size() -> wgpu::BufferSize { + wgpu::BufferSize::new(std::mem::size_of::() as u64).unwrap() } /// Une copie RT -> staging DEJA SOUMISE, dont le mapping est arme mais pas @@ -226,7 +239,7 @@ impl Compositor { ty: wgpu::BindingType::Buffer { ty: wgpu::BufferBindingType::Uniform, has_dynamic_offset: false, - min_binding_size: wgpu::BufferSize::new(128), + min_binding_size: Some(layer_uniform_size()), }, count: None, }, @@ -308,7 +321,7 @@ impl Compositor { ty: wgpu::BindingType::Buffer { ty: wgpu::BufferBindingType::Uniform, has_dynamic_offset: false, - min_binding_size: wgpu::BufferSize::new(128), + min_binding_size: Some(layer_uniform_size()), }, count: None, }, @@ -1249,6 +1262,9 @@ impl Compositor { // precedente, et un rect source qui ne correspond pas au calque // dessine ferait diverger la trainee vers une zone de la texture qui // n'a jamais ete affichee. Seul `dst_prev` porte le mouvement. + // + // Incrustation couleur : le SEUL draw qui la porte (cf. `compositor_windows`). + let (chroma_key, chroma_fx) = chroma_key_uniform(&lp); let cb = LayerCB { dst: g.w_dst, src: [u0, cv0, u1, cv1], @@ -1259,6 +1275,8 @@ impl Compositor { src_prev: [u0, cv0, u1, cv1], dst_prev: g.w_dst_prev, mb: [g.mb_taps, 1.0, 1.0, 0.0], + chroma_key, + chroma_fx, ..Default::default() }; self.make_bind(&cb, Some((wy, wuv)), &dummy) @@ -1268,9 +1286,13 @@ impl Compositor { // vertical-stack) : la camera y est collee a l'ecran comme une tuile, // et une ombre entre les deux dessinerait une couture. Meme condition // que macOS. + // `!lp.chroma_enabled` : cf. `compositor_windows` — l'ombre est celle de la bulle + // opaque, et l'incrustation supprime la bulle. La garder laisse un rectangle sombre + // visible derriere le sujet detoure. let webcam_shadow = (cfg.shadow && g.shape_fade > 0.0 && webcam_draw.is_some() + && !lp.chroma_enabled && !matches!( g.scene_preset.as_deref(), Some("dual-frame") | Some("vertical-stack") diff --git a/crates/compositor/src/compositor_macos.rs b/crates/compositor/src/compositor_macos.rs index cffc9395d..10df044a0 100644 --- a/crates/compositor/src/compositor_macos.rs +++ b/crates/compositor/src/compositor_macos.rs @@ -32,7 +32,8 @@ use crate::ffi::AVFrame; // les deux backends — cf. `frame_geometry`, qui documente les divergences que // l'unification a corrigées. pub use crate::frame_geometry::{ - live_params_from_scene, webcam_shape_code, FIXTURE_FRAMES, LayerCB, LiveParams, OUT_H, OUT_W, + chroma_key_uniform, 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, SCREEN_SHADOW_SPREAD_FRAC, WEBCAM_SHADOW_OFFSET_FRAC, WEBCAM_SHADOW_OPACITY, @@ -1618,7 +1619,15 @@ impl Compositor { g.scene_preset.as_deref(), Some("dual-frame") | Some("vertical-stack") ); - if cfg.shadow && !webcam_is_block && g.shape_fade > 0.0 { + // ...et PAS quand l'incrustation couleur est active. L'ombre est celle de la + // BULLE : un rectangle arrondi opaque qui flotte au-dessus de la scene. Detourer + // le fond supprime cette bulle — il ne reste que le sujet — et l'ombre devient + // alors un rectangle sombre visible DERRIERE lui, la ou le fond vient d'etre + // rendu transparent. Mesure sur un export de controle : le fond detoure ressortait + // a 0,65x la couleur de l'ecran, soit exactement WEBCAM_SHADOW_OPACITY. + // Meme raison que `shape_fade` retire l'ombre au plein ecran : plus de bulle, + // plus d'ombre. + if cfg.shadow && !webcam_is_block && g.shape_fade > 0.0 && !lp.chroma_enabled { self.draw_shadow( enc, g.w_dst, @@ -1629,6 +1638,8 @@ impl Compositor { WEBCAM_SHADOW_OPACITY * g.shape_fade, ); } + // Incrustation couleur : le SEUL draw qui la porte (cf. `compositor_windows`). + let (chroma_key, chroma_fx) = chroma_key_uniform(&lp); self.draw_video( enc, &LayerCB { @@ -1641,6 +1652,8 @@ impl Compositor { src_prev: [u0, cv0, u1, cv1], dst_prev: g.w_dst_prev, mb: [g.mb_taps, 1.0, 1.0, 0.0], + chroma_key, + chroma_fx, ..Default::default() }, wy, diff --git a/crates/compositor/src/compositor_windows.rs b/crates/compositor/src/compositor_windows.rs index 51c3a3a28..f929340b0 100644 --- a/crates/compositor/src/compositor_windows.rs +++ b/crates/compositor/src/compositor_windows.rs @@ -6,8 +6,8 @@ use crate::config::Cfg; // pour le pourquoi. `pub use` sur les constantes : `pipeline_windows.rs`, `live.rs` et // `crates/poc-d3d/src/app.rs` les lisent via `crate::compositor::…`, et ce chemin doit // rester valable. -pub use crate::frame_geometry::{live_params_from_scene, webcam_shape_code, LayerCB, - LiveParams, FIXTURE_FRAMES, HALF_H, HALF_W, OUT_H, OUT_W}; +pub use crate::frame_geometry::{chroma_key_uniform, live_params_from_scene, webcam_shape_code, + LayerCB, LiveParams, FIXTURE_FRAMES, HALF_H, HALF_W, OUT_H, OUT_W}; 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, @@ -1538,7 +1538,15 @@ impl Compositor { scene_preset.as_deref(), Some("dual-frame") | Some("vertical-stack"), ); - if cfg.shadow && !webcam_is_block && shape_fade > 0.0 { + // ...et PAS quand l'incrustation couleur est active. L'ombre est celle de la + // BULLE : un rectangle arrondi opaque qui flotte au-dessus de la scene. Detourer + // le fond supprime cette bulle — il ne reste que le sujet — et l'ombre devient + // alors un rectangle sombre visible DERRIERE lui, la ou le fond vient d'etre + // rendu transparent. Mesure sur un export de controle : le fond detoure ressortait + // a 0,65x la couleur de l'ecran, soit exactement WEBCAM_SHADOW_OPACITY. + // Meme raison que `shape_fade` retire l'ombre au plein ecran : plus de bulle, + // plus d'ombre. + if cfg.shadow && !webcam_is_block && shape_fade > 0.0 && !lp.chroma_enabled { let strength = WEBCAM_SHADOW_OPACITY * shape_fade; self.draw_shadow( w_dst, @@ -1549,6 +1557,11 @@ impl Compositor { strength, ); } + // Incrustation couleur : le SEUL draw qui la porte. L'écran partage le mode 0 et + // garde `..Default::default()`, donc ses deux `float4` sont nuls et la branche du + // shader est uniformément fausse pour lui — coût nul, et aucun risque d'incruster + // l'enregistrement d'écran. + let (chroma_key, chroma_fx) = chroma_key_uniform(&lp); self.draw_video( &LayerCB { dst: w_dst, @@ -1560,6 +1573,8 @@ impl Compositor { src_prev: [u0, sv0, u1, sv1], // src fixe (pas de zoom webcam) dst_prev: w_dst_prev, mb: [mb_taps, 1.0, 1.0, 0.0], + chroma_key, + chroma_fx, ..Default::default() }, &wy, diff --git a/crates/compositor/src/frame_geometry.rs b/crates/compositor/src/frame_geometry.rs index 655b746b7..ef7384499 100644 --- a/crates/compositor/src/frame_geometry.rs +++ b/crates/compositor/src/frame_geometry.rs @@ -55,6 +55,18 @@ pub struct LayerCB { pub src_prev: [f32; 4], pub dst_prev: [f32; 4], pub mb: [f32; 4], // mb[0] = nombre de taps de motion blur + /// Incrustation couleur, mode 0 uniquement : [keyCb, keyCr, seuil, adoucissement]. + /// Les deux seuils sont en unités de TEXTURE du plan UV (ce que `texUV.Sample` rend), + /// donc le shader n'a aucune conversion à faire — cf. `chroma_key_uniform`. + pub chroma_key: [f32; 4], + /// [actif (0/1), désaturation du débord, 0, 0]. + /// + /// Deux `float4` dédiés plutôt qu'un squat des cases libres de `fx`/`mb` : ces deux + /// champs signifient déjà cinq choses différentes selon le mode (2/8/9/10/12/13), et un + /// sixième sens aliasé est exactement le piège que le reste du fichier passe son temps à + /// documenter. `Default` les met à zéro, donc TOUS les draws existants (écran, curseur, + /// annotations, ombres) restent corrects et désactivés sans être touchés. + pub chroma_fx: [f32; 4], } pub const OUT_W: u32 = 1920; @@ -623,6 +635,17 @@ pub struct LiveParams { /// `timeline_walk.rs` for every export. Defaults `true` (draw) so fixture/bench renders /// and any caller that never sets it keep their old behavior. pub has_webcam: bool, + /// Incrustation couleur (fond vert) de la CAMÉRA seule. `false` par défaut → aucun + /// changement pour un appelant qui l'ignore (bench, fixtures, GUI standalone). + pub chroma_enabled: bool, + /// Couleur clé en RGB 0..1, déjà parsée depuis le hex (`parse_hex_color`). + pub chroma_color: [f32; 3], + /// 0..1, espace SLIDER. La conversion en distance de chrominance vit dans + /// `chroma_key_uniform`, pas ici : deux chemins alimentent ces champs (la scène pour + /// l'export, l'inspector pour la preview) et ils doivent aboutir au même uniforme. + pub chroma_similarity: f32, + pub chroma_smoothness: f32, + pub chroma_spill: f32, } fn same_source_path(a: &str, b: &str) -> bool { @@ -663,10 +686,20 @@ impl Default for LiveParams { cursor_bounce_scale: 1.0, cursor_motion_blur: 0.0, has_webcam: true, + chroma_enabled: false, + chroma_color: DEFAULT_CHROMA_COLOR, + chroma_similarity: 0.32, + chroma_smoothness: 0.1, + chroma_spill: 0.3, } } } +/// `#00b140`, le vert « studio » de la SMPTE — le fond réellement vendu comme fond vert, là +/// où le `#00ff00` pur n'est ni un tissu ni une peinture. Doit rester identique au défaut TS +/// (`DEFAULT_WEBCAM_CHROMA_KEY_COLOR`). +const DEFAULT_CHROMA_COLOR: [f32; 3] = [0.0, 0.694, 0.251]; + /// "rectangle"|"circle"|"square"|"rounded" -> code webcam_shape (0/1/2/3). Partagé entre le /// live (`live.rs::set_param_str`) et l'export (construit `LiveParams` depuis la scène) — une /// seule table de vérité pour ce mapping. @@ -679,6 +712,61 @@ pub fn webcam_shape_code(shape: &str) -> u32 { } } +/// Ce que vaut « Similarity 1 » en distance de chrominance : le rayon CENTRE → COIN du plan +/// UV. Les sliders 0..1 se projettent sur cette échelle, pas sur 0..1 — un seuil de « 1 » en +/// unités de texture n'aurait aucun rapport avec la taille réelle de l'espace. +/// +/// La géométrie : Cb et Cr valent chacun ±0,5 au plus, et le plan les stocke en +/// `(128 + 224·c) / 255`, donc chaque composante couvre `224/255 = 0,8784`. La diagonale +/// complète du carré vaut `0,8784·√2 = 1,2423`, et son DEMI — la distance du centre à un +/// coin — `0,6211`. +/// +/// C'est le demi et non la diagonale entière, pour une raison qui se vérifie : une clé +/// quelconque est au plus à un rayon de n'importe quelle couleur atteignable dès lors +/// qu'elle est proche du centre, et les fonds réels (vert studio à 0,313 du neutre) le sont. +/// Prendre la diagonale entière doublerait l'échelle sans rien rendre atteignable de plus, +/// et tasserait toute la plage utile — un fond vert se détoure autour de 0,2, soit le +/// réglage par défaut 0,32 — dans le premier dixième du slider. +/// +/// Conséquence exacte dont dépend le golden `compose_linux_incrustation_couleur` : une clé +/// posée sur le NEUTRE (`#808080`, pile au centre) avec Similarity à 1 couvre tout le plan, +/// donc efface n'importe quel pixel, quel que soit le contenu de la fixture. +const CHROMA_MAX_DIST: f32 = 0.621_144_8; + +/// `LiveParams` → les deux `float4` d'incrustation du `LayerCB`. +/// +/// LE point de convergence des deux chemins. La preview alimente `LiveParams` depuis +/// l'inspector (`live.rs`), l'export depuis la scène (`live_params_from_scene`) ; les deux +/// aboutissent ici, donc les seuils ne peuvent pas être mappés différemment de part et +/// d'autre. C'est aussi le seul endroit qui connaît la matrice BT.709 inverse — le shader +/// compare directement à ce que `texUV.Sample` lui rend, sans aucune conversion. +/// +/// L'inverse de `yuv709_limited` (cf. `rgb2uv` dans `shaders.hlsl`) : +/// yp = 0,2126·r + 0,7152·g + 0,0722·b +/// cb = (b − yp) / 1,8556 cr = (r − yp) / 1,5748 +/// plan UV = (128 + 224·c) / 255 +/// +/// Comparer la clé à l'inverse de la matrice que le shader applique lui-même rend la +/// distance nulle sur la couleur choisie PAR CONSTRUCTION, quelle que soit la colorimétrie +/// réelle du fichier. +pub fn chroma_key_uniform(lp: &LiveParams) -> ([f32; 4], [f32; 4]) { + if !lp.chroma_enabled { + return ([0.0; 4], [0.0; 4]); + } + let [r, g, b] = lp.chroma_color; + let yp = 0.2126 * r + 0.7152 * g + 0.0722 * b; + let cb = (b - yp) / 1.8556; + let cr = (r - yp) / 1.5748; + let key_cb = (128.0 + 224.0 * cb) / 255.0; + let key_cr = (128.0 + 224.0 * cr) / 255.0; + let similarity = lp.chroma_similarity.clamp(0.0, 1.0) * CHROMA_MAX_DIST; + let smoothness = lp.chroma_smoothness.clamp(0.0, 1.0) * CHROMA_MAX_DIST; + ( + [key_cb, key_cr, similarity, smoothness], + [1.0, lp.chroma_spill.clamp(0.0, 1.0), 0.0, 0.0], + ) +} + /// Construit les `LiveParams` équivalents à ce que l'inspector pousse en live, mais depuis la /// scène de l'app — l'export est un rendu one-shot sans historique de sliders, donc il doit lire /// directement la config déjà posée dans la scène plutôt que dupliquer un mécanisme d'inspector. @@ -696,6 +784,29 @@ pub fn live_params_from_scene(s: &crate::scene::Scene) -> LiveParams { webcam_size_scale: s.layout.webcam_size, webcam_mirror: s.layout.webcam_mirror, webcam_shape: webcam_shape_code(&s.layout.webcam_shape), + // Absent de la scène ⇒ incrustation coupée : c'est ce que l'app envoie quand + // l'utilisateur l'a désactivée, et c'est aussi ce que produit tout payload antérieur. + chroma_enabled: s.layout.chroma_key.is_some(), + chroma_color: s + .layout + .chroma_key + .as_ref() + .and_then(|k| parse_hex(&k.color)) + .map(|c| [c[0], c[1], c[2]]) + .unwrap_or(DEFAULT_CHROMA_COLOR), + chroma_similarity: s + .layout + .chroma_key + .as_ref() + .map(|k| k.similarity) + .unwrap_or(0.32), + chroma_smoothness: s + .layout + .chroma_key + .as_ref() + .map(|k| k.smoothness) + .unwrap_or(0.1), + chroma_spill: s.layout.chroma_key.as_ref().map(|k| k.spill).unwrap_or(0.3), cursor_size_scale: s.cursor.size, cursor_bounce_scale: s.cursor.click_bounce, cursor_motion_blur: s.cursor.motion_blur, @@ -1532,7 +1643,7 @@ mod tests { #[test] fn layer_cb_matches_the_shader_constant_buffer() { use std::mem::{align_of, offset_of, size_of}; - assert_eq!(size_of::(), 128); + assert_eq!(size_of::(), 160); assert_eq!(align_of::(), 16); for (name, got, want) in [ ("dst", offset_of!(LayerCB, dst), 0), @@ -1545,11 +1656,76 @@ mod tests { ("src_prev", offset_of!(LayerCB, src_prev), 80), ("dst_prev", offset_of!(LayerCB, dst_prev), 96), ("mb", offset_of!(LayerCB, mb), 112), + ("chroma_key", offset_of!(LayerCB, chroma_key), 128), + ("chroma_fx", offset_of!(LayerCB, chroma_fx), 144), ] { assert_eq!(got, want, "offset de `{name}`"); } } + /// La couleur choisie doit tomber à distance NULLE de la clé — c'est toute la raison de + /// dériver `chroma_key_uniform` de l'inverse exact de `yuv709_limited` plutôt que d'une + /// matrice « proche ». On refait ici le trajet du shader : couleur → uniforme → plan UV. + #[test] + fn the_picked_colour_lands_exactly_on_the_key() { + for color in [[0.0, 0.694, 0.251], [0.0, 1.0, 0.0], [0.0, 0.0, 1.0]] { + let lp = LiveParams { + chroma_enabled: true, + chroma_color: color, + ..LiveParams::default() + }; + let (key, fx) = chroma_key_uniform(&lp); + assert_eq!(fx[0], 1.0, "actif"); + // Ce que le plan UV contiendrait pour cette couleur (`rgb2uv` du shader). + let [r, g, b] = color; + let yp = 0.2126 * r + 0.7152 * g + 0.0722 * b; + let uv_cb = (128.0 + 224.0 * (b - yp) / 1.8556) / 255.0; + let uv_cr = (128.0 + 224.0 * (r - yp) / 1.5748) / 255.0; + let dist = ((uv_cb - key[0]).powi(2) + (uv_cr - key[1]).powi(2)).sqrt(); + assert!(dist < 1e-6, "distance {dist} pour {color:?}"); + } + } + + /// Coupée, l'incrustation doit rendre un uniforme entièrement nul : c'est le drapeau que + /// le shader teste, et c'est aussi ce que `LayerCB::default()` écrit pour tous les autres + /// calques (écran, curseur, annotations) qui partagent le mode 0. + #[test] + fn a_disabled_key_emits_a_zeroed_uniform() { + let (key, fx) = chroma_key_uniform(&LiveParams::default()); + assert_eq!(key, [0.0; 4]); + assert_eq!(fx, [0.0; 4]); + } + + /// `CHROMA_MAX_DIST` doit rester le rayon CENTRE → COIN du plan de chrominance. + /// + /// C'est la propriété dont dépend le golden `compose_linux_incrustation_couleur` : une + /// clé neutre à Similarity 1 y efface tout le calque, ce qui n'est vrai que si le seuil + /// atteint exactement le coin. Une constante « arrondie au propre » (0,62) ferait + /// survivre une frange de pixels saturés et le golden échouerait sur une fixture, pas + /// sur une régression — d'où cette vérification, qui dit POURQUOI la valeur est celle-là. + #[test] + fn max_similarity_from_a_neutral_key_reaches_every_corner() { + let neutral = LiveParams { + chroma_enabled: true, + chroma_color: [0.5, 0.5, 0.5], // Cb = Cr = 0 → pile au centre du plan + chroma_similarity: 1.0, + ..LiveParams::default() + }; + let (key, _) = chroma_key_uniform(&neutral); + let centre = 128.0 / 255.0; + assert!((key[0] - centre).abs() < 1e-6, "clé neutre hors du centre : {}", key[0]); + assert!((key[1] - centre).abs() < 1e-6, "clé neutre hors du centre : {}", key[1]); + // Coin du plan : les deux composantes à leur extrême (Cb = Cr = ±0,5). + let corner = (128.0 + 224.0 * 0.5) / 255.0; + let to_corner = + ((corner - key[0]).powi(2) + (corner - key[1]).powi(2)).sqrt(); + assert!( + key[2] >= to_corner - 1e-5, + "seuil max {} < distance au coin {to_corner}", + key[2], + ); + } + /// Le pivot doit rester collé à `center` quand le sprite grandit — c'est exactement ce qui /// était cassé (ancrage centré en dur : la pointe s'éloignait proportionnellement à la /// taille). On dessine la même flèche à deux tailles et on vérifie que le point désigné diff --git a/crates/compositor/src/live.rs b/crates/compositor/src/live.rs index 552d2264a..f57fde083 100644 --- a/crates/compositor/src/live.rs +++ b/crates/compositor/src/live.rs @@ -631,10 +631,21 @@ struct InspectorParams { cursor_smoothing: f32, /// 0..1 : force du flou de mouvement DU CURSEUR (indépendant du motion blur écran). cursor_motion_blur: f32, + /// Incrustation couleur de la caméra. Ces champs DOIVENT exister ici en plus de la scène : + /// la preview construit ses `LiveParams` depuis cet inspector et ne lit jamais la copie de + /// la scène (cf. `render_thread`), alors que l'export fait l'inverse via + /// `live_params_from_scene`. Ne câbler qu'un des deux chemins donne une incrustation qui + /// marche à l'écran et disparaît à l'export, ou le contraire. + chroma_enabled: bool, + chroma_color: [f32; 3], + chroma_similarity: f32, + chroma_smoothness: f32, + chroma_spill: f32, } impl Default for InspectorParams { fn default() -> Self { + let chroma = LiveParams::default(); Self { bg_blur: false, bg_color: [0.10, 0.11, 0.14, 1.0], @@ -650,6 +661,13 @@ impl Default for InspectorParams { cursor_bounce_scale: 1.0, cursor_smoothing: 0.0, cursor_motion_blur: 0.0, + // Repris de `LiveParams::default()` plutôt que recopiés : preview et export + // doivent partir du même état, et une valeur écrite deux fois finit par diverger. + chroma_enabled: chroma.chroma_enabled, + chroma_color: chroma.chroma_color, + chroma_similarity: chroma.chroma_similarity, + chroma_smoothness: chroma.chroma_smoothness, + chroma_spill: chroma.chroma_spill, } } } @@ -904,6 +922,7 @@ impl LiveView { match key { "backgroundBlur" => p.bg_blur = value, "webcamMirror" => p.webcam_mirror = value, + "webcamChromaEnabled" => p.chroma_enabled = value, "cursorShow" => p.cursor_show = value, _ => {} } @@ -922,6 +941,11 @@ impl LiveView { "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), "webcamSize" => p.webcam_size_scale = v.max(0.05), + // Espace SLIDER 0..1 : leur passage en distance de chrominance vit dans + // `chroma_key_uniform`, avec celui de l'export. + "webcamChromaSimilarity" => p.chroma_similarity = v.clamp(0.0, 1.0), + "webcamChromaSmoothness" => p.chroma_smoothness = v.clamp(0.0, 1.0), + "webcamChromaSpill" => p.chroma_spill = v.clamp(0.0, 1.0), "cursorSize" => p.cursor_size_scale = v.max(0.0), "cursorClickBounce" => p.cursor_bounce_scale = v.max(0.0), "cursorSmoothing" => p.cursor_smoothing = v.clamp(0.0, 1.0), @@ -943,6 +967,15 @@ impl LiveView { "webcamShape" => { p.webcam_shape = crate::compositor::webcam_shape_code(value); } + "webcamChromaColor" => { + // Une couleur illisible laisse la précédente en place plutôt que de + // retomber sur un défaut : l'utilisateur tape dans un champ hex, et + // reverdir l'incrustation à chaque frappe incomplète serait pire que + // de ne rien faire jusqu'à ce que la valeur soit complète. + if let Some(c) = crate::frame_geometry::parse_hex(value) { + p.chroma_color = [c[0], c[1], c[2]]; + } + } _ => {} } } @@ -1491,6 +1524,11 @@ unsafe fn render_thread( cursor_bounce_scale: ip.cursor_bounce_scale, cursor_motion_blur: ip.cursor_motion_blur, has_webcam: has_real_webcam, + chroma_enabled: ip.chroma_enabled, + chroma_color: ip.chroma_color, + chroma_similarity: ip.chroma_similarity, + chroma_smoothness: ip.chroma_smoothness, + chroma_spill: ip.chroma_spill, }); // Lissage ressort-amortisseur : re-génère la piste (240 Hz) uniquement quand la valeur // change (pas à chaque frame — le resample+ressort parcourt tout l'enregistrement). diff --git a/crates/compositor/src/scene.rs b/crates/compositor/src/scene.rs index 2d20e233b..78db61f8c 100644 --- a/crates/compositor/src/scene.rs +++ b/crates/compositor/src/scene.rs @@ -41,6 +41,17 @@ pub struct SceneLayout { /// bloc découpent toujours un rectangle (côté app, cf. `computeCompositeLayout`). pub webcam_shape: String, pub webcam_mirror: bool, + /// Incrustation couleur (fond vert) de la caméra, ou None quand l'utilisateur l'a coupée. + /// + /// L'app envoie la couleur en HEX, pas en coordonnées de chrominance : la conversion doit + /// de toute façon exister ici (le chemin preview pousse la couleur via un paramètre + /// STRING, cf. `live.rs::set_param_str`), et la refaire côté TS mettrait deux copies de la + /// même matrice BT.709 de part et d'autre de la frontière, libres de diverger en silence. + /// + /// `#[serde(default)]` : le champ est absent de tous les payloads antérieurs et de toutes + /// les fixtures — absent ⇒ None ⇒ aucune incrustation, exactement l'ancien rendu. + #[serde(default)] + pub chroma_key: Option, /// position normalisée (0..1) du centre webcam, ou None → défaut du preset. pub webcam_position: Option, /// la webcam rétrécit pendant un zoom actif. @@ -108,6 +119,19 @@ pub struct SceneLayout { pub webcam_radius_frac: Option, } +/// Incrustation couleur de la caméra. Miroir de `SceneChromaKey` (TS, +/// `src/native/sceneDescription.ts`). Les seuils sont en espace SLIDER 0..1 ; leur passage en +/// distance de chrominance se fait dans `chroma_key_uniform` — une seule fois, un seul endroit. +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct SceneChromaKey { + /// "#rrggbb". + pub color: String, + pub similarity: f32, + pub smoothness: f32, + pub spill: f32, +} + /// La moitié du layout qui dépend de la FORME de la source, résolue pour un clip. /// Voir `SceneLayout::layout_by_clip`. /// @@ -601,6 +625,20 @@ mod tests { let s = Scene::from_json(json).expect("parse sans webcam_rect"); assert!(s.layout.webcam_rect.is_none()); assert_eq!(s.layout.preset, "picture-in-picture"); + // Même payload : aucune mention de l'incrustation ⇒ coupée. C'est le cas de TOUS les + // projets antérieurs, et c'est ce qui garantit qu'aucun d'eux ne se met à incruster. + assert!(s.layout.chroma_key.is_none()); + } + + #[test] + fn parses_the_chroma_key_payload() { + let json = r##"{"clips":[],"layout":{"preset":"picture-in-picture","webcamSize":1,"webcamShape":"rectangle","webcamMirror":false,"chromaKey":{"color":"#00b140","similarity":0.4,"smoothness":0.15,"spill":0.25},"webcamPosition":null,"webcamReactiveZoom":false},"effects":{"padding":0,"blur":false,"shadow":0,"roundnessFrac":0,"motionBlur":0},"background":{"kind":"color","color":"#000000"},"zoomRegions":[],"cursor":{"show":false,"size":1,"smoothing":0,"motionBlur":0,"clickBounce":0,"clipToBounds":false,"theme":"default"},"cropByClip":[],"output":{"width":1920,"height":1080,"fps":null}}"##; + let s = Scene::from_json(json).expect("parse avec chromaKey"); + let k = s.layout.chroma_key.expect("chroma_key présent"); + assert_eq!(k.color, "#00b140"); + assert_eq!(k.similarity, 0.4); + assert_eq!(k.smoothness, 0.15); + assert_eq!(k.spill, 0.25); } } diff --git a/crates/compositor/src/shaders.hlsl b/crates/compositor/src/shaders.hlsl index b9d438d7d..bbdf4a0bd 100644 --- a/crates/compositor/src/shaders.hlsl +++ b/crates/compositor/src/shaders.hlsl @@ -13,6 +13,8 @@ cbuffer Layer : register(b0) float4 src_prev; // src à la frame précédente (flou de mouvement par vélocité) float4 dst_prev; // dst à la frame précédente float4 mb; // mb.x = nombre de taps de motion blur (1 = désactivé) + float4 chroma_key; // mode 0 : keyCb, keyCr, seuil, adoucissement (unités du plan UV) + float4 chroma_fx; // chroma_fx.x = incrustation active, .y = désaturation du débord }; struct VSOut @@ -410,6 +412,9 @@ float4 ps_main(VSOut i) : SV_Target } float3 rgb; + // Couverture de l'incrustation couleur : 1 = pixel gardé. Neutre par défaut, écrite + // uniquement par le mode 0 ci-dessous, appliquée à `alpha` après le if/else. + float chroma_keep = 1.0; if (mode < 0.5) { // flou de mouvement par vélocité (§8) : pour CE pixel sortie, uv à la frame @@ -435,13 +440,52 @@ float4 ps_main(VSOut i) : SV_Target } rgb = acc / (float) taps; } + + // INCRUSTATION COULEUR (fond vert), CAMÉRA uniquement. Le mode 0 dessine aussi + // l'écran, qui laisse ces deux float4 à zéro : la branche est alors uniformément + // fausse, donc gratuite, et l'enregistrement d'écran ne peut pas être incrusté par + // accident. + // + // DEDANS le mode 0, et pas dans la queue commune que le mode 1 (couleur pleine) + // traverse aussi : le mode 0 est le seul à avoir une texture NV12 liée, donc c'est + // le seul où `texUV` veut dire quelque chose. Y enfermer le bloc rend ça structurel + // au lieu de dépendre de la discipline des appelants à laisser `chroma_fx` à zéro + // sur les autres calques. (Vérifié, contrairement à ce qu'une version antérieure de + // ce commentaire affirmait : naga accepte parfaitement `textureSample` dans la + // queue commune — l'analyse d'uniformité n'est PAS la raison.) + // + // La clé est calculée dans le plan CHROMA, pas en RGB. La source est du NV12 : + // `texUV` est déjà là, et ignorer la luma est précisément ce qui fait tenir le + // masque quand le fond est éclairé de façon inégale (une ombre sur le tissu reste + // le même vert). Aucune conversion ici : `chroma_key.xy` arrive DÉJÀ dans les + // unités que `texUV` rend, via l'inverse exact de `yuv709_limited` (cf. + // `chroma_key_uniform`, Rust). + // + // Le plan UV est en demi-résolution, donc le bord du masque est adouci à cette + // échelle. Ça se lit comme un feather naturel sur une silhouette et ça perd les + // mèches de cheveux isolées — même compromis que `ffmpeg -vf chromakey`. + if (chroma_fx.x > 0.5) + { + float2 cbcr = texUV.Sample(samp, i.uv); + float dist = length(cbcr - chroma_key.xy); + float t0 = chroma_key.z; + // `+1e-4` : à adoucissement nul, smoothstep aurait des bornes égales et le + // masque partirait en NaN plutôt qu'en bord net. + chroma_keep = smoothstep(t0, t0 + chroma_key.w + 1e-4, dist); + // DÉBORD : près de la clé, le vert renvoyé par le fond teinte encore le sujet. + // On désature vers la luma plutôt que de retirer du vert, ce qui garde la teinte + // de la peau au lieu de la faire virer au magenta. + float spill = chroma_fx.y * saturate(1.0 - dist / max(t0 * 2.0, 1e-4)); + float luma = dot(rgb, float3(0.2126, 0.7152, 0.0722)); + rgb = lerp(rgb, float3(luma, luma, luma), spill); + } } else { rgb = color.rgb; } - float alpha = color.a; + float alpha = color.a * chroma_keep; if (radius_px > 0.0) { // `quad_px` est en px de SORTIE (le render target porte la géométrie de sortie) et diff --git a/crates/compositor/src/shaders.metal b/crates/compositor/src/shaders.metal index 64dd8d476..308676691 100644 --- a/crates/compositor/src/shaders.metal +++ b/crates/compositor/src/shaders.metal @@ -42,7 +42,7 @@ using namespace metal; // ================================================================================= // // Le moteur côté CPU upload ce buffer via `setVertexBytes` (vertex stage) et -// `setFragmentBytes` (fragment stage) avant chaque draw — la copie est de 128 octets, +// `setFragmentBytes` (fragment stage) avant chaque draw — la copie est de 160 octets, // ce qui est sous le seuil d'alignement 4K de Metal pour le mode « immediate ». struct Layer @@ -57,6 +57,8 @@ struct Layer float4 src_prev; // src à la frame précédente (flou de mouvement par vélocité) float4 dst_prev; // dst à la frame précédente float4 mb; // mb.x = nombre de taps de motion blur (1 = désactivé) + float4 chroma_key; // mode 0 : keyCb, keyCr, seuil, adoucissement (unités du plan UV) + float4 chroma_fx; // chroma_fx.x = incrustation active, .y = désaturation du débord }; // `layer` est passé en `constant Layer& [[buffer(0)]]` à chaque entry point qui le lit @@ -467,6 +469,8 @@ fragment float4 ps_main(VSOut i [[stage_in]], } float3 rgb; + // Couverture de l'incrustation couleur : 1 = pixel gardé. Cf. `shaders.hlsl`. + float chroma_keep = 1.0; if (layer.mode < 0.5) { // flou de mouvement par vélocité (§8) @@ -490,13 +494,32 @@ fragment float4 ps_main(VSOut i [[stage_in]], } rgb = acc / float(taps); } + + // INCRUSTATION COULEUR (fond vert), CAMÉRA uniquement — parité EXACTE avec la + // branche correspondante de `shaders.hlsl`, qui porte le commentaire complet : clé + // dans le plan chroma, aucune conversion ici, demi-résolution du plan UV, et + // pourquoi le bloc vit DEDANS le mode 0 (seul mode à avoir une texture NV12 liée) + // plutôt que dans la queue commune que le mode 1 traverse aussi. + if (layer.chroma_fx.x > 0.5) + { + float2 cbcr = texUV.sample(samp, i.uv).rg; + float dist = length(cbcr - layer.chroma_key.xy); + float t0 = layer.chroma_key.z; + chroma_keep = smoothstep(t0, t0 + layer.chroma_key.w + 1e-4, dist); + // `clamp` et non `saturate` : convention du fichier (cf. l'en-tete — MSL 2.0, + // `saturate` n'arrive qu'en 2.4). Ce fichier n'en contenait aucun avant. + float spill = + layer.chroma_fx.y * clamp(1.0 - dist / max(t0 * 2.0, 1e-4), 0.0, 1.0); + float luma = dot(rgb, float3(0.2126, 0.7152, 0.0722)); + rgb = mix(rgb, float3(luma), spill); + } } else { rgb = layer.color.rgb; } - float alpha = layer.color.a; + float alpha = layer.color.a * chroma_keep; if (layer.radius_px > 0.0) { float2 halfsz = layer.quad_px * 0.5; diff --git a/crates/compositor/src/vk_shaders/blur.wgsl b/crates/compositor/src/vk_shaders/blur.wgsl index c5f8f88e6..a89ed4d82 100644 --- a/crates/compositor/src/vk_shaders/blur.wgsl +++ b/crates/compositor/src/vk_shaders/blur.wgsl @@ -23,6 +23,12 @@ struct Layer { src_prev: vec4, dst_prev: vec4, mb: vec4, + // Inutilisés ici, mais le struct doit rester le MÊME que dans `layer.wgsl` : les deux + // partagent `LayerCB` côté Rust et leurs bind group layouts déclarent tous deux + // `min_binding_size = size_of::()`. Une copie restée en arrière n'échoue pas + // à la compilation du module — elle échoue à la création du bind group, loin d'ici. + chroma_key: vec4, + chroma_fx: vec4, } @group(0) @binding(0) var layer: Layer; diff --git a/crates/compositor/src/vk_shaders/layer.wgsl b/crates/compositor/src/vk_shaders/layer.wgsl index 6fb2a73ed..7cba23106 100644 --- a/crates/compositor/src/vk_shaders/layer.wgsl +++ b/crates/compositor/src/vk_shaders/layer.wgsl @@ -28,6 +28,8 @@ struct Layer { src_prev: vec4, // modes 8/12/13 : coins BR,BL du quad projeté ; mode 9 : barbe 1 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 mb: vec4, // mode 12 : mb.y = spread de la pénombre en px ; mode 9 : mb.y = demi-épaisseur du trait en px + chroma_key: vec4, // mode 0 : keyCb, keyCr, seuil, adoucissement (unités du plan UV) + chroma_fx: vec4, // chroma_fx.x = incrustation active, .y = désaturation du débord } @group(0) @binding(0) var layer: Layer; @@ -201,6 +203,9 @@ fn quad_inverse_bilinear(P: vec2, c00: vec2, c10: vec2, c11: vec2 fn fs_main(i: VsOut) -> @location(0) vec4 { var rgb: vec3; var alpha: f32; + // Couverture de l'incrustation couleur : 1 = pixel garde. Neutre par defaut, ecrite + // uniquement par le mode 0, appliquee a `alpha` dans la queue commune. + var chroma_keep: f32 = 1.0; if layer.mode < 0.5 { // Mode 0 — vidéo NV12 + flou de mouvement par vélocité (§8), port 1:1 du @@ -234,6 +239,30 @@ fn fs_main(i: VsOut) -> @location(0) vec4 { rgb = acc / f32(taps); } } + + // INCRUSTATION COULEUR (fond vert), CAMERA uniquement. L'ecran partage le mode 0 + // mais laisse `chroma_fx` a zero, donc la branche est uniformement fausse pour lui. + // + // DEDANS le mode 0, et pas dans la queue commune que le mode 1 (couleur pleine) + // traverse aussi : le mode 0 est le seul a avoir une texture NV12 liee, donc le seul + // ou `texUV` veut dire quelque chose. `shaders.hlsl` et `shaders.metal` portent le + // meme bloc au meme endroit — ce sont des ports 1:1. + // + // Ce n'est PAS une contrainte d'uniformite : naga valide les deux placements (teste + // avec naga 24 sur ce fichier). Une version anterieure de ce commentaire l'affirmait + // — c'etait faux. + // + // Commentaire complet (cle dans le plan chroma, aucune conversion ici, + // demi-resolution du plan UV) : `shaders.hlsl`. + if layer.chroma_fx.x > 0.5 { + let cbcr = textureSample(texUV, samp, i.uv).rg; + let dist = length(cbcr - layer.chroma_key.xy); + let t0 = layer.chroma_key.z; + chroma_keep = smoothstep(t0, t0 + layer.chroma_key.w + 1e-4, dist); + let spill = layer.chroma_fx.y * clamp(1.0 - dist / max(t0 * 2.0, 1e-4), 0.0, 1.0); + let luma = dot(rgb, vec3(0.2126, 0.7152, 0.0722)); + rgb = mix(rgb, vec3(luma), spill); + } } else if layer.mode < 1.5 { // Mode 1 — couleur pleine. rgb = layer.color.rgb; @@ -427,7 +456,7 @@ fn fs_main(i: VsOut) -> @location(0) vec4 { return vec4(layer.color.rgb * a, a); } - alpha = layer.color.a; + alpha = layer.color.a * chroma_keep; if layer.radius_px > 0.0 { // Feather ~1.5 px sur le bord du quad — parité exacte avec le HLSL diff --git a/crates/compositor/tests/compose_linux.rs b/crates/compositor/tests/compose_linux.rs index 62361572d..46a4a8a88 100644 --- a/crates/compositor/tests/compose_linux.rs +++ b/crates/compositor/tests/compose_linux.rs @@ -997,6 +997,161 @@ fn compose_linux_forme_webcam_cercle() { ); } +/// L'incrustation couleur (fond vert) DECOUPE reellement des pixels de la camera, et le +/// fait SELECTIVEMENT. +/// +/// La fixture n'est pas un fond vert, donc on ne peut pas mesurer un vrai detourage. Ce +/// que ce test epingle est la chaine complete, qui est ce qui casse en pratique : +/// `Scene` -> `live_params_from_scene` -> `chroma_key_uniform` -> les deux float4 du +/// `LayerCB` -> la branche du shader -> l'alpha du calque camera. Deux mesures suffisent +/// a la prouver sans fond vert : +/// +/// - cle sur le NEUTRE (`#808080`) avec `similarity = 1` : la camera ENTIERE disparait +/// et l'ecran repasse dessous. Ce n'est pas une esperance sur le contenu de la +/// fixture mais une propriete geometrique — le neutre tombe pile au centre du plan +/// de chrominance et `CHROMA_MAX_DIST` EST le rayon centre → coin, donc le seuil +/// couvre tout le plan (voir sa doc dans `frame_geometry.rs`). Une empreinte qui +/// reste pleine signifie que l'uniforme n'arrive pas jusqu'au shader. +/// - une couleur cle ABSENTE de l'image (magenta) a seuil serre : l'empreinte doit +/// rester intacte. Sans cette seconde mesure, un shader qui effacerait la camera +/// quoi qu'il arrive passerait la premiere. +/// +/// La selectivite par couleur elle-meme est verrouillee cote unitaire par +/// `the_picked_colour_lands_exactly_on_the_key` (`frame_geometry.rs`), qui refait le +/// trajet de la matrice BT.709 sans avoir besoin d'un GPU. +#[test] +fn compose_linux_incrustation_couleur() { + if std::env::var("OPENSCREEN_LINUX_COMPOSE").is_err() || !Path::new(FIXTURE).is_file() { + eprintln!("compose_linux incrustation: opt-in. Skip."); + return; + } + let webcam_fixture = "../fixture/webcam.mp4"; + if !Path::new(webcam_fixture).is_file() { + eprintln!("compose_linux incrustation: pas de fixture webcam. Skip."); + return; + } + + let gpu = Gpu::create(false).expect("Gpu::create"); + let comp = Compositor::new_sized(&gpu, W, H).expect("Compositor::new_sized"); + let mut screen = Decoder::open(FIXTURE, &gpu).expect("Decoder::open screen"); + let mut cam = Decoder::open(webcam_fixture, &gpu).expect("Decoder::open webcam"); + + // `chroma` est insere tel quel dans `layout` : "" = pas de cle (payload d'avant la + // fonctionnalite), sinon l'objet complet. + let scene = |preset: &str, chroma: &str| { + format!( + r##"{{"clips":[],"layout":{{"preset":"{preset}","webcamSize":1.6,"webcamShape":"rectangle","webcamMirror":false{chroma},"webcamPosition":null,"webcamReactiveZoom":false}},"effects":{{"padding":0.1,"blur":false,"shadow":0,"roundnessFrac":0.0,"motionBlur":0}},"background":{{"kind":"color","color":"#00ff00"}},"zoomRegions":[],"annotations":[],"cursor":{{"show":false,"size":1,"smoothing":0,"motionBlur":0,"clickBounce":0,"clipToBounds":false,"theme":"default"}},"cropByClip":[],"output":{{"width":1920,"height":1080,"fps":30}}}}"## + ) + }; + + let mut render = |preset: &str, chroma: &str| -> Vec { + let parsed = Scene::from_json(&scene(preset, chroma)).expect("scene json"); + // Meme raison qu'au test de forme : `compose_frame` lit les LiveParams, pas la + // scene brute — et c'est par la que l'incrustation transite a l'export. + comp.set_live_params(openscreen_compositor::compositor::live_params_from_scene(&parsed)); + comp.set_scene(Some(parsed)); + unsafe { + let sf = screen.seek_to(1.0).expect("seek screen"); + let wf = cam.seek_to(1.0).expect("seek webcam"); + let mut cfg = Cfg::c8(); + cfg.shadow = false; + comp.compose_frame(sf, wf, 1.0, &cfg).expect("compose_frame"); + comp.readback_direct().expect("readback").2 + } + }; + + let none = render("no-webcam", ""); + let off = render("picture-in-picture", ""); + // Cle au CENTRE du plan de chrominance + seuil au maximum = rayon centre → coin : + // aucun pixel possible n'est hors du seuil. Effacement garanti par la geometrie, pas + // par le contenu de la fixture (que ce test ne connait pas). + let keyed_all = render( + "picture-in-picture", + r##","chromaKey":{"color":"#808080","similarity":1.0,"smoothness":0.0,"spill":0.0}"##, + ); + // Magenta pur, absent de la fixture, avec un seuil serre : rien ne doit tomber. + let keyed_none = render( + "picture-in-picture", + r##","chromaKey":{"color":"#ff00ff","similarity":0.02,"smoothness":0.0,"spill":0.0}"##, + ); + write_ppm("compose_linux_chroma_off", W, H, &off); + write_ppm("compose_linux_chroma_all", W, H, &keyed_all); + + // Nombre de pixels qui changent quand la camera apparait = son empreinte. + let footprint = |with: &[u8]| -> u32 { + with.chunks_exact(4) + .zip(none.chunks_exact(4)) + .filter(|(a, b)| { + (a[0] as i32 - b[0] as i32).abs() + + (a[1] as i32 - b[1] as i32).abs() + + (a[2] as i32 - b[2] as i32).abs() + > 24 + }) + .count() as u32 + }; + + let n_off = footprint(&off); + let n_all = footprint(&keyed_all); + let n_none = footprint(&keyed_none); + println!( + "compose_linux incrustation : sans cle={n_off} px, seuil max={n_all} px, couleur absente={n_none} px" + ); + + assert!(n_off > 1000, "la camera n'est pas dessinee du tout ({n_off} px)"); + assert!( + (n_all as f32) < n_off as f32 * 0.02, + "cle au centre + seuil max : la camera devrait avoir entierement disparu, il reste \ + {n_all} px sur {n_off} — les float4 d'incrustation n'atteignent pas le shader ?" + ); + assert!( + (n_none as f32) > n_off as f32 * 0.98, + "couleur cle absente de l'image : la camera devrait etre intacte ({n_none} px sur \ + {n_off}) — l'incrustation efface sans regarder la couleur ?" + ); + + // L'OMBRE de la bulle PiP doit disparaitre avec elle. Sinon le fond detoure laisse voir + // un rectangle sombre derriere le sujet : l'ombre est dessinee SOUS la camera, donc la + // rendre transparente la revele au lieu de la supprimer. Mesure sur un export de + // controle avant le correctif : le fond detoure ressortait a 0,65x la couleur du calque + // du dessous, soit exactement WEBCAM_SHADOW_OPACITY (0,35 de noir). + // + // `cfg.shadow = true` ici (contrairement au `render` ci-dessus) : sans ombre demandee, + // le test ne pourrait pas constater son absence. + let mut render_shadowed = |chroma: &str| -> Vec { + let parsed = Scene::from_json(&scene("picture-in-picture", chroma)).expect("scene json"); + comp.set_live_params(openscreen_compositor::compositor::live_params_from_scene(&parsed)); + comp.set_scene(Some(parsed)); + unsafe { + let sf = screen.seek_to(1.0).expect("seek screen"); + let wf = cam.seek_to(1.0).expect("seek webcam"); + let mut cfg = Cfg::c8(); + cfg.shadow = true; + comp.compose_frame(sf, wf, 1.0, &cfg).expect("compose_frame"); + comp.readback_direct().expect("readback").2 + } + }; + let shadow_keyed = render_shadowed( + r##","chromaKey":{"color":"#808080","similarity":1.0,"smoothness":0.0,"spill":0.0}"##, + ); + // Camera entierement detouree + ombre supprimee ⇒ la frame doit etre celle SANS camera. + let residue = shadow_keyed + .chunks_exact(4) + .zip(none.chunks_exact(4)) + .filter(|(a, b)| { + (a[0] as i32 - b[0] as i32).abs() + + (a[1] as i32 - b[1] as i32).abs() + + (a[2] as i32 - b[2] as i32).abs() + > 24 + }) + .count(); + println!("compose_linux incrustation : residu avec ombre demandee = {residue} px"); + assert!( + (residue as f32) < n_off as f32 * 0.02, + "incrustation totale mais {residue} px different encore du rendu sans camera — \ + l'ombre de la bulle PiP reste dessinee sous un calque devenu transparent" + ); +} + /// Un enregistrement SANS camera ne doit rien dessiner dans la boite PiP. /// /// Le cas est reproduit tel quel : le decodeur « webcam » recoit la frame de diff --git a/crates/compositor/tests/cpu_backend_linux.rs b/crates/compositor/tests/cpu_backend_linux.rs index 9731cf2a5..e39a2cb26 100644 --- a/crates/compositor/tests/cpu_backend_linux.rs +++ b/crates/compositor/tests/cpu_backend_linux.rs @@ -16,6 +16,7 @@ // resout vers un autre module. #![cfg(target_os = "linux")] +use openscreen_compositor::compositor::Compositor; use openscreen_compositor::d3d::{create_backend, Backend, Gpu}; /// Pose a 1 par la CI, ou `mesa-vulkan-drivers` est installe. Sur un poste de dev @@ -90,3 +91,32 @@ fn create_est_materiel_strict() { Err(e) => eprintln!("cpu_backend_linux: pas de GPU ici, create() a bien echoue ({e:#})."), } } + +/// Le pendant Linux de `every_shader_entry_point_compiles` (Windows/HLSL et macOS/MSL), +/// qui n'existait pas : `layer.wgsl` et `blur.wgsl` sont validés par naga au moment du +/// `create_shader_module`, donc une erreur WGSL ne se voit ni au `cargo build` ni au +/// `cargo test` — seulement au premier `Compositor::new` chez un utilisateur. +/// +/// `Compositor::new_sized` couvre DEUX modes de panne d'un coup, et c'est pour ça qu'on +/// construit un compositeur entier plutôt que de compiler les modules à la main : +/// - la validation WGSL des deux modules ; +/// - la création des bind group layouts, dont le `min_binding_size` est dérivé de +/// `size_of::()`. Un struct WGSL resté en arrière du struct Rust (ils sont +/// déclarés trois fois : `LayerCB`, `layer.wgsl`, `blur.wgsl`) échoue précisément là. +/// +/// Aucune fixture vidéo requise : le compositeur se construit avant tout décodage, ce qui +/// rend ce test exécutable en CI, contrairement aux goldens de `compose_linux.rs` qui sont +/// opt-in derrière `OPENSCREEN_LINUX_COMPOSE` et des .mp4 absents du dépôt. +#[test] +fn les_modules_wgsl_compilent_et_le_layout_accepte_le_constant_buffer() { + let Ok(gpu) = create_backend(Backend::Cpu).or_else(|_| create_backend(Backend::Hardware)) + else { + assert!( + !required(), + "{REQUIRE} est pose mais aucun adaptateur Vulkan n'existe ici" + ); + eprintln!("cpu_backend_linux: aucun adaptateur Vulkan ici. Skip."); + return; + }; + Compositor::new_sized(&gpu, 320, 180).expect("layer.wgsl / blur.wgsl doivent compiler"); +} diff --git a/src/components/ai-edition/PreviewCanvas.chromaPick.test.tsx b/src/components/ai-edition/PreviewCanvas.chromaPick.test.tsx new file mode 100644 index 000000000..9117a1db6 --- /dev/null +++ b/src/components/ai-edition/PreviewCanvas.chromaPick.test.tsx @@ -0,0 +1,276 @@ +// @vitest-environment jsdom +/** + * The eyedropper's click, end to end through `PreviewCanvas`. + * + * What is NOT covered anywhere else: the native pushes. The live preview builds + * its params from `setNativeParam` and never re-reads the scene, so a pick that + * only writes the setting leaves the native window keying on the OLD colour — + * a bug that is invisible to every pure test in `webcamEyedropper.test.ts`, + * because it is not in the maths, it is in the wiring. `Preview.test.tsx` cannot + * catch it either: it mocks this component out entirely. + * + * The pushes have to land BEFORE `commit()`, which is what pins the shape of the + * handler: `commit` awaits a disk write, and a colour that reaches the compositor + * only after it would flash the old key for the length of that write. + * + * The geometry and the sampling are stubbed — both have their own unit tests, and + * jsdom reports a zero-sized slot and a zero-sized video, so the real ones would + * bail before any of this ran. What is left is exactly the wiring. + */ + +import "@testing-library/jest-dom"; +import { cleanup, fireEvent, render, screen } from "@testing-library/react"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { I18nProvider } from "@/contexts/I18nContext"; +import type { AxcutClip, AxcutDocument } from "@/lib/ai-edition/schema"; +import { axcutSchemaVersion } from "@/lib/ai-edition/schema"; +import { startChromaPick, stopChromaPick } from "@/lib/ai-edition/store/chromaPickStore"; +import { useProjectStore } from "@/lib/ai-edition/store/projectStore"; +import type { EyedropperCrop } from "@/lib/ai-edition/webcamEyedropper"; + +const mocks = vi.hoisted(() => ({ + isNativeCompositorActive: vi.fn<() => boolean>(() => true), + setNativeParam: vi.fn<(key: string, value: unknown) => void>(), + mapSlotPointToVideoPixel: vi.fn<() => { x: number; y: number } | null>(() => ({ x: 42, y: 24 })), + sampleVideoPixelHex: vi.fn<() => string | null>(() => "#00b140"), + saveDocument: vi.fn(async () => true), + /** Every native push and every commit, in the order they actually happened. */ + order: [] as string[], +})); + +vi.mock("@/native/client", () => ({ + nativeBridgeClient: { aiEdition: {} }, +})); + +vi.mock("@/native/nativeCompositorStore", async (importOriginal) => ({ + ...(await importOriginal()), + isNativeCompositorActive: mocks.isNativeCompositorActive, + setNativeParam: mocks.setNativeParam, +})); + +vi.mock("@/lib/ai-edition/webcamEyedropper", async (importOriginal) => ({ + ...(await importOriginal()), + mapSlotPointToVideoPixel: mocks.mapSlotPointToVideoPixel, + sampleVideoPixelHex: mocks.sampleVideoPixelHex, +})); + +// The pixel sources are irrelevant here and expensive to boot: the native canvas +// wants a GPU view, the two