From e7211f941da7ab0f988fae8cf84b1711285dbd58 Mon Sep 17 00:00:00 2001 From: MrPanda009 Date: Wed, 19 Aug 2026 14:57:46 +0530 Subject: [PATCH 1/5] feat: implement webcam chroma keying with shader support and project settings integration --- crates/compositor/src/compositor_linux.rs | 34 +++- crates/compositor/src/compositor_macos.rs | 17 +- crates/compositor/src/compositor_windows.rs | 21 ++- crates/compositor/src/frame_geometry.rs | 178 +++++++++++++++++- crates/compositor/src/live.rs | 38 ++++ crates/compositor/src/scene.rs | 38 ++++ crates/compositor/src/shaders.hlsl | 46 ++++- crates/compositor/src/shaders.metal | 27 ++- crates/compositor/src/vk_shaders/blur.wgsl | 6 + crates/compositor/src/vk_shaders/layer.wgsl | 31 ++- crates/compositor/tests/compose_linux.rs | 155 +++++++++++++++ crates/compositor/tests/cpu_backend_linux.rs | 30 +++ src/components/ai-edition/PreviewCanvas.tsx | 59 +++++- src/components/ai-edition/RightPanes.tsx | 153 +++++++++++++++ src/components/ai-edition/WebcamOverlay.tsx | 11 ++ src/i18n/locales/en/settings.json | 8 + src/lib/ai-edition/store/chromaPickStore.ts | 52 +++++ .../ai-edition/store/editorSettings.test.ts | 33 ++++ src/lib/ai-edition/store/editorSettings.ts | 28 ++- src/lib/ai-edition/webcamEyedropper.test.ts | 87 +++++++++ src/lib/ai-edition/webcamEyedropper.ts | 112 +++++++++++ src/lib/webcamChromaKey.test.ts | 67 +++++++ src/lib/webcamChromaKey.ts | 102 ++++++++++ src/native/nativeCompositorStore.ts | 12 ++ src/native/sceneDescription.test.ts | 32 ++++ src/native/sceneDescription.ts | 38 ++++ .../architecture/preview.md | 22 +++ .../architecture/recording.md | 8 +- 28 files changed, 1421 insertions(+), 24 deletions(-) create mode 100644 src/lib/ai-edition/store/chromaPickStore.ts create mode 100644 src/lib/ai-edition/webcamEyedropper.test.ts create mode 100644 src/lib/ai-edition/webcamEyedropper.ts create mode 100644 src/lib/webcamChromaKey.test.ts create mode 100644 src/lib/webcamChromaKey.ts diff --git a/crates/compositor/src/compositor_linux.rs b/crates/compositor/src/compositor_linux.rs index fc32ecaff..c7fad23ad 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, }, @@ -1246,6 +1259,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], @@ -1256,6 +1272,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) @@ -1265,9 +1283,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 4ea09a42c..90ebce526 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, @@ -1611,7 +1612,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, @@ -1622,6 +1631,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 { @@ -1634,6 +1645,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 b4f19b9be..a2ccbc8d8 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, @@ -1535,7 +1535,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, @@ -1546,6 +1554,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, @@ -1557,6 +1570,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 738a14f8e..f4c10f12e 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; @@ -606,6 +618,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 { @@ -646,10 +669,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. @@ -662,6 +695,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. @@ -679,6 +767,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, @@ -1392,7 +1503,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), @@ -1405,11 +1516,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 c486da8e1..ca9261c73 100644 --- a/crates/compositor/src/live.rs +++ b/crates/compositor/src/live.rs @@ -586,10 +586,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], @@ -605,6 +616,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, } } } @@ -859,6 +877,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, _ => {} } @@ -877,6 +896,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), @@ -898,6 +922,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]]; + } + } _ => {} } } @@ -1438,6 +1471,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 2de569510..042cd86fa 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. @@ -105,6 +116,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`. /// @@ -539,6 +563,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 9aa60d928..663a85b66 100644 --- a/crates/compositor/tests/compose_linux.rs +++ b/crates/compositor/tests/compose_linux.rs @@ -996,6 +996,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.tsx b/src/components/ai-edition/PreviewCanvas.tsx index 86879fd12..fec7899a4 100644 --- a/src/components/ai-edition/PreviewCanvas.tsx +++ b/src/components/ai-edition/PreviewCanvas.tsx @@ -38,12 +38,14 @@ import type { AxcutTrimRange, AxcutZoomRegion, } from "@/lib/ai-edition/schema"; +import { stopChromaPick, useChromaPicking } from "@/lib/ai-edition/store/chromaPickStore"; import { useProjectStore } from "@/lib/ai-edition/store/projectStore"; import { useEditorSettings } from "@/lib/ai-edition/store/useEditorSettings"; import { resolveActiveCameraTrack } from "@/lib/ai-edition/timeline/camera"; import { createPlaybackClockRef } from "@/lib/ai-edition/timeline/playback-clock"; import type { SpeedRegion } from "@/lib/ai-edition/timeline/speed"; import { locateVirtualPosition } from "@/lib/ai-edition/timeline/virtual-preview"; +import { mapSlotPointToVideoPixel, sampleVideoPixelHex } from "@/lib/ai-edition/webcamEyedropper"; import { computeCameraFullscreenRect, computeCompositeLayout, @@ -114,6 +116,9 @@ export function PreviewCanvas(props: PreviewCanvasProps) { const assets = document?.assets ?? []; const frameRef = useRef(null); const webcamSlotRef = useRef(null); + // Chroma-key eyedropper: armed from the Layout pane, consumed here. + const isPickingChroma = useChromaPicking(); + const webcamVideoRef = useRef(null); // One clock per mounted canvas, shared between the screen preview (writer) // and the webcam overlay (reader) — see playback-clock.ts. const clockRefHolder = useRef>(); @@ -341,6 +346,50 @@ export function PreviewCanvas(props: PreviewCanvasProps) { clockRef, }; + // Escape leaves pick mode. Without it the only way out is to pick a colour, + // and the camera stays revealed over the composed frame — the user would read + // that as the preview having broken. + useEffect(() => { + if (!isPickingChroma) return; + const onKey = (e: KeyboardEvent) => { + if (e.key === "Escape") stopChromaPick(); + }; + window.addEventListener("keydown", onKey); + return () => window.removeEventListener("keydown", onKey); + }, [isPickingChroma]); + + /** + * Sample the pixel under the pointer out of the RAW camera and make it the key. + * + * Runs on pointer DOWN, ahead of the drag handler, and swallows the event: the + * same slot is the drag hitbox for repositioning the PiP, and a pick that also + * nudged the camera would be a trap. + */ + const handleChromaPick = (event: ReactPointerEvent) => { + event.preventDefault(); + event.stopPropagation(); + const slot = webcamSlotRef.current; + const video = webcamVideoRef.current; + // Always disarm, even when the sample fails — an eyedropper that stays + // armed after a click reads as a frozen editor. + stopChromaPick(); + if (!slot || !video) return; + const rect = slot.getBoundingClientRect(); + const at = mapSlotPointToVideoPixel( + { x: event.clientX - rect.left, y: event.clientY - rect.top }, + { width: rect.width, height: rect.height }, + { width: video.videoWidth, height: video.videoHeight }, + settings.webcamMirrored, + ); + if (!at) return; + const hex = sampleVideoPixelHex(video, at); + if (!hex) return; + // `setLive` + `commit`: one user gesture, one undo entry — the same pairing + // the drag handler above uses. + setLive({ webcamChromaKey: { color: hex, enabled: true } }); + void commit(); + }; + const handleWebcamPointerDown = (event: ReactPointerEvent) => { if (settings.webcamLayoutPreset !== "picture-in-picture") return; if (isPlaying) return; @@ -455,10 +504,12 @@ export function PreviewCanvas(props: PreviewCanvasProps) { className={styles.webcamSlot} style={{ ...webcamStyle, - cursor: isPipGrab && !isPlaying ? "grab" : "default", + cursor: isPickingChroma ? "crosshair" : isPipGrab && !isPlaying ? "grab" : "default", touchAction: "none", }} - onPointerDown={isPipGrab ? handleWebcamPointerDown : undefined} + onPointerDown={ + isPickingChroma ? handleChromaPick : isPipGrab ? handleWebcamPointerDown : undefined + } aria-label={te("preview.webcamPreview")} > { + webcamVideoRef.current = el; + }} + revealVideo={isPickingChroma} borderRadius={ effectiveLayout?.webcamRect?.borderRadius ?? layout.webcamRect.borderRadius } diff --git a/src/components/ai-edition/RightPanes.tsx b/src/components/ai-edition/RightPanes.tsx index 5ac70471a..0ef2f0206 100644 --- a/src/components/ai-edition/RightPanes.tsx +++ b/src/components/ai-edition/RightPanes.tsx @@ -12,6 +12,7 @@ import { Loader2, MousePointerClick, Palette, + Pipette as PipetteIcon, Sliders, Trash2, } from "lucide-react"; @@ -41,6 +42,11 @@ import type { AxcutTrimRange, AxcutWord, } from "@/lib/ai-edition/schema"; +import { + startChromaPick, + stopChromaPick, + useChromaPicking, +} from "@/lib/ai-edition/store/chromaPickStore"; import { useProjectStore } from "@/lib/ai-edition/store/projectStore"; import { useEditorSettings } from "@/lib/ai-edition/store/useEditorSettings"; import { @@ -61,7 +67,9 @@ import { supportsCursorClickEffects } from "@/lib/cursor/cursorCapabilities"; import { CURSOR_THEMES, DEFAULT_CURSOR_THEME_ID } from "@/lib/cursor/cursorThemes"; import { buildGradientFromEditor } from "@/lib/gradientBuilder"; import { resolveImageWallpaperUrl, WALLPAPER_PATHS, WALLPAPER_THUMB_PATHS } from "@/lib/wallpaper"; +import { CHROMA_KEY_PRESETS } from "@/lib/webcamChromaKey"; import { isNativeCompositorActive, setNativeParam } from "@/native"; +import { ColorField } from "./ColorField"; import styles from "./NewEditorShell.module.css"; interface PaneProps { @@ -1520,6 +1528,11 @@ export function LayoutPane() { ? hasAnyClipWithCamera(document.assets, document.timeline.clips) : false; const layoutControlsDisabled = !hasDocument || !hasAnyCamera; + // The chroma key acts on camera pixels, so it applies to every preset that + // actually draws a camera — not only PiP, unlike the shape/size controls above. + // Under "no-webcam" there is nothing to key, so the section is dropped rather + // than shown as a control with no effect. + const hasCameraOnScreen = hasAnyCamera && settings.webcamLayoutPreset !== "no-webcam"; return ( } helpText={ts("layout.help")}>
{ts("layout.preset")}
@@ -1652,10 +1665,150 @@ export function LayoutPane() { ) : null} + {hasCameraOnScreen ? : null}
); } +/** + * Chroma key (green screen) for the camera. + * + * NON-DESTRUCTIVE: nothing here touches the recorded camera file. The key is a + * per-frame effect the compositor applies while drawing the PiP, so switching it + * off returns the original footage, and the colour stays re-pickable forever. + * + * Every control pushes a live param as well as writing the setting, for the same + * reason `webcamMirror` does: the live preview builds its params from these + * pushes and never reads the scene's copy, while the export reads only the + * scene. Both roads or the feature is half-wired. + */ +function ChromaKeySection({ disabled }: { disabled: boolean }) { + const ts = useScopedT("settings"); + const { settings, set, setLive, commit } = useEditorSettings(); + const isPicking = useChromaPicking(); + const key = settings.webcamChromaKey; + + // Leaving the pane with the eyedropper armed would strand the preview showing + // the raw camera with no visible way back. + useEffect(() => stopChromaPick, []); + + const sliders: ReadonlyArray<{ + labelKey: string; + value: number; + param: string; + apply: (v: number) => void; + }> = [ + { + labelKey: "layout.chromaSimilarity", + value: key.similarity, + param: "webcamChromaSimilarity", + apply: (v) => setLive({ webcamChromaKey: { similarity: v } }), + }, + { + labelKey: "layout.chromaSmoothness", + value: key.smoothness, + param: "webcamChromaSmoothness", + apply: (v) => setLive({ webcamChromaKey: { smoothness: v } }), + }, + { + labelKey: "layout.chromaSpill", + value: key.spill, + param: "webcamChromaSpill", + apply: (v) => setLive({ webcamChromaKey: { spill: v } }), + }, + ]; + + return ( + <> +
{ts("layout.chromaKey")}
+
+ {ts("layout.chromaKeyEnable")} + { + void set({ webcamChromaKey: { enabled: v } }); + if (!v) stopChromaPick(); + if (isNativeCompositorActive()) { + setNativeParam("webcamChromaEnabled", v); + } + }} + /> +
+ {key.enabled ? ( + <> +
+ {ts("layout.chromaColor")} +
+ { + setLive({ webcamChromaKey: { color: next } }); + if (isNativeCompositorActive()) { + setNativeParam("webcamChromaColor", next); + } + }} + onCommit={() => void commit()} + /> + +
+
+ {isPicking ? ( +
+ {ts("layout.chromaPickHint")} +
+ ) : null} +
+ {sliders.map((s) => ( +
+
+ {ts(s.labelKey)} + {Math.round(s.value * 100)}% +
+ { + const next = Number(e.target.value) / 100; + s.apply(next); + if (isNativeCompositorActive()) { + setNativeParam(s.param, next); + } + }} + onMouseUp={() => void commit()} + onTouchEnd={() => void commit()} + onKeyUp={() => void commit()} + /> +
+ ))} +
+ + ) : null} + + ); +} + // ─── Cursor ─────────────────────────────────────────────────────── function safeAssetUrl(relativePath: string): string { diff --git a/src/components/ai-edition/WebcamOverlay.tsx b/src/components/ai-edition/WebcamOverlay.tsx index d194421af..2d59c327e 100644 --- a/src/components/ai-edition/WebcamOverlay.tsx +++ b/src/components/ai-edition/WebcamOverlay.tsx @@ -49,6 +49,15 @@ interface WebcamOverlayProps { // numeric sync target comes straight from the screen's own rAF, this // frame, with no React round trip in between. clockRef?: PlaybackClockRef; + // The chroma-key eyedropper samples this element: it is the only un-keyed + // copy of the camera in the app (the preview canvas holds the COMPOSED frame, + // key included). Handing the element out rather than the sampled colour keeps + // this component read-only, which is the rule stated at the top of the file. + onVideoElement?: (el: HTMLVideoElement | null) => void; + // While the eyedropper is armed the camera must be VISIBLE and un-keyed, so + // the user can aim at the real backdrop. `visibility` is the only thing the + // native path hides it with, so lifting it is enough. + revealVideo?: boolean; } export function WebcamOverlay(props: WebcamOverlayProps) { @@ -170,6 +179,7 @@ export function WebcamOverlay(props: WebcamOverlayProps) { transform: settings.webcamMirrored ? "scaleX(-1)" : undefined, clipPath: getCssClipPath(props.webcamMaskShape) ?? undefined, borderRadius: `${props.borderRadius}px`, + ...(props.revealVideo ? { visibility: "visible" as const } : {}), }; return ( @@ -177,6 +187,7 @@ export function WebcamOverlay(props: WebcamOverlayProps) { key={cameraTrack.sourcePath} ref={(el) => { setVideoEl(el); + props.onVideoElement?.(el); setHasError(false); }} src={toFileUrl(cameraTrack.sourcePath)} diff --git a/src/i18n/locales/en/settings.json b/src/i18n/locales/en/settings.json index d6fa45887..c81840bb6 100644 --- a/src/i18n/locales/en/settings.json +++ b/src/i18n/locales/en/settings.json @@ -52,6 +52,14 @@ "mirrorWebcam": "Mirror Webcam", "reactiveWebcam": "Shrink on Zoom", "reactiveWebcamDescription": "Camera smoothly shrinks while the video is zoomed in, so it stays out of the way.", + "chromaKey": "Chroma Key", + "chromaKeyEnable": "Remove Background", + "chromaColor": "Key Color", + "chromaPick": "Pick", + "chromaPickHint": "Click the camera in the preview to sample its background colour.", + "chromaSimilarity": "Similarity", + "chromaSmoothness": "Smoothness", + "chromaSpill": "Spill Removal", "help": "How the webcam is composed with the screen: picture-in-picture, vertical stack, dual frame, mask shape, size, and mirroring.", "shapes": { "rectangle": "Rect", diff --git a/src/lib/ai-edition/store/chromaPickStore.ts b/src/lib/ai-edition/store/chromaPickStore.ts new file mode 100644 index 000000000..15255eb10 --- /dev/null +++ b/src/lib/ai-edition/store/chromaPickStore.ts @@ -0,0 +1,52 @@ +/** + * "The chroma-key eyedropper is armed" — a one-boolean store. + * + * The button that arms it lives in the Layout pane (`RightPanes.tsx`) and the + * surface that consumes the click lives in the preview (`PreviewCanvas.tsx`). + * They are siblings under the editor shell with no shared owner between them, + * and threading a boolean plus two callbacks through that shell would put + * transient UI state into the document-facing prop chain. + * + * Same shape as `src/native/nativeCompositorStore.ts` (module-level value + + * listener set + `useSyncExternalStore`), which solves the same problem for the + * native view id. Deliberately NOT in the project store: this is not document + * state, it must not survive a reload, and it must never mark the project dirty. + */ + +import { useSyncExternalStore } from "react"; + +let picking = false; +const listeners = new Set<() => void>(); + +function emit(): void { + for (const l of listeners) l(); +} + +/** Arm the eyedropper. The preview reveals the raw camera and takes the next click. */ +export function startChromaPick(): void { + if (picking) return; + picking = true; + emit(); +} + +/** Disarm — after a successful pick, on Escape, or when the pane unmounts. */ +export function stopChromaPick(): void { + if (!picking) return; + picking = false; + emit(); +} + +export function isChromaPicking(): boolean { + return picking; +} + +function subscribe(listener: () => void): () => void { + listeners.add(listener); + return () => { + listeners.delete(listener); + }; +} + +export function useChromaPicking(): boolean { + return useSyncExternalStore(subscribe, isChromaPicking, () => false); +} diff --git a/src/lib/ai-edition/store/editorSettings.test.ts b/src/lib/ai-edition/store/editorSettings.test.ts index e31f9fcb2..8a8fb9694 100644 --- a/src/lib/ai-edition/store/editorSettings.test.ts +++ b/src/lib/ai-edition/store/editorSettings.test.ts @@ -143,4 +143,37 @@ describe("patchEditorSettings", () => { const snap = getEditorSettings(doc); expect(snap.webcamPosition).toEqual({ cx: 1, cy: 0 }); }); + + it("leaves the chroma key off for a project that never mentions it", () => { + // Every project recorded before this feature. The camera has to come back + // exactly as it was shot, so "no setting" can only mean "no key". + expect(getEditorSettings(baseDoc).webcamChromaKey.enabled).toBe(false); + }); + + it("merges a partial chroma-key patch instead of replacing the object", () => { + // The toggle and each slider patch ONE field. A straight spread would drop + // the picked colour every time the user moved a slider. + const picked = patchEditorSettings(baseDoc, { + webcamChromaKey: { color: "#123456", enabled: true }, + }); + const tuned = patchEditorSettings(picked, { webcamChromaKey: { similarity: 0.5 } }); + const snap = getEditorSettings(tuned); + expect(snap.webcamChromaKey.color).toBe("#123456"); + expect(snap.webcamChromaKey.enabled).toBe(true); + expect(snap.webcamChromaKey.similarity).toBe(0.5); + }); + + it("normalises a malformed persisted chroma key on read", () => { + const doc: AxcutDocument = { + ...baseDoc, + legacyEditor: { + webcamChromaKey: { enabled: true, color: "#0F0", similarity: 9, spill: "lots" }, + }, + }; + const key = getEditorSettings(doc).webcamChromaKey; + expect(key.enabled).toBe(true); + expect(key.color).toBe("#00ff00"); + expect(key.similarity).toBe(1); + expect(key.spill).toBe(DEFAULT_EDITOR_SETTINGS.webcamChromaKey.spill); + }); }); diff --git a/src/lib/ai-edition/store/editorSettings.ts b/src/lib/ai-edition/store/editorSettings.ts index c77f42330..d1fd07838 100644 --- a/src/lib/ai-edition/store/editorSettings.ts +++ b/src/lib/ai-edition/store/editorSettings.ts @@ -30,6 +30,11 @@ import { } from "@/components/video-editor/types"; import { DEFAULT_CURSOR_THEME_ID } from "@/lib/cursor/cursorThemes"; import { DEFAULT_WALLPAPER } from "@/lib/wallpaper"; +import { + DEFAULT_WEBCAM_CHROMA_KEY, + normaliseChromaKeySettings, + type WebcamChromaKeySettings, +} from "@/lib/webcamChromaKey"; import type { AspectRatio } from "@/utils/aspectRatioUtils"; import type { AxcutDocument } from "../schema"; @@ -53,6 +58,7 @@ export interface EditorSettingsSnapshot { webcamReactiveZoom: boolean; webcamSizePreset: WebcamSizePreset; webcamPosition: WebcamPosition | null; + webcamChromaKey: WebcamChromaKeySettings; cursor: CursorVisualSettings; cursorShow: boolean; cursorTheme: string; @@ -79,6 +85,7 @@ export const DEFAULT_EDITOR_SETTINGS: EditorSettingsSnapshot = { webcamReactiveZoom: DEFAULT_WEBCAM_REACTIVE_ZOOM, webcamSizePreset: DEFAULT_WEBCAM_SIZE_PRESET, webcamPosition: DEFAULT_WEBCAM_POSITION, + webcamChromaKey: DEFAULT_WEBCAM_CHROMA_KEY, cursor: { size: DEFAULT_CURSOR_SIZE, smoothing: DEFAULT_CURSOR_SMOOTHING, @@ -106,6 +113,7 @@ interface LegacyShape { webcamReactiveZoom?: boolean; webcamSizePreset?: WebcamSizePreset; webcamPosition?: WebcamPosition | null; + webcamChromaKey?: WebcamChromaKeySettings; cursorSize?: number; cursorSmoothing?: number; cursorMotionBlur?: number; @@ -160,6 +168,10 @@ export function getEditorSettings(doc: AxcutDocument | null | undefined): Editor ), webcamSizePreset: num(legacy?.webcamSizePreset, DEFAULT_EDITOR_SETTINGS.webcamSizePreset), webcamPosition: normaliseWebcamPosition(legacy?.webcamPosition), + // Every field is re-validated on read rather than trusted: this blob is a + // `passthrough()` envelope, so a hand-edited project (or one written by an + // older build) can hold anything at all under this key. + webcamChromaKey: normaliseChromaKeySettings(legacy?.webcamChromaKey), cursor, cursorShow: bool(legacy?.cursorShow, DEFAULT_EDITOR_SETTINGS.cursorShow), cursorTheme: str(legacy?.cursorTheme, DEFAULT_EDITOR_SETTINGS.cursorTheme), @@ -181,6 +193,8 @@ export interface EditorSettingsPatch { webcamReactiveZoom?: boolean; webcamSizePreset?: WebcamSizePreset; webcamPosition?: WebcamPosition | null; + /** Partial: merged onto the stored key, so a toggle doesn't reset the colour. */ + webcamChromaKey?: Partial; cursor?: Partial & { theme?: string; show?: boolean }; autoFocusAll?: boolean; } @@ -190,15 +204,23 @@ function nextLegacy(current: LegacyShape | null, patch: EditorSettingsPatch): Le // Spread, but only over keys the patch actually set — a plain {...base, // ...patch} would write undefined over a value the caller left alone. // `cursor` is handled separately below: its keys are renamed (size -> - // cursorSize), so it is not a straight passthrough. + // cursorSize), so it is not a straight passthrough. `webcamChromaKey` is + // separate for the other reason — the patch carries a PARTIAL of it, so a + // straight spread would drop the colour every time the toggle moves. // The Pick keeps what the 16 `if`s used to check: a patch key with no // LegacyShape counterpart fails to compile instead of leaking into the // persisted blob. - const { cursor: _cursor, ...rest } = patch; + const { cursor: _cursor, webcamChromaKey: _chroma, ...rest } = patch; const defined = Object.fromEntries( Object.entries(rest).filter(([, v]) => v !== undefined), - ) as Partial>>; + ) as Partial>>; const next: LegacyShape = { ...base, ...defined }; + if (patch.webcamChromaKey) { + next.webcamChromaKey = normaliseChromaKeySettings({ + ...normaliseChromaKeySettings(base.webcamChromaKey), + ...patch.webcamChromaKey, + }); + } if (patch.cursor) { const c = patch.cursor; if (c.size !== undefined) next.cursorSize = c.size; diff --git a/src/lib/ai-edition/webcamEyedropper.test.ts b/src/lib/ai-edition/webcamEyedropper.test.ts new file mode 100644 index 000000000..f97575907 --- /dev/null +++ b/src/lib/ai-edition/webcamEyedropper.test.ts @@ -0,0 +1,87 @@ +import { describe, expect, it } from "vitest"; +import { mapSlotPointToVideoPixel, rgbToHex } from "./webcamEyedropper"; + +// A 16:9 camera in a square slot — the `circle`/`square` mask shapes, where +// `object-fit: cover` crops the WIDTH. The other orientation (a 16:9 camera in a +// wide slot) crops the HEIGHT, and both are exercised below: a mapping that only +// handled one axis would still pass a single-orientation test. +const CAMERA = { width: 1280, height: 720 }; + +describe("mapSlotPointToVideoPixel", () => { + it("maps the slot centre to the camera centre, whatever gets cropped", () => { + for (const slot of [ + { width: 300, height: 300 }, // square slot: crops width + { width: 400, height: 100 }, // wide slot: crops height + { width: 320, height: 180 }, // exact ratio: crops nothing + ]) { + const at = mapSlotPointToVideoPixel( + { x: slot.width / 2, y: slot.height / 2 }, + slot, + CAMERA, + false, + ); + expect(at).not.toBeNull(); + expect(at?.x).toBe(CAMERA.width / 2); + expect(at?.y).toBe(CAMERA.height / 2); + } + }); + + it("accounts for the width cropped off a square slot", () => { + // scale = max(300/1280, 300/720) = 300/720. Shown width = 1280*300/720 + // = 533.33, so 116.67px is cropped off each side. The slot's left edge is + // therefore camera x = 116.67/scale = 280, not 0. + const at = mapSlotPointToVideoPixel({ x: 0, y: 0 }, { width: 300, height: 300 }, CAMERA, false); + expect(at?.x).toBe(280); + expect(at?.y).toBe(0); + }); + + it("mirrors horizontally without touching the vertical axis", () => { + const slot = { width: 320, height: 180 }; // exact ratio: no crop, so the + // mirror is the only transform in play and the arithmetic is checkable. + const plain = mapSlotPointToVideoPixel({ x: 80, y: 45 }, slot, CAMERA, false); + const mirrored = mapSlotPointToVideoPixel({ x: 80, y: 45 }, slot, CAMERA, true); + expect(plain?.x).toBe(320); + // 320 - 80 = 240 slot px → 240 * (1280/320) = 960. + expect(mirrored?.x).toBe(960); + expect(mirrored?.y).toBe(plain?.y); + }); + + it("keeps the far edges inside the raster", () => { + // A click exactly on the right/bottom edge rounds one pixel past the end. + // Clamping rather than rejecting is what keeps the camera's border pickable. + const slot = { width: 320, height: 180 }; + const at = mapSlotPointToVideoPixel({ x: slot.width, y: slot.height }, slot, CAMERA, false); + expect(at).toEqual({ x: CAMERA.width - 1, y: CAMERA.height - 1 }); + }); + + it("returns null on degenerate geometry instead of the origin pixel", () => { + // `videoWidth`/`videoHeight` are 0 until metadata loads; sampling then + // would silently key on the top-left pixel of nothing. + expect( + mapSlotPointToVideoPixel( + { x: 5, y: 5 }, + { width: 300, height: 300 }, + { + width: 0, + height: 0, + }, + false, + ), + ).toBeNull(); + expect( + mapSlotPointToVideoPixel({ x: 5, y: 5 }, { width: 0, height: 300 }, CAMERA, false), + ).toBeNull(); + }); +}); + +describe("rgbToHex", () => { + it("pads each channel to two digits", () => { + expect(rgbToHex(0, 177, 64)).toBe("#00b140"); + expect(rgbToHex(0, 0, 0)).toBe("#000000"); + expect(rgbToHex(255, 255, 255)).toBe("#ffffff"); + }); + + it("rounds and clamps out-of-range channels", () => { + expect(rgbToHex(-5, 300, 127.6)).toBe("#00ff80"); + }); +}); diff --git a/src/lib/ai-edition/webcamEyedropper.ts b/src/lib/ai-edition/webcamEyedropper.ts new file mode 100644 index 000000000..87a3e7ffc --- /dev/null +++ b/src/lib/ai-edition/webcamEyedropper.ts @@ -0,0 +1,112 @@ +// Sampling the RAW camera pixel under the pointer, for the chroma-key colour picker. +// +// WHY THE RAW CAMERA AND NOT THE PREVIEW CANVAS. The preview canvas holds the +// COMPOSED frame — wallpaper, screen, camera, cursor, and (once the key is on) +// the key itself. Sampling it would feed the key its own output, so the second +// pick would never find the backdrop it just removed. The editor keeps a real +// `