diff --git a/crates/compositor/src/compositor_linux.rs b/crates/compositor/src/compositor_linux.rs index dc7567373..fe021539b 100644 --- a/crates/compositor/src/compositor_linux.rs +++ b/crates/compositor/src/compositor_linux.rs @@ -2254,7 +2254,9 @@ impl Compositor { let (wants_effect, model_path) = { let scene = self.scene.borrow(); match scene.as_ref().and_then(|s| s.webcam_effect.as_ref()) { - Some(e) if e.shader_code() > 0.0 => (true, e.model_path.clone()), + Some(e) if e.shader_code() > 0.0 && e.shader_code() < 3.5 => { + (true, e.model_path.clone()) + } _ => (false, None), } }; @@ -2468,7 +2470,7 @@ impl Compositor { .borrow() .as_ref() .and_then(|s| s.webcam_effect.as_ref()) - .is_some_and(|e| e.shader_code() > 0.0); + .is_some_and(|e| e.shader_code() > 0.0 && e.shader_code() < 3.5); if wants_seg && !webcam.is_null() { // `nv12_srvs` dereference `data[0]` sans verifier la frame elle-meme, // d'ou le garde de nullite au-dessus (meme condition que le draw PiP). @@ -2740,15 +2742,19 @@ impl Compositor { // lancement gracieux, le temps que l'inference rende son premier masque. // // Calcule ICI, avant le draw comme avant l'ombre : les deux en dependent. - let (effect_code, blur_intensity, webcam_bg) = { + let (effect_code, blur_intensity, webcam_bg, chroma_color, chroma_trail_a) = { let has_mask = self.webcam_mask.borrow().is_some(); let effect = scene_ref .as_ref() .and_then(|s| s.webcam_effect.as_ref()) - .filter(|_| has_mask) + .filter(|e| e.shader_code() > 3.5 || has_mask) .map(|e| (e.shader_code(), e)) .filter(|(code, _)| *code > 0.0); match effect { + Some((code, e)) if code > 3.5 => { + let (c, ta) = e.chroma_uniforms(); + (code, 0.0, None, c, ta) + } // Fond personnalise : on PEINT le fond dans la bulle, puis on y // decoupe la camera par-dessus — le melange alpha donne // `lerp(fond, camera, personne)`, soit exactement ce que la branche @@ -2768,20 +2774,27 @@ impl Compositor { &dummy, ) }); - (1.0, 0.0, bg) + (1.0, 0.0, bg, [0.0, 0.0, 0.0, 1.0], [0.0; 4]) } - Some((code, e)) => (code, e.blur_intensity.clamp(0.0, 1.0), None), - None => (0.0, 0.0, None), + Some((code, e)) => ( + code, + e.blur_intensity.clamp(0.0, 1.0), + None, + [0.0, 0.0, 0.0, 1.0], + [0.0; 4], + ), + None => (0.0, 0.0, None, [0.0, 0.0, 0.0, 1.0], [0.0; 4]), } }; // L'ombre se juge sur le mode DE LA SCENE, pas sur `effect_code` : le fond // personnalise se compose desormais en detourage (code 1) tout en gardant // sa bulle, et tester le code compose la lui retirerait. Meme lecture que // `is_cutout` cote Windows. - let is_cutout = matches!( - scene_ref.as_ref().and_then(|s| s.webcam_effect.as_ref()), - Some(e) if e.shader_code() == 1.0 - ) && self.webcam_mask.borrow().is_some(); + let is_cutout = match scene_ref.as_ref().and_then(|s| s.webcam_effect.as_ref()) { + Some(e) if e.shader_code() > 3.5 => true, + Some(e) if e.shader_code() == 1.0 => self.webcam_mask.borrow().is_some(), + _ => false, + }; let webcam_draw = webcam_planes.as_ref().map(|(wy, wu, wv)| { // COVER-CROP. `src` etait cable a [0,0,1,1], donc la texture entiere // etait etiree sur la boite quelle que soit sa forme : le facteur de @@ -2818,9 +2831,9 @@ impl Compositor { quad_px: g.w_px, radius_px: g.w_radius, mode: 0.0, - // `color.a` porte l'alpha du decoupage (`color.a * personne`) ; le - // RGB n'est plus lu, le fond ayant deja ete peint sous la camera. - color: [0.0, 0.0, 0.0, 1.0], + // `color.a` porte l'alpha du decoupage (`color.a * personne`) ; en mode + // chromakey, `color.rgb` porte la couleur cle et `trail_a` la tolerance. + color: chroma_color, // `fx.xy` = etendue valide de la texture webcam, par quoi le // shader divise `uv` pour retomber dans l'espace du masque ; // `fx.z` = mode, `fx.w` = intensite du flou. Contrat commun aux @@ -2829,6 +2842,7 @@ impl Compositor { src_prev: [u0, cv0, u1, cv1], dst_prev: g.w_dst_prev, mb: [g.mb_taps, g.mb_amount, 1.0, 0.0], + trail_a: chroma_trail_a, ..Default::default() }; // Le masque est lie par `make_bind` sur tous les draws, pas seulement diff --git a/crates/compositor/src/compositor_macos.rs b/crates/compositor/src/compositor_macos.rs index 2756f372a..07ddccff2 100644 --- a/crates/compositor/src/compositor_macos.rs +++ b/crates/compositor/src/compositor_macos.rs @@ -2193,7 +2193,9 @@ impl Compositor { let (wants_effect, model_path) = { let scene = self.scene.borrow(); match scene.as_ref().and_then(|s| s.webcam_effect.as_ref()) { - Some(e) if e.shader_code() > 0.0 => (true, e.model_path.clone()), + Some(e) if e.shader_code() > 0.0 && e.shader_code() < 3.5 => { + (true, e.model_path.clone()) + } _ => (false, None), } }; @@ -2932,7 +2934,7 @@ impl Compositor { let effect = scene_ref .as_ref() .and_then(|s| s.webcam_effect.as_ref()) - .filter(|_| mask.is_some()) + .filter(|e| e.shader_code() > 3.5 || mask.is_some()) .map(|e| (e.shader_code(), e)) .filter(|(code, _)| *code > 0.0); @@ -2941,7 +2943,7 @@ impl Compositor { // sur le code de la SCÈNE et non sur celui envoyé au shader : le fond personnalisé // part lui aussi en détourage ci-dessous, mais sa bulle, elle, est bien peinte et // garde donc son ombre. - let is_cutout = matches!(effect, Some((code, _)) if code == 1.0); + let is_cutout = matches!(effect, Some((code, _)) if code == 1.0 || code > 3.5); if cfg.shadow && !webcam_is_block && !is_cutout && g.shape_fade > 0.0 { self.draw_shadow( enc, @@ -2960,13 +2962,22 @@ impl Compositor { // fond. Le shader ne sait peindre qu'une couleur plate sous le masque ; dégradés et // images y tombaient sur du noir, et le défaut EST une image. L'ordre est imposé : // ombre, puis fond, puis caméra. - let (effect_code, blur_intensity) = match effect { + let (effect_code, blur_intensity, color, trail_a) = match effect { + Some((code, e)) if code > 3.5 => { + let (c, ta) = e.chroma_uniforms(); + (code, 0.0, c, ta) + } Some((code, e)) if code > 2.5 => { self.draw_webcam_bg(enc, e.background.as_ref(), g.w_dst, g.w_px, g.w_radius); - (1.0, 0.0) + (1.0, 0.0, [0.0, 0.0, 0.0, 1.0], [0.0; 4]) } - Some((code, e)) => (code, e.blur_intensity.clamp(0.0, 1.0)), - None => (0.0, 0.0), + Some((code, e)) => ( + code, + e.blur_intensity.clamp(0.0, 1.0), + [0.0, 0.0, 0.0, 1.0], + [0.0; 4], + ), + None => (0.0, 0.0, [0.0, 0.0, 0.0, 1.0], [0.0; 4]), }; // Metal tolère l'index 3 non lié tant que `fx.z` reste à 0 : la branche n'est @@ -2990,13 +3001,14 @@ impl Compositor { quad_px: g.w_px, radius_px: g.w_radius, mode: 0.0, - // `color.a` porte l'alpha du découpage (`color.a * personne`) ; le RGB n'est - // plus lu, le fond ayant déjà été peint sous la caméra. - color: [0.0, 0.0, 0.0, 1.0], + // `color.a` porte l'alpha du découpage (`color.a * personne`) ; en mode + // chromakey, `color.rgb` porte la couleur clé et `trail_a` la tolérance. + color, fx: [w_valid[0], w_valid[1], effect_code, blur_intensity], src_prev: [u0, cv0, u1, cv1], dst_prev: g.w_dst_prev, mb: [g.mb_taps, g.mb_amount, 1.0, 0.0], + trail_a, ..Default::default() }, wy, diff --git a/crates/compositor/src/compositor_windows.rs b/crates/compositor/src/compositor_windows.rs index 81ba78b74..8cb959665 100644 --- a/crates/compositor/src/compositor_windows.rs +++ b/crates/compositor/src/compositor_windows.rs @@ -1860,7 +1860,9 @@ impl Compositor { let (wants_effect, model_path) = { let scene = self.scene.borrow(); match scene.as_ref().and_then(|s| s.webcam_effect.as_ref()) { - Some(e) if e.shader_code() > 0.0 => (true, e.model_path.clone()), + Some(e) if e.shader_code() > 0.0 && e.shader_code() < 3.5 => { + (true, e.model_path.clone()) + } _ => (false, None), } }; @@ -2736,10 +2738,11 @@ impl Compositor { ); // L'ombre appartient à la bulle PiP. En détourage il n'y a plus de bulle — une // ombre portée par un rectangle invisible se lit comme un artefact. - let is_cutout = matches!( - scene_ref.as_ref().and_then(|s| s.webcam_effect.as_ref()), - Some(e) if e.shader_code() == 1.0 - ) && self.webcam_mask.borrow().is_some(); + let is_cutout = match scene_ref.as_ref().and_then(|s| s.webcam_effect.as_ref()) { + Some(e) if e.shader_code() > 3.5 => true, + Some(e) if e.shader_code() == 1.0 => self.webcam_mask.borrow().is_some(), + _ => false, + }; if cfg.shadow && !webcam_is_block && !is_cutout && shape_fade > 0.0 { let strength = WEBCAM_SHADOW_OPACITY * shape_fade; self.draw_shadow( @@ -2759,7 +2762,7 @@ impl Compositor { let effect = scene_ref .as_ref() .and_then(|s| s.webcam_effect.as_ref()) - .filter(|_| mask.is_some()) + .filter(|e| e.shader_code() > 3.5 || mask.is_some()) .map(|e| (e.shader_code(), e)) .filter(|(code, _)| *code > 0.0); @@ -2768,13 +2771,22 @@ impl Compositor { // ce que la branche « mode 3 » du shader calculait, mais pour les TROIS sortes de // fond. Le shader ne sait peindre qu'une couleur plate sous le masque ; dégradés et // images y tombaient sur du noir, et le défaut EST une image. - let (effect_code, blur_intensity) = match effect { + let (effect_code, blur_intensity, color, trail_a) = match effect { + Some((code, e)) if code > 3.5 => { + let (c, ta) = e.chroma_uniforms(); + (code, 0.0, c, ta) + } Some((code, e)) if code > 2.5 => { self.draw_webcam_bg(e.background.as_ref(), w_dst, w_px, w_radius); - (1.0, 0.0) + (1.0, 0.0, [0.0, 0.0, 0.0, 1.0], [0.0; 4]) } - Some((code, e)) => (code, e.blur_intensity.clamp(0.0, 1.0)), - None => (0.0, 0.0), + Some((code, e)) => ( + code, + e.blur_intensity.clamp(0.0, 1.0), + [0.0, 0.0, 0.0, 1.0], + [0.0; 4], + ), + None => (0.0, 0.0, [0.0, 0.0, 0.0, 1.0], [0.0; 4]), }; if let Some(m) = mask.as_ref() { @@ -2790,13 +2802,14 @@ impl Compositor { quad_px: w_px, radius_px: w_radius, mode: 0.0, - // `color.a` porte l'alpha du découpage (`color.a * personne`) ; le RGB n'est - // plus lu, le fond ayant déjà été peint sous la caméra. - color: [0.0, 0.0, 0.0, 1.0], + // `color.a` porte l'alpha du découpage (`color.a * personne`) ; en mode + // chromakey, `color.rgb` porte la couleur clé et `trail_a` la tolérance. + color, fx: [w_valid[0], w_valid[1], effect_code, blur_intensity], src_prev: [u0, sv0, u1, sv1], // src fixe (pas de zoom webcam) dst_prev: w_dst_prev, mb: [mb_taps, mb_amount, 1.0, 0.0], + trail_a, ..Default::default() }, &wy, diff --git a/crates/compositor/src/scene.rs b/crates/compositor/src/scene.rs index 94dee1d59..476333c98 100644 --- a/crates/compositor/src/scene.rs +++ b/crates/compositor/src/scene.rs @@ -798,10 +798,36 @@ pub struct SceneOutput { /// la segmentation qui tourne dans ce processus (`segmentation.rs`) et arrive au shader comme /// texture `t3`. Une version antérieure faisait cuire le composite côté app et l'envoyait /// comme piste vidéo : le codec ne sait pas porter l'alpha, et preview et export divergeaient. +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct SceneChromaKey { + #[serde(default = "default_chroma_color")] + pub color: String, + #[serde(default = "default_chroma_similarity")] + pub similarity: f32, + #[serde(default = "default_chroma_smoothness")] + pub smoothness: f32, + #[serde(default = "default_chroma_spill")] + pub spill: f32, +} + +fn default_chroma_color() -> String { + "#00FF00".to_string() +} +fn default_chroma_similarity() -> f32 { + 0.4 +} +fn default_chroma_smoothness() -> f32 { + 0.1 +} +fn default_chroma_spill() -> f32 { + 0.5 +} + #[derive(Debug, Clone, Deserialize)] #[serde(rename_all = "camelCase")] pub struct SceneWebcamEffect { - /// "none" | "transparent" | "blur" | "custom" + /// "none" | "transparent" | "blur" | "custom" | "chromakey" pub mode: String, /// 0..1, seulement pour `blur`. #[serde(default)] @@ -814,19 +840,45 @@ pub struct SceneWebcamEffect { /// natif ne devine pas. Absent = pas de segmentation, l'effet reste éteint. #[serde(default)] pub model_path: Option, + /// Paramètres chroma key (fond vert) quand `mode == "chromakey"`. + #[serde(default)] + pub chroma_key: Option, } impl SceneWebcamEffect { /// Code passé au shader dans `fx.z` : 0 = aucun (la webcam se dessine telle quelle), - /// 1 = détourage, 2 = flou, 3 = fond personnalisé. + /// 1 = détourage, 2 = flou, 3 = fond personnalisé, 4 = chroma key (fond vert). pub(crate) fn shader_code(&self) -> f32 { match self.mode.as_str() { "transparent" => 1.0, "blur" => 2.0, "custom" => 3.0, + "chromakey" => 4.0, _ => 0.0, } } + + /// Renvoie `(color, trail_a)` pour le `LayerCB` de la webcam. + /// En mode `"chromakey"`, `color` porte `[key_r, key_g, key_b, 1.0]` et + /// `trail_a` porte `[similarity, smoothness, spill, 0.0]`. + pub(crate) fn chroma_uniforms(&self) -> ([f32; 4], [f32; 4]) { + if self.mode == "chromakey" { + if let Some(ck) = &self.chroma_key { + let mut c = crate::frame_geometry::parse_hex(&ck.color) + .unwrap_or([0.0, 1.0, 0.0, 1.0]); + c[3] = 1.0; + let params = [ + ck.similarity.clamp(0.01, 0.95), + ck.smoothness.clamp(0.005, 0.5), + ck.spill.clamp(0.0, 1.0), + 0.0, + ]; + return (c, params); + } + return ([0.0, 1.0, 0.0, 1.0], [0.4, 0.1, 0.5, 0.0]); + } + ([0.0, 0.0, 0.0, 1.0], [0.0; 4]) + } } /// Tout ce dont le natif a besoin pour composer la scène, sérialisé depuis un document. @@ -1146,6 +1198,9 @@ mod tests { assert_eq!(scene_with(r#"{"mode":"transparent"}"#).shader_code(), 1.0); assert_eq!(scene_with(r#"{"mode":"blur","blurIntensity":0.75}"#).shader_code(), 2.0); assert_eq!(scene_with(r#"{"mode":"custom"}"#).shader_code(), 3.0); + assert_eq!(scene_with(r#"{"mode":"chromakey"}"#).shader_code(), 4.0); + let ck = scene_with(r##"{"mode":"chromakey","chromaKey":{"color":"#00FF00","similarity":0.35,"smoothness":0.15,"spill":0.6}}"##); + assert_eq!(ck.chroma_uniforms(), ([0.0, 1.0, 0.0, 1.0], [0.35, 0.15, 0.6, 0.0])); // Un mode inconnu (document trafiqué, schéma futur) ne doit pas allumer un effet. assert_eq!(scene_with(r#"{"mode":"hologram"}"#).shader_code(), 0.0); diff --git a/crates/compositor/src/shaders.hlsl b/crates/compositor/src/shaders.hlsl index 847882303..2662a79fb 100644 --- a/crates/compositor/src/shaders.hlsl +++ b/crates/compositor/src/shaders.hlsl @@ -4269,7 +4269,39 @@ float4 ps_main(VSOut i) : SV_Target // premiere frame : `person` vaut alors 0 et le mode 1 rendrait la webcam invisible, donc // c'est l'appelant qui ne met fx.z a autre chose que 0 qu'une fois un masque disponible. float effect = fx.z; - if (effect > 0.5) + if (effect > 3.5) + { + float3 key_rgb = color.rgb; + float2 cur_cbcr = float2( + -0.168736 * rgb.r - 0.331264 * rgb.g + 0.5 * rgb.b, + 0.5 * rgb.r - 0.418688 * rgb.g - 0.081312 * rgb.b + ); + float2 key_cbcr = float2( + -0.168736 * key_rgb.r - 0.331264 * key_rgb.g + 0.5 * key_rgb.b, + 0.5 * key_rgb.r - 0.418688 * key_rgb.g - 0.081312 * key_rgb.b + ); + float dist = length(cur_cbcr - key_cbcr); + float similarity = max(trail_a.x, 0.001); + float smoothness = max(trail_a.y, 0.001); + alpha_mask = smoothstep(similarity, similarity + smoothness, dist); + float spill = saturate(trail_a.z); + if (spill > 0.0) + { + if (key_rgb.g >= key_rgb.r && key_rgb.g >= key_rgb.b) + { + rgb.g = lerp(rgb.g, min(rgb.g, max(rgb.r, rgb.b)), spill); + } + else if (key_rgb.b >= key_rgb.r && key_rgb.b >= key_rgb.g) + { + rgb.b = lerp(rgb.b, min(rgb.b, max(rgb.r, rgb.g)), spill); + } + else + { + rgb.r = lerp(rgb.r, min(rgb.r, max(rgb.g, rgb.b)), spill); + } + } + } + else if (effect > 0.5) { float2 mask_uv = uv_now / max(fx.xy, 1e-6); float person = saturate(texMask.Sample(samp, mask_uv)); diff --git a/crates/compositor/src/shaders.metal b/crates/compositor/src/shaders.metal index 2a469a504..a4d7a847b 100644 --- a/crates/compositor/src/shaders.metal +++ b/crates/compositor/src/shaders.metal @@ -3965,10 +3965,42 @@ fragment float4 ps_main(VSOut i [[stage_in]], } // Effet d'arriere-plan webcam. Miroir exact de la branche HLSL : fx.z porte le mode - // (1 = detourage, 2 = flou, 3 = fond plat), fx.w l'intensite du flou, fx.xy l'etendue - // valide de la texture webcam pour ramener uv dans l'espace du masque. + // (1 = detourage, 2 = flou, 3 = fond plat, 4 = chroma key), fx.w l'intensite du flou, + // fx.xy l'etendue valide de la texture webcam pour ramener uv dans l'espace du masque. float effect = layer.fx.z; - if (effect > 0.5) + if (effect > 3.5) + { + float3 key_rgb = layer.color.rgb; + float2 cur_cbcr = float2( + -0.168736 * rgb.r - 0.331264 * rgb.g + 0.5 * rgb.b, + 0.5 * rgb.r - 0.418688 * rgb.g - 0.081312 * rgb.b + ); + float2 key_cbcr = float2( + -0.168736 * key_rgb.r - 0.331264 * key_rgb.g + 0.5 * key_rgb.b, + 0.5 * key_rgb.r - 0.418688 * key_rgb.g - 0.081312 * key_rgb.b + ); + float dist = length(cur_cbcr - key_cbcr); + float similarity = max(layer.trail_a.x, 0.001); + float smoothness = max(layer.trail_a.y, 0.001); + alpha_mask = smoothstep(similarity, similarity + smoothness, dist); + float spill = saturate(layer.trail_a.z); + if (spill > 0.0) + { + if (key_rgb.g >= key_rgb.r && key_rgb.g >= key_rgb.b) + { + rgb.g = mix(rgb.g, min(rgb.g, max(rgb.r, rgb.b)), spill); + } + else if (key_rgb.b >= key_rgb.r && key_rgb.b >= key_rgb.g) + { + rgb.b = mix(rgb.b, min(rgb.b, max(rgb.r, rgb.g)), spill); + } + else + { + rgb.r = mix(rgb.r, min(rgb.r, max(rgb.g, rgb.b)), spill); + } + } + } + else if (effect > 0.5) { float2 mask_uv = uv_now / max(layer.fx.xy, float2(1e-6)); float person = saturate(texMask.sample(samp, mask_uv).r); diff --git a/crates/compositor/src/vk_shaders/layer.wgsl b/crates/compositor/src/vk_shaders/layer.wgsl index 48902d420..96e50572f 100644 --- a/crates/compositor/src/vk_shaders/layer.wgsl +++ b/crates/compositor/src/vk_shaders/layer.wgsl @@ -2483,10 +2483,34 @@ fn fs_main(i: VsOut) -> @location(0) vec4 { } // Effet d'arriere-plan webcam. Miroir exact des branches HLSL et MSL : fx.z porte le - // mode (1 = detourage, 2 = flou, 3 = fond plat), fx.w l'intensite du flou, fx.xy - // l'etendue valide de la texture webcam pour ramener uv dans l'espace du masque. + // mode (1 = detourage, 2 = flou, 3 = fond plat, 4 = chroma key), fx.w l'intensite du flou, + // fx.xy l'etendue valide de la texture webcam pour ramener uv dans l'espace du masque. let effect = layer.fx.z; - if effect > 0.5 { + if effect > 3.5 { + let key_rgb = layer.color.rgb; + let cur_cbcr = vec2( + -0.168736 * rgb.r - 0.331264 * rgb.g + 0.5 * rgb.b, + 0.5 * rgb.r - 0.418688 * rgb.g - 0.081312 * rgb.b, + ); + let key_cbcr = vec2( + -0.168736 * key_rgb.r - 0.331264 * key_rgb.g + 0.5 * key_rgb.b, + 0.5 * key_rgb.r - 0.418688 * key_rgb.g - 0.081312 * key_rgb.b, + ); + let dist = length(cur_cbcr - key_cbcr); + let similarity = max(layer.trail_a.x, 0.001); + let smoothness = max(layer.trail_a.y, 0.001); + alpha_mask = smoothstep(similarity, similarity + smoothness, dist); + let spill = clamp(layer.trail_a.z, 0.0, 1.0); + if spill > 0.0 { + if key_rgb.g >= key_rgb.r && key_rgb.g >= key_rgb.b { + rgb.g = mix(rgb.g, min(rgb.g, max(rgb.r, rgb.b)), spill); + } else if key_rgb.b >= key_rgb.r && key_rgb.b >= key_rgb.g { + rgb.b = mix(rgb.b, min(rgb.b, max(rgb.r, rgb.g)), spill); + } else { + rgb.r = mix(rgb.r, min(rgb.r, max(rgb.g, rgb.b)), spill); + } + } + } else if effect > 0.5 { let mask_uv = i.uv / max(layer.fx.xy, vec2(1e-6)); let person = clamp(textureSample(texMask, samp, mask_uv).r, 0.0, 1.0); if effect > 2.5 { diff --git a/electron/ai-edition/editor-settings-tool.ts b/electron/ai-edition/editor-settings-tool.ts index 5d828406a..941fddebf 100644 --- a/electron/ai-edition/editor-settings-tool.ts +++ b/electron/ai-edition/editor-settings-tool.ts @@ -133,6 +133,12 @@ export const setEditorSettingsArgs = z webcamBackgroundMode: z.enum(WEBCAM_BACKGROUND_MODES), webcamWallpaper: wallpaperArg("webcamWallpaper"), webcamBlurIntensity: bounded("webcamBlurIntensity"), + webcamChromaKeyColor: z + .string() + .regex(/^#[0-9a-fA-F]{6}$/, "Must be a 6-digit hex color like #00FF00."), + webcamChromaKeySimilarity: bounded("webcamChromaKeySimilarity"), + webcamChromaKeySmoothness: bounded("webcamChromaKeySmoothness"), + webcamChromaKeySpill: bounded("webcamChromaKeySpill"), audioGainDb: z.number().min(-AUDIO_GAIN_DB_LIMIT).max(AUDIO_GAIN_DB_LIMIT), autoFocusAll: z.boolean(), cursor: z @@ -194,6 +200,10 @@ function settingsForModel(document: AxcutDocument) { webcamBackgroundMode: s.webcamBackgroundMode, webcamWallpaper: elideDataUrl(s.webcamWallpaper), webcamBlurIntensity: s.webcamBlurIntensity, + webcamChromaKeyColor: s.webcamChromaKeyColor, + webcamChromaKeySimilarity: s.webcamChromaKeySimilarity, + webcamChromaKeySmoothness: s.webcamChromaKeySmoothness, + webcamChromaKeySpill: s.webcamChromaKeySpill, audioGainDb: s.audioGainDb, autoFocusAll: s.autoFocusAll, cursor: { @@ -233,6 +243,10 @@ const WEBCAM_KEYS = [ "webcamBackgroundMode", "webcamWallpaper", "webcamBlurIntensity", + "webcamChromaKeyColor", + "webcamChromaKeySimilarity", + "webcamChromaKeySmoothness", + "webcamChromaKeySpill", ] as const; /** @@ -290,6 +304,16 @@ export function inactiveSettings( out.webcamWallpaper = "Only used when webcamBackgroundMode is custom."; } } + if (s.webcamBackgroundMode !== "chromakey") { + for (const key of [ + "webcamChromaKeyColor", + "webcamChromaKeySimilarity", + "webcamChromaKeySmoothness", + "webcamChromaKeySpill", + ] as const) { + out[key] = "Only used when webcamBackgroundMode is chromakey."; + } + } } if (!s.cursorShow) { diff --git a/src/components/ai-edition/RightPanes.layout.test.tsx b/src/components/ai-edition/RightPanes.layout.test.tsx index f3eaec126..f4ca7bbc5 100644 --- a/src/components/ai-edition/RightPanes.layout.test.tsx +++ b/src/components/ai-edition/RightPanes.layout.test.tsx @@ -227,6 +227,45 @@ describe("LayoutPane webcam framing background", () => { expect(segmentation.segmentCameraFrame).toHaveBeenCalledTimes(1); }); + + it("offers Green screen as a toggle inside Cutout without running ONNX segmentation", () => { + renderLayout(withBackground("chromakey")); + showFrame(); + expect(segmentation.segmentCameraFrame).not.toHaveBeenCalled(); + + const greenScreenToggle = screen.getByRole("button", { name: "Green screen" }); + expect(greenScreenToggle).toHaveAttribute("aria-pressed", "true"); + expect(screen.getByRole("button", { name: "Key color" })).toBeEnabled(); + + const tolerance = screen.getByRole("slider", { name: "Tolerance" }); + const softness = screen.getByRole("slider", { name: "Edge softness" }); + const spill = screen.getByRole("slider", { name: "Spill suppression" }); + expect(tolerance).toHaveValue("40"); + expect(softness).toHaveValue("10"); + expect(spill).toHaveValue("50"); + + fireEvent.change(tolerance, { target: { value: "55" } }); + fireEvent.change(softness, { target: { value: "25" } }); + fireEvent.change(spill, { target: { value: "80" } }); + expect(useProjectStore.getState().document?.legacyEditor).toMatchObject({ + webcamChromaKeySimilarity: 0.55, + webcamChromaKeySmoothness: 0.25, + webcamChromaKeySpill: 0.8, + }); + + // Turning the Green screen toggle off switches back to standard AI Cutout ("transparent"). + fireEvent.click(greenScreenToggle); + expect(useProjectStore.getState().document?.legacyEditor).toMatchObject({ + webcamBackgroundMode: "transparent", + }); + expect(screen.queryByRole("slider", { name: "Tolerance" })).not.toBeInTheDocument(); + + // Turning it back on from Cutout re-enables Green screen ("chromakey"). + fireEvent.click(screen.getByRole("button", { name: "Green screen" })); + expect(useProjectStore.getState().document?.legacyEditor).toMatchObject({ + webcamBackgroundMode: "chromakey", + }); + }); }); describe("LayoutPane picture-in-picture camera", () => { diff --git a/src/components/ai-edition/RightPanes.tsx b/src/components/ai-edition/RightPanes.tsx index c0551227d..61304e70d 100644 --- a/src/components/ai-edition/RightPanes.tsx +++ b/src/components/ai-edition/RightPanes.tsx @@ -3055,7 +3055,13 @@ export function LayoutPane() { }} > {CAMERA_BACKGROUND_MODES.map((mode) => { - const isActive = settings.webcamBackgroundMode === mode.value; + const isCutoutMode = + settings.webcamBackgroundMode === "transparent" || + settings.webcamBackgroundMode === "chromakey"; + const isActive = + mode.value === "transparent" + ? isCutoutMode + : settings.webcamBackgroundMode === mode.value; return (