diff --git a/biome.json b/biome.json index 4fa1d2fdc..8954e61bf 100644 --- a/biome.json +++ b/biome.json @@ -3,7 +3,7 @@ "vcs": { "enabled": true, "clientKind": "git", "useIgnoreFile": true }, "files": { "ignoreUnknown": false, - "includes": ["**", "!**/*.css", "!**/design/**", "!**/.worktrees/**"] + "includes": ["**", "!**/*.css", "!**/design/**", "!**/.worktrees/**", "!**/public/mediapipe/**"] }, "formatter": { "enabled": true, diff --git a/crates/Cargo.lock b/crates/Cargo.lock index dbe2f253a..940c3b7dc 100644 --- a/crates/Cargo.lock +++ b/crates/Cargo.lock @@ -44,7 +44,7 @@ version = "0.38.0+1.3.281" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0bb44936d800fea8f016d7f2311c6a4f97aebd5dc86f09906139ec848cf3a46f" dependencies = [ - "libloading", + "libloading 0.8.9", ] [[package]] @@ -180,7 +180,7 @@ checksum = "0b023947811758c97c59bf9d1c188fd619ad4718dcaa767947df1cadb14f39f4" dependencies = [ "glob", "libc", - "libloading", + "libloading 0.8.9", ] [[package]] @@ -652,7 +652,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6aae1df220ece3c0ada96b8153459b67eebe9ae9212258bb0134ae60416fdf76" dependencies = [ "libc", - "libloading", + "libloading 0.8.9", "pkg-config", ] @@ -678,6 +678,16 @@ dependencies = [ "windows-link", ] +[[package]] +name = "libloading" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "754ca22de805bb5744484a5b151a9e1a8e837d5dc232c2d7d8c2e3492edc8b60" +dependencies = [ + "cfg-if", + "windows-link", +] + [[package]] name = "libm" version = "0.2.16" @@ -720,6 +730,16 @@ dependencies = [ "libc", ] +[[package]] +name = "matrixmultiply" +version = "0.3.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f607c237553f086e7043417a51df26b2eb899d3caff94e6a67592ff992fedc7" +dependencies = [ + "autocfg", + "rawpointer", +] + [[package]] name = "memchr" version = "2.8.3" @@ -867,7 +887,37 @@ version = "2.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "427802e8ec3a734331fec1035594a210ce1ff4dc5bc1950530920ab717964ea3" dependencies = [ - "libloading", + "libloading 0.8.9", +] + +[[package]] +name = "ndarray" +version = "0.16.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "882ed72dce9365842bf196bdeedf5055305f11fc8c03dee7bb0194a6cad34841" +dependencies = [ + "matrixmultiply", + "num-complex", + "num-integer", + "num-traits", + "portable-atomic", + "portable-atomic-util", + "rawpointer", +] + +[[package]] +name = "ndarray" +version = "0.17.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "520080814a7a6b4a6e9070823bb24b4531daac8c4627e08ba5de8c5ef2f2752d" +dependencies = [ + "matrixmultiply", + "num-complex", + "num-integer", + "num-traits", + "portable-atomic", + "portable-atomic-util", + "rawpointer", ] [[package]] @@ -889,6 +939,24 @@ dependencies = [ "minimal-lexical", ] +[[package]] +name = "num-complex" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "73f88a1307638156682bada9d7604135552957b7818057dcef22705b4d509495" +dependencies = [ + "num-traits", +] + +[[package]] +name = "num-integer" +version = "0.1.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ce2d95d4b3734dc35aa2f45e1aa22cd416814592a4f9d9205e11affd5b8e10b" +dependencies = [ + "num-traits", +] + [[package]] name = "num-traits" version = "0.2.19" @@ -925,7 +993,9 @@ dependencies = [ "cosmic-text", "image", "metal 0.29.0", + "ndarray 0.16.1", "objc", + "ort", "pollster", "serde", "serde_json", @@ -942,6 +1012,25 @@ dependencies = [ "num-traits", ] +[[package]] +name = "ort" +version = "2.0.0-rc.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4336a1e2b38848325241c72889086886004e589b7c74f335e60a8e8db5138a0b" +dependencies = [ + "libloading 0.9.0", + "ndarray 0.17.2", + "ort-sys", + "smallvec", + "tracing", +] + +[[package]] +name = "ort-sys" +version = "2.0.0-rc.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf211e3776eea6aec988552fa118dd746d70e1b1e5e244058d1c98015f3e5872" + [[package]] name = "parking_lot" version = "0.12.5" @@ -1011,6 +1100,21 @@ version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2f3a9f18d041e6d0e102a0a46750538147e5e8992d3b4873aaafee2520b00ce3" +[[package]] +name = "portable-atomic" +version = "1.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05c8b63e8d9609db387f0324918f81d68fe27748f084ef092fb35954d0539a85" + +[[package]] +name = "portable-atomic-util" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2a106d1259c23fac8e543272398ae0e3c0b8d33c88ed73d0cc71b0f1d902618" +dependencies = [ + "portable-atomic", +] + [[package]] name = "presser" version = "0.3.1" @@ -1075,6 +1179,12 @@ version = "0.6.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "20675572f6f24e9e76ef639bc5552774ed45f1c30e2951e1e99c59888861c539" +[[package]] +name = "rawpointer" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "60a357793950651c4ed0f3f52338f53b2f809f32d83a07f72909fa13e4c6c1e3" + [[package]] name = "read-fonts" version = "0.37.0" @@ -1434,6 +1544,25 @@ version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" +[[package]] +name = "tracing" +version = "0.1.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" +dependencies = [ + "pin-project-lite", + "tracing-core", +] + +[[package]] +name = "tracing-core" +version = "0.1.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" +dependencies = [ + "once_cell", +] + [[package]] name = "ttf-parser" version = "0.25.1" @@ -1630,7 +1759,7 @@ dependencies = [ "js-sys", "khronos-egl", "libc", - "libloading", + "libloading 0.8.9", "log", "metal 0.31.0", "naga", diff --git a/crates/Cargo.toml b/crates/Cargo.toml index 208f376cc..abd74df99 100644 --- a/crates/Cargo.toml +++ b/crates/Cargo.toml @@ -34,6 +34,26 @@ image = { version = "0.25", default-features = false, features = ["jpeg", "png"] wgpu = { version = "24", features = ["wgsl"] } pollster = "0.4" cosmic-text = "0.19" +# Inference for the webcam segmentation mask. CPU execution provider only: measured on the +# target integrated GPU it costs +0.47 ms/frame against DirectML's +1.03, its cost does not +# scale with input resolution, and choosing it deletes the whole D3D11<->D3D12 interop +# (technical-documentation/engineering/webcam-segmentation.md). +# +# Behind the `segmentation` feature and OFF by default: `download-binaries` fetches the +# ONNX Runtime libs at build time, which is a packaging decision (nix, AUR, MS Store, CI) +# that has not been taken yet. The default build is unchanged. +# `load-dynamic` et NON `download-binaries` : ce dernier tire une build STATIQUE d'ONNX +# Runtime avec DirectML dedans (DirectML.lib, DXCORE.lib et les DmlOperator* apparaissent +# dans la ligne de lien) — exactement la dépendance que le choix de l'EP CPU sert à +# supprimer. En chargement dynamique, la lib est résolue à l'exécution, ce qui laisse le +# packaging la stager par plateforme comme il le fait déjà pour whisper-stt. +ort = { version = "2.0.0-rc.13", default-features = false, features = [ + "std", + "ndarray", + "load-dynamic", + "api-27", +] } +ndarray = "0.16" [workspace.dependencies.windows] version = "0.58" diff --git a/crates/compositor/Cargo.toml b/crates/compositor/Cargo.toml index f968b5497..c3a3fae26 100644 --- a/crates/compositor/Cargo.toml +++ b/crates/compositor/Cargo.toml @@ -12,8 +12,20 @@ path = "src/lib.rs" bindgen = "0.70" cc = "1" +[features] +default = ["segmentation"] +# Segmentation IA de la webcam via ONNX Runtime (EP CPU). +# +# Activée par défaut, ce qui ne coûte rien au build : `ort` est lié en `load-dynamic`, donc +# aucune bibliothèque n'est nécessaire pour COMPILER. Elle l'est pour tourner — absente, +# `Segmenter::load` échoue, le compositeur écrit une ligne et dessine la webcam telle quelle. +# La désactiver reste possible pour une build qui ne veut pas du tout du code d'inférence. +segmentation = ["dep:ort", "dep:ndarray"] + [dependencies] anyhow.workspace = true +ort = { workspace = true, optional = true } +ndarray = { workspace = true, optional = true } serde.workspace = true serde_json.workspace = true image.workspace = true diff --git a/crates/compositor/src/compositor_linux.rs b/crates/compositor/src/compositor_linux.rs index 03a0aa6b9..5691dd9f7 100644 --- a/crates/compositor/src/compositor_linux.rs +++ b/crates/compositor/src/compositor_linux.rs @@ -238,6 +238,12 @@ impl Compositor { ty: wgpu::BindingType::Sampler(wgpu::SamplerBindingType::Filtering), count: None, }, + // Masque de segmentation du sujet webcam. TOUJOURS declare, meme sans + // masque : wgpu valide le bind group contre le layout, donc une entree + // absente ferait echouer chaque draw et pas seulement ceux qui l'utilisent. + // `dummy_view()` est lie a la place, et la branche du shader n'est de + // toute facon prise que si fx.z > 0.5. + tex_entry(4), ], }); let pipeline_layout = gpu.device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor { @@ -945,6 +951,14 @@ impl Compositor { binding: 3, resource: wgpu::BindingResource::Sampler(&self.sampler), }, + // Le portage Linux de la segmentation (capture + televersement du masque) + // n'est pas fait : on lie le dummy, ce qui laisse `fx.z` a 0 cote scene et + // donc la branche du shader jamais prise. Voir + // `technical-documentation/engineering/webcam-segmentation.md`. + wgpu::BindGroupEntry { + binding: 4, + resource: wgpu::BindingResource::TextureView(dummy), + }, ], }); (uniform, bind) diff --git a/crates/compositor/src/compositor_windows.rs b/crates/compositor/src/compositor_windows.rs index 51c3a3a28..e59518b48 100644 --- a/crates/compositor/src/compositor_windows.rs +++ b/crates/compositor/src/compositor_windows.rs @@ -46,6 +46,27 @@ use windows::Win32::Graphics::Dxgi::Common::*; +/// Cadence de l'inférence. Pas 60 : une silhouette ne bouge pas de façon perceptible en +/// 16 ms, et c'est le seul levier mesuré qui divise le coût par deux sans toucher au modèle. +const SEGMENTATION_HZ: u32 = 30; + +/// Cible RGBA + staging CPU pour l'extraction de la frame webcam qui alimente le modèle. +struct SegCapture { + rtv: ID3D11RenderTargetView, + rt: ID3D11Texture2D, + staging: ID3D11Texture2D, + width: u32, + height: u32, +} + +/// Texture du masque de segmentation, recréée seulement quand la résolution du modèle change. +struct WebcamMask { + tex: ID3D11Texture2D, + srv: ID3D11ShaderResourceView, + width: u32, + height: u32, +} + pub struct Compositor { dev: ID3D11Device, ctx: ID3D11DeviceContext, @@ -125,6 +146,11 @@ pub struct Compositor { /// Cache des textures wallpaper image (clé = chemin absolu) : décodage/upload une seule /// fois, puis réutilisées par frame. (SRV, largeur, hauteur). img_cache: RefCell>, + /// Masque de segmentation du sujet webcam, R8 à la résolution du modèle. Écrit par + /// `set_webcam_mask` depuis le thread d'inférence, lu au moment de dessiner la webcam. + /// `None` tant qu'aucune frame n'a été segmentée — l'effet reste alors éteint plutôt que + /// de rendre une webcam invisible en mode détourage. + webcam_mask: RefCell>, /// Dimensions du RENDER TARGET en pixels — la taille à laquelle `compose_frame` /// rastérise réellement, et donc le dénominateur de TOUTE conversion /// normalisé↔px de ce fichier. @@ -150,6 +176,21 @@ pub struct Compositor { /// de prévisualisation demandée (variable, contrairement au `staging` fixe à /// OUT_W×OUT_H). Recréée quand la taille change — voir `readback_resized`. live_readback_staging: RefCell>, + /// Cible + staging pour extraire la frame webcam à la résolution du modèle de + /// segmentation. Créée à la première capture, jamais redimensionnée : le modèle a une + /// entrée fixe. + seg_capture: RefCell>, + /// Worker d'inférence, absent tant que `enable_segmentation` n'a pas été appelé. + seg_worker: RefCell>, + /// Boîte aux lettres du worker. Le masque est déposé depuis le thread d'inférence et + /// téléversé depuis le thread de rendu : aucun appel D3D ne traverse de thread, malgré + /// le device multithread-protected qui l'autoriserait. + seg_inbox: std::sync::Arc>>>, + seg_rate: RefCell, + /// Frame RGB réutilisée d'une capture à l'autre. + seg_scratch: RefCell>, + /// Le chargement du modèle a échoué : ne pas réessayer à chaque frame. + seg_failed: RefCell, /// Staging NV12 du readback d'ENCODAGE (backend CPU) — même motif de cache par taille /// que `live_readback_staging`, mais en NV12 et non en RGBA : l'encodeur logiciel veut /// les plans Y/UV, pas des pixels RGBA. Voir `read_nv12_scaled`. @@ -556,9 +597,16 @@ impl Compositor { text_cache: RefCell::new(HashMap::new()), ann_img_cache: RefCell::new(HashMap::new()), img_cache: RefCell::new(HashMap::new()), + webcam_mask: RefCell::new(None), render_size: Cell::new((out_w, out_h)), resize_target: RefCell::new(None), live_readback_staging: RefCell::new(None), + seg_capture: RefCell::new(None), + seg_worker: RefCell::new(None), + seg_inbox: std::sync::Arc::new(std::sync::Mutex::new(None)), + seg_rate: RefCell::new(crate::segmentation::RateLimiter::new(SEGMENTATION_HZ)), + seg_scratch: RefCell::new(Vec::new()), + seg_failed: RefCell::new(false), nv12_readback_staging: RefCell::new(None), }) } @@ -846,6 +894,269 @@ impl Compositor { Ok((srv.unwrap(), w, h)) } + /// Extrait la frame webcam en RGB8 à la résolution du modèle, dans `out`. + /// + /// `src` est le rect source de la webcam en UV (le même que celui passé à `draw_video`), + /// donc le crop utilisateur et le miroir sont déjà dedans — le modèle voit exactement ce + /// que le spectateur verra, et le masque n'a pas à être recadré après coup. + /// + /// **À appeler AVANT `begin()`** : la méthode réquisitionne la cible de rendu et le + /// viewport, et ne les restaure pas. Les appeler dans l'autre ordre dessinerait la scène + /// dans une texture de 256x144. + /// + /// C'est le seul readback GPU->CPU du chemin. Il porte 256x144x4 = 147 Ko, contre la + /// frame entière que la preview lit déjà à chaque image ; sur le chemin export, qui lui + /// est GPU-résident de bout en bout, c'est en revanche un point de synchronisation neuf + /// et c'est là qu'il faudra le mesurer. + pub unsafe fn capture_webcam_rgb( + &self, + wy: &ID3D11ShaderResourceView, + wuv: &ID3D11ShaderResourceView, + src: [f32; 4], + width: u32, + height: u32, + out: &mut Vec, + ) -> Result<()> { + if width == 0 || height == 0 { + bail!("capture webcam de dimensions nulles ({width}x{height})"); + } + { + let mut slot = self.seg_capture.borrow_mut(); + if !matches!(slot.as_ref(), Some(c) if c.width == width && c.height == height) { + let td = D3D11_TEXTURE2D_DESC { + Width: width, + Height: height, + MipLevels: 1, + ArraySize: 1, + Format: DXGI_FORMAT_R8G8B8A8_UNORM, + SampleDesc: DXGI_SAMPLE_DESC { Count: 1, Quality: 0 }, + Usage: D3D11_USAGE_DEFAULT, + BindFlags: D3D11_BIND_RENDER_TARGET.0 as u32, + CPUAccessFlags: 0, + MiscFlags: 0, + }; + let mut rt: Option = None; + self.dev.CreateTexture2D(&td, None, Some(&mut rt))?; + let rt = rt.unwrap(); + let mut rtv: Option = None; + self.dev.CreateRenderTargetView(&rt, None, Some(&mut rtv))?; + + let sd = D3D11_TEXTURE2D_DESC { + Usage: D3D11_USAGE_STAGING, + BindFlags: 0, + CPUAccessFlags: D3D11_CPU_ACCESS_READ.0 as u32, + ..td + }; + let mut staging: Option = None; + self.dev.CreateTexture2D(&sd, None, Some(&mut staging))?; + + *slot = Some(SegCapture { + rtv: rtv.unwrap(), + rt, + staging: staging.unwrap(), + width, + height, + }); + } + } + + let cap = self.seg_capture.borrow(); + let cap = cap.as_ref().expect("créé juste au-dessus"); + + self.bind_compose_state(); + self.ctx.OMSetBlendState(&self.blend_none, None, 0xffffffff); + self.ctx.OMSetRenderTargets(Some(&[Some(cap.rtv.clone())]), None); + let vp = D3D11_VIEWPORT { + TopLeftX: 0.0, TopLeftY: 0.0, + Width: width as f32, Height: height as f32, MinDepth: 0.0, MaxDepth: 1.0, + }; + self.ctx.RSSetViewports(Some(&[vp])); + // Plein cadre de la cible, sans coins ni motion blur : le modèle veut l'image, pas + // la mise en forme. + self.draw_video( + &LayerCB { + dst: [0.0, 0.0, 1.0, 1.0], + src, + quad_px: [width as f32, height as f32], + mode: 0.0, + color: [0.0, 0.0, 0.0, 1.0], + mb: [1.0, 1.0, 1.0, 0.0], + ..Default::default() + }, + wy, + wuv, + ); + + self.ctx.CopyResource(&cap.staging, &cap.rt); + let mut mapped = D3D11_MAPPED_SUBRESOURCE::default(); + self.ctx.Map(&cap.staging, 0, D3D11_MAP_READ, 0, Some(&mut mapped))?; + out.clear(); + out.reserve((width * height * 3) as usize); + for row in 0..height as usize { + let line = (mapped.pData as *const u8).add(row * mapped.RowPitch as usize); + for col in 0..width as usize { + let px = line.add(col * 4); + // RGBA -> RGB : le modèle n'a pas de canal alpha en entrée. + out.push(*px); + out.push(*px.add(1)); + out.push(*px.add(2)); + } + } + self.ctx.Unmap(&cap.staging, 0); + Ok(()) + } + + /// Publie le masque de segmentation du sujet webcam (R8, `width`x`height`, 0 = fond). + /// + /// Appelé depuis le thread d'inférence, pas depuis le thread de rendu — d'où le + /// `SetMultithreadProtected(true)` posé à la création du device (`d3d_windows.rs`). La + /// texture est `DYNAMIC` et réécrite en place ; elle n'est recréée que si la résolution du + /// modèle change, ce qui n'arrive pas en régime établi. + pub fn set_webcam_mask(&self, data: &[u8], width: u32, height: u32) -> Result<()> { + if width == 0 || height == 0 { + bail!("masque webcam de dimensions nulles ({width}x{height})"); + } + let expected = (width as usize) * (height as usize); + if data.len() < expected { + bail!("masque webcam trop court : {} octets pour {width}x{height}", data.len()); + } + + let mut slot = self.webcam_mask.borrow_mut(); + let needs_alloc = !matches!(slot.as_ref(), Some(m) if m.width == width && m.height == height); + if needs_alloc { + let td = D3D11_TEXTURE2D_DESC { + Width: width, + Height: height, + MipLevels: 1, + ArraySize: 1, + Format: DXGI_FORMAT_R8_UNORM, + SampleDesc: DXGI_SAMPLE_DESC { Count: 1, Quality: 0 }, + Usage: D3D11_USAGE_DYNAMIC, + BindFlags: D3D11_BIND_SHADER_RESOURCE.0 as u32, + CPUAccessFlags: D3D11_CPU_ACCESS_WRITE.0 as u32, + MiscFlags: 0, + }; + let mut tex: Option = None; + unsafe { self.dev.CreateTexture2D(&td, None, Some(&mut tex))? }; + let tex = tex.unwrap(); + let mut srv: Option = None; + unsafe { self.dev.CreateShaderResourceView(&tex, None, Some(&mut srv))? }; + *slot = Some(WebcamMask { tex, srv: srv.unwrap(), width, height }); + } + + let mask = slot.as_ref().expect("alloué juste au-dessus"); + unsafe { + let mut mapped = D3D11_MAPPED_SUBRESOURCE::default(); + self.ctx.Map(&mask.tex, 0, D3D11_MAP_WRITE_DISCARD, 0, Some(&mut mapped))?; + // `RowPitch` n'est pas `width` : le driver aligne les lignes, donc on recopie + // ligne à ligne plutôt que d'un bloc. + for row in 0..height as usize { + let dst = (mapped.pData as *mut u8).add(row * mapped.RowPitch as usize); + let src = data.as_ptr().add(row * width as usize); + std::ptr::copy_nonoverlapping(src, dst, width as usize); + } + self.ctx.Unmap(&mask.tex, 0); + } + Ok(()) + } + + /// Un tour de segmentation : téléverse le masque prêt, puis soumet une nouvelle frame si + /// la cadence l'autorise. + /// + /// Les deux moitiés sont volontairement désynchronisées. Le masque téléversé ici vient de + /// la frame précédente — une frame de retard sur une silhouette est invisible, alors + /// qu'attendre l'inférence bloquerait le rendu, ce qui est exactement le coût que toute + /// cette conception cherche à ne pas payer. + unsafe fn pump_segmentation( + &self, + wy: &ID3D11ShaderResourceView, + wuv: &ID3D11ShaderResourceView, + valid: [f32; 2], + ) -> Result<()> { + if *self.seg_failed.borrow() { + return Ok(()); + } + // Rien à faire si aucun effet n'est demandé : ni capture, ni inférence, ni masque. + // Le coût de la fonctionnalité est alors exactement nul. + 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()), + _ => (false, None), + } + }; + if !wants_effect { + return Ok(()); + } + + // Démarrage paresseux, piloté par la scène : personne n'a à appeler + // `enable_segmentation` à la main, et un modèle introuvable éteint l'effet au lieu + // de faire tomber le rendu. + if self.seg_worker.borrow().is_none() { + let Some(path) = model_path else { return Ok(()) }; + if let Err(e) = self.enable_segmentation(std::path::Path::new(&path)) { + eprintln!("[segmentation] désactivée : {e}"); + // Une scène qui reste identique retenterait à chaque frame ; on pose un + // worker vide plutôt que de journaliser 60 fois par seconde. + *self.seg_failed.borrow_mut() = true; + } + return Ok(()); + } + + if let Some(mask) = self.seg_inbox.lock().unwrap().take() { + self.set_webcam_mask( + &mask, + crate::segmentation::MODEL_WIDTH, + crate::segmentation::MODEL_HEIGHT, + )?; + } + + if !self.seg_rate.borrow_mut().should_run(std::time::Instant::now()) { + return Ok(()); + } + let mut scratch = self.seg_scratch.borrow_mut(); + // La frame ENTIÈRE, pas le sous-rect dessiné : un crop utilisateur serré amputerait + // le sujet en entrée du modèle, et le masque serait faux là où il compte le plus. + // Le shader ramène ses coordonnées dans cet espace via `fx.xy`. + self.capture_webcam_rgb( + wy, + wuv, + [0.0, 0.0, valid[0], valid[1]], + crate::segmentation::MODEL_WIDTH, + crate::segmentation::MODEL_HEIGHT, + &mut scratch, + )?; + if let Some(w) = self.seg_worker.borrow().as_ref() { + w.submit(&scratch); + } + Ok(()) + } + + /// Démarre la segmentation du sujet webcam pour ce compositeur. + /// + /// Idempotent. Tant qu'elle n'est pas appelée, `compose_frame` ne fait rien de plus et + /// la webcam se dessine comme avant — c'est ce qui rend l'effet inerte plutôt que cassé + /// sur une build sans modèle. + pub fn enable_segmentation(&self, model_path: &std::path::Path) -> Result<()> { + if self.seg_worker.borrow().is_some() { + return Ok(()); + } + let segmenter = crate::segmentation::Segmenter::load(model_path)?; + let inbox = std::sync::Arc::clone(&self.seg_inbox); + let worker = crate::segmentation::SegmentationWorker::spawn(segmenter, move |mask, _, _| { + // Écrase le masque précédent s'il n'a pas encore été téléversé : c'est le plus + // récent qui vaut, jamais une file. + *inbox.lock().unwrap() = Some(mask.to_vec()); + }); + *self.seg_worker.borrow_mut() = Some(worker); + Ok(()) + } + + /// Éteint l'effet : la webcam se redessine telle quelle à la frame suivante. + pub fn clear_webcam_mask(&self) { + *self.webcam_mask.borrow_mut() = None; + } + pub fn set_cursor(&self, track: CursorTrack) { *self.cursor.borrow_mut() = Some(track); } @@ -1097,6 +1408,12 @@ impl Compositor { let (wtw, wth) = self.tex_dims(webcam); let (scw, sch) = ((*screen).width as f32, (*screen).height as f32); let (wcw, wch) = ((*webcam).width as f32, (*webcam).height as f32); + // Étendue valide de la texture webcam : les décodeurs allouent des textures alignées, + // donc la frame n'occupe pas forcément toute la texture. + let w_valid = [wcw / wtw as f32, wch / wth as f32]; + + // Segmentation, AVANT `begin()` : la capture réquisitionne la cible de rendu. + self.pump_segmentation(&wy, &wuv, w_valid)?; let u_max = scw / stw as f32; let v_max = sch / sth as f32; @@ -1538,7 +1855,13 @@ impl Compositor { scene_preset.as_deref(), Some("dual-frame") | Some("vertical-stack"), ); - if cfg.shadow && !webcam_is_block && shape_fade > 0.0 { + // 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(); + if cfg.shadow && !webcam_is_block && !is_cutout && shape_fade > 0.0 { let strength = WEBCAM_SHADOW_OPACITY * shape_fade; self.draw_shadow( w_dst, @@ -1549,6 +1872,41 @@ impl Compositor { strength, ); } + // Effet d'arrière-plan : le mode vient de la scène, le masque par pixel de + // l'inférence. Les DEUX sont requis — un mode sans masque rendrait la webcam + // invisible en détourage, donc tant que rien n'a été segmenté on dessine la piste + // telle quelle. C'est aussi ce qui rend le premier lancement gracieux. + let mask = self.webcam_mask.borrow(); + let effect = scene_ref + .as_ref() + .and_then(|s| s.webcam_effect.as_ref()) + .filter(|_| mask.is_some()) + .map(|e| (e.shader_code(), e)) + .filter(|(code, _)| *code > 0.0); + + let (effect_code, blur_intensity, bg_color) = match effect { + Some((code, e)) if code > 2.5 => { + // Fond personnalisé : seule une couleur plate se peint dans le shader. Un + // dégradé ou une image passeraient par une texture, ce que ce calque ne + // porte pas encore — on retombe alors sur du noir plutôt que sur du hasard. + let col = match &e.background { + Some(SceneBackground::Color { color }) => { + parse_hex(color).unwrap_or([0.0, 0.0, 0.0, 1.0]) + } + _ => [0.0, 0.0, 0.0, 1.0], + }; + (code, 0.0, col) + } + Some((code, e)) => (code, e.blur_intensity.clamp(0.0, 1.0), [0.0, 0.0, 0.0, 1.0]), + None => (0.0, 0.0, [0.0, 0.0, 0.0, 1.0]), + }; + + if let Some(m) = mask.as_ref() { + // `draw_video` ne lie que les slots 0-1, donc le masque posé ici tient pour + // l'appel qui suit. Il est délié juste après pour ne pas fuir sur les calques + // d'annotation, qui utilisent eux aussi le slot 2 et au-delà. + self.ctx.PSSetShaderResources(3, Some(&[Some(m.srv.clone())])); + } self.draw_video( &LayerCB { dst: w_dst, @@ -1556,7 +1914,8 @@ impl Compositor { quad_px: w_px, radius_px: w_radius, mode: 0.0, - color: [0.0, 0.0, 0.0, 1.0], + color: bg_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, 1.0, 1.0, 0.0], @@ -1565,6 +1924,9 @@ impl Compositor { &wy, &wuv, ); + if mask.is_some() { + self.ctx.PSSetShaderResources(3, Some(&[None])); + } } // --- annotations : calque le plus haut, comme dans le DOM de la preview (le calque y est diff --git a/crates/compositor/src/lib.rs b/crates/compositor/src/lib.rs index d8972e7d1..90dee8155 100644 --- a/crates/compositor/src/lib.rs +++ b/crates/compositor/src/lib.rs @@ -40,6 +40,10 @@ pub mod regions; // n'est spécifique à Linux. pub mod remux; pub mod scene; +// Segmentation du sujet webcam (masque -> `t3`). Le module compile toujours ; sans la feature +// `segmentation` ses deux entrées échouent proprement, ce qui garde le reste du crate +// indépendant du choix de packaging d'ONNX Runtime. +pub mod segmentation; pub mod text_anim; pub mod text_plate; pub(crate) mod timeline_walk; diff --git a/crates/compositor/src/scene.rs b/crates/compositor/src/scene.rs index 2d20e233b..b6fb420c0 100644 --- a/crates/compositor/src/scene.rs +++ b/crates/compositor/src/scene.rs @@ -429,6 +429,43 @@ pub struct SceneOutput { pub fps: Option, } +/// Effet d'arrière-plan de la webcam. +/// +/// Ne porte que le MODE et ses paramètres — jamais des pixels. Le masque par pixel vient de +/// 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 SceneWebcamEffect { + /// "none" | "transparent" | "blur" | "custom" + pub mode: String, + /// 0..1, seulement pour `blur`. + #[serde(default)] + pub blur_intensity: f32, + /// Fond derrière le sujet pour `custom`, parsé comme `settings.wallpaper`. + #[serde(default)] + pub background: Option, + /// Chemin du modèle ONNX de segmentation. Même convention que `SceneCursorSprite::path` + /// ou qu'un wallpaper image : c'est l'app qui sait où ses assets sont installés, le + /// natif ne devine pas. Absent = pas de segmentation, l'effet reste éteint. + #[serde(default)] + pub model_path: 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é. + pub(crate) fn shader_code(&self) -> f32 { + match self.mode.as_str() { + "transparent" => 1.0, + "blur" => 2.0, + "custom" => 3.0, + _ => 0.0, + } + } +} + /// Tout ce dont le natif a besoin pour composer la scène, sérialisé depuis un document. #[derive(Debug, Clone, Deserialize)] #[serde(rename_all = "camelCase")] @@ -454,6 +491,9 @@ pub struct Scene { /// Crop écran par clip, dans le même ordre que `clips` (`cropByClip` côté TS). #[serde(default)] pub crop_by_clip: Vec>, + /// Effet d'arrière-plan de la webcam. Absent = aucun effet. + #[serde(default)] + pub webcam_effect: Option, /// État de rendu interne, positionné par `for_clip_window` (jamais envoyé par l'app). #[serde(skip)] pub(crate) active_clip_index: usize, @@ -601,6 +641,37 @@ 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"); + assert!(s.webcam_effect.is_none()); + } + + #[test] + fn webcam_effect_maps_each_mode_to_its_shader_code() { + let scene_with = |effect: &str| { + let json = format!( + r##"{{"clips":[],"layout":{{"preset":"picture-in-picture","webcamSize":1,"webcamShape":"rectangle","webcamMirror":false,"webcamPosition":null,"webcamReactiveZoom":false}},"effects":{{"padding":0,"blur":false,"shadow":0,"roundnessFrac":0,"motionBlur":0}},"background":{{"kind":"color","color":"#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}},"webcamEffect":{}}}"##, + effect + ); + Scene::from_json(&json).expect("parse avec webcamEffect").webcam_effect.expect("présent") + }; + + assert_eq!(scene_with(r#"{"mode":"none"}"#).shader_code(), 0.0); + 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); + // 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); + + let blur = scene_with(r#"{"mode":"blur","blurIntensity":0.75}"#); + assert_eq!(blur.blur_intensity, 0.75); + // `blurIntensity` absent => 0, pas une erreur de parse. + assert_eq!(scene_with(r#"{"mode":"blur"}"#).blur_intensity, 0.0); + + let custom = + scene_with(r##"{"mode":"custom","background":{"kind":"color","color":"#ff0080"}}"##); + match custom.background { + Some(SceneBackground::Color { color }) => assert_eq!(color, "#ff0080"), + other => panic!("attendu un fond couleur, obtenu {other:?}"), + } } } diff --git a/crates/compositor/src/segmentation.rs b/crates/compositor/src/segmentation.rs new file mode 100644 index 000000000..92b88d3fc --- /dev/null +++ b/crates/compositor/src/segmentation.rs @@ -0,0 +1,352 @@ +//! Segmentation du sujet webcam — le masque que `ps_main` consomme en `t3`. +//! +//! # Pourquoi l'EP CPU et pas le GPU +//! +//! Mesuré sur la cible (Radeon 610M intégré, cf. +//! `technical-documentation/engineering/webcam-segmentation.md`) : l'EP CPU coûte **+0,47 ms +//! par frame** au compositeur contre **+1,03 ms** pour DirectML, et — le point qui décide — +//! son coût **ne dépend pas de la résolution d'entrée**, là où celui de DirectML suit les +//! pixels. L'EP CPU à pleine résolution est donc moins cher que DirectML ne l'est jamais, +//! même à résolution réduite. +//! +//! Le vrai gain n'est pas la marge, il est architectural : pas de DirectML ⇒ pas de device +//! D3D12, pas de handle partagé, pas d'appariement de LUID d'adaptateur, pas de fence +//! inter-queue, et un seul chemin sur les trois plateformes au lieu de trois. +//! +//! # Le piège du nombre de threads +//! +//! Une session ONNX Runtime laissée par défaut prend tous les cœurs. Sur la machine de +//! mesure (4 cœurs) ça donne un **p95 de 24,9 ms** — une frame perdue à chaque fois que ça +//! tombe. `intra_op_num_threads = 2` est à 8 % du meilleur p10 avec moins de la moitié de la +//! traîne, et laisse deux cœurs au compositeur. La bonne valeur n'était pas la plus rapide. + +use anyhow::{bail, Result}; +use std::path::Path; +use std::sync::{Arc, Condvar, Mutex}; +use std::time::{Duration, Instant}; + +/// Résolution d'entrée du modèle vendorisé (`selfie_segmentation_landscape.onnx`). +/// +/// Le graphe est entièrement convolutif, donc réductible — mais mesuré, ça ne sert à rien : +/// le coût de l'EP CPU est plat en résolution. Et 128x80 n'est pas livrable, la caméra en +/// plein écran agrandit le masque ~15x et les cheveux s'effondrent en rampe. +pub const MODEL_WIDTH: u32 = 256; +pub const MODEL_HEIGHT: u32 = 144; + +/// Deux threads intra-op. Voir la note du module : le défaut prend toute la machine. +const INTRA_OP_THREADS: usize = 2; + +/// Segmenteur chargé, prêt à produire un masque par frame. +pub struct Segmenter { + #[cfg(feature = "segmentation")] + session: ort::session::Session, + /// Réutilisé d'une frame à l'autre pour ne pas réallouer 110 Ko à 30 Hz. + input_scratch: Vec, + mask_scratch: Vec, +} + +impl Segmenter { + /// Charge le modèle ONNX. `model_path` est le `.onnx` vendorisé à côté des `.tflite`. + #[cfg(feature = "segmentation")] + pub fn load(model_path: &Path) -> Result { + if !model_path.exists() { + bail!("modèle de segmentation absent : {}", model_path.display()); + } + // `ort::Error` est générique sur le type du builder, donc il ne satisfait pas les + // bornes d'`anyhow::Context` — d'où le `map_err` explicite plutôt qu'un `?` direct. + let session = (|| -> ort::Result { + ort::session::Session::builder()? + .with_intra_threads(INTRA_OP_THREADS)? + // Un seul thread inter-op : le graphe est une chaîne, il n'y a rien à + // paralléliser entre branches, et un pool de plus ne ferait que disputer les + // cœurs au compositeur. + .with_inter_threads(1)? + .commit_from_file(model_path) + })() + .map_err(|e| anyhow::anyhow!("chargement de {} : {e}", model_path.display()))?; + Ok(Self { + session, + input_scratch: vec![0.0; (MODEL_WIDTH * MODEL_HEIGHT * 3) as usize], + mask_scratch: vec![0; (MODEL_WIDTH * MODEL_HEIGHT) as usize], + }) + } + + #[cfg(not(feature = "segmentation"))] + pub fn load(_model_path: &Path) -> Result { + bail!("compilé sans la feature `segmentation`") + } + + /// Produit le masque du sujet à partir d'une frame RGB8 déjà mise à l'échelle du modèle. + /// + /// `rgb` fait `MODEL_WIDTH * MODEL_HEIGHT * 3` octets, entrelacé R,G,B. Le retour fait + /// `MODEL_WIDTH * MODEL_HEIGHT` octets, 0 = fond, 255 = sujet — exactement ce que + /// `Compositor::set_webcam_mask` attend. + /// + /// Le redimensionnement n'est pas fait ici : l'appelant a déjà la frame sur le GPU et sait + /// la réduire bien mieux qu'une boucle CPU. + #[cfg(feature = "segmentation")] + pub fn run(&mut self, rgb: &[u8]) -> Result<&[u8]> { + let expected = (MODEL_WIDTH * MODEL_HEIGHT * 3) as usize; + if rgb.len() != expected { + bail!("frame de {} octets, {expected} attendus", rgb.len()); + } + // Le modèle veut du 0..1 en NHWC — le même ordre que la frame entrelacée, donc une + // simple division sans transposition. + for (dst, &src) in self.input_scratch.iter_mut().zip(rgb.iter()) { + *dst = src as f32 * (1.0 / 255.0); + } + + // `TensorRef` emprunte le scratch au lieu de le copier : à 30 Hz, 442 Ko recopiés par + // frame pour rien seraient exactement le genre de coût que cette conception évite. + let shape = [1_i64, MODEL_HEIGHT as i64, MODEL_WIDTH as i64, 3]; + let input = ort::value::TensorRef::from_array_view((shape, self.input_scratch.as_slice())) + .map_err(|e| anyhow::anyhow!("construction du tenseur d'entrée : {e}"))?; + let outputs = self + .session + .run(ort::inputs!["input_1" => input]) + .map_err(|e| anyhow::anyhow!("inférence : {e}"))?; + let (_, mask) = outputs["segment_back"] + .try_extract_tensor::() + .map_err(|e| anyhow::anyhow!("extraction du masque : {e}"))?; + + if mask.len() != self.mask_scratch.len() { + bail!("masque de {} valeurs, {} attendues", mask.len(), self.mask_scratch.len()); + } + // Déjà passé par une sigmoïde dans le graphe, donc borné 0..1 — le clamp ne protège + // que d'un modèle regénéré différemment. + for (dst, &src) in self.mask_scratch.iter_mut().zip(mask.iter()) { + *dst = (src.clamp(0.0, 1.0) * 255.0) as u8; + } + Ok(&self.mask_scratch) + } + + #[cfg(not(feature = "segmentation"))] + pub fn run(&mut self, _rgb: &[u8]) -> Result<&[u8]> { + bail!("compilé sans la feature `segmentation`") + } +} + +#[cfg(test)] +mod tests { + use super::*; + + /// Le chemin par défaut du modèle vendorisé, depuis la racine du dépôt. + fn vendored_model() -> std::path::PathBuf { + Path::new(env!("CARGO_MANIFEST_DIR")) + .join("../../public/mediapipe/selfie_segmentation/selfie_segmentation_landscape.onnx") + } + + #[test] + fn the_vendored_model_is_where_the_loader_expects_it() { + // Ne charge pas le modèle (la feature peut être éteinte) : vérifie seulement que le + // fichier que `load` ira chercher existe et n'est pas un pointeur LFS ou un tronçon. + let path = vendored_model(); + let meta = std::fs::metadata(&path) + .unwrap_or_else(|e| panic!("modèle introuvable en {} : {e}", path.display())); + assert!(meta.len() > 100_000, "modèle suspicieusement petit : {} octets", meta.len()); + } + + // Sans la feature, `load` échoue en disant que la feature manque — un message utile lui + // aussi, mais pas celui-ci. + #[cfg(feature = "segmentation")] + #[test] + fn a_missing_model_fails_with_the_path_in_the_message() { + // `.err()` plutôt que `.unwrap_err()` : ce dernier exigerait `Debug` sur `Segmenter`, + // qui contient une session ONNX Runtime. + let err = match Segmenter::load(Path::new("nexiste/pas.onnx")) { + Ok(_) => panic!("un modèle inexistant ne doit pas charger"), + Err(e) => e.to_string(), + }; + assert!(err.contains("nexiste"), "message peu utile : {err}"); + } + + #[cfg(feature = "segmentation")] + #[test] + fn a_frame_of_the_wrong_size_is_refused_rather_than_read_out_of_bounds() { + let mut seg = Segmenter::load(&vendored_model()).expect("chargement du modèle"); + let err = seg.run(&[0u8; 12]).unwrap_err().to_string(); + assert!(err.contains("attendus"), "message peu utile : {err}"); + } + + #[test] + fn the_rate_limiter_admits_one_frame_per_interval() { + let mut rl = RateLimiter::new(30); + let t0 = Instant::now(); + assert!(rl.should_run(t0), "la première frame passe toujours"); + assert!(!rl.should_run(t0 + Duration::from_millis(10)), "10 ms < 33 ms"); + assert!(!rl.should_run(t0 + Duration::from_millis(33)), "juste sous l'intervalle"); + assert!(rl.should_run(t0 + Duration::from_millis(34)), "au-delà de l'intervalle"); + // Le pas repart du dernier passage accepté, pas du premier : sinon la cadence + // dériverait vers le haut après chaque frame refusée. + assert!(!rl.should_run(t0 + Duration::from_millis(40))); + assert!(rl.should_run(t0 + Duration::from_millis(68))); + } + + #[test] + fn a_60_hz_render_loop_yields_about_30_inferences_per_second() { + let mut rl = RateLimiter::new(30); + let t0 = Instant::now(); + let admitted = (0..60) + .filter(|i| rl.should_run(t0 + Duration::from_micros(16_667 * i))) + .count(); + assert_eq!(admitted, 30, "60 frames rendues doivent donner 30 inférences"); + } + + #[cfg(feature = "segmentation")] + #[test] + fn the_worker_drops_stale_frames_rather_than_queueing_them() { + use std::sync::atomic::{AtomicUsize, Ordering}; + + let seen = Arc::new(AtomicUsize::new(0)); + let counter = Arc::clone(&seen); + let worker = SegmentationWorker::spawn( + Segmenter::load(&vendored_model()).expect("chargement du modèle"), + move |mask, w, h| { + assert_eq!(mask.len(), (w * h) as usize); + counter.fetch_add(1, Ordering::SeqCst); + }, + ); + + // Cent frames déposées d'affilée : le worker en traite bien moins que cent, puisque + // chaque dépôt écrase le précédent non consommé. La borne est large — le test pin le + // fait qu'on écrase, pas un débit. + let frame = vec![90u8; (MODEL_WIDTH * MODEL_HEIGHT * 3) as usize]; + for _ in 0..100 { + worker.submit(&frame); + } + std::thread::sleep(Duration::from_millis(300)); + let done = seen.load(Ordering::SeqCst); + assert!(done > 0, "le worker n'a rien traité"); + assert!(done < 100, "{done} inférences pour 100 dépôts : la file s'accumule"); + } + + #[cfg(feature = "segmentation")] + #[test] + fn segments_a_uniform_frame_without_panicking_and_returns_the_right_size() { + let mut seg = Segmenter::load(&vendored_model()).expect("chargement du modèle"); + let frame = vec![128u8; (MODEL_WIDTH * MODEL_HEIGHT * 3) as usize]; + let mask = seg.run(&frame).expect("inférence"); + assert_eq!(mask.len(), (MODEL_WIDTH * MODEL_HEIGHT) as usize); + // Un gris uniforme ne contient pas de sujet : le masque doit être massivement du + // fond. C'est une borne large, pas une assertion de qualité — elle attrape un modèle + // qui renverrait du bruit ou du plein. + let subject = mask.iter().filter(|&&v| v > 128).count(); + assert!( + subject * 10 < mask.len(), + "{subject} pixels sujet sur {} pour une image unie", + mask.len() + ); + } +} + +/// Cadence l'inférence : 30 Hz, pas la fréquence de rendu. +/// +/// Une silhouette ne change pas de façon perceptible en 16 ms, et c'est le seul levier +/// mesuré qui divise le coût par deux sans toucher au modèle ni à sa précision. Le chemin +/// export tourne déjà à 30 Hz. +pub struct RateLimiter { + interval: Duration, + last: Option, +} + +impl RateLimiter { + pub fn new(hz: u32) -> Self { + Self { interval: Duration::from_secs_f64(1.0 / hz.max(1) as f64), last: None } + } + + /// `true` si assez de temps s'est écoulé depuis le dernier passage. Prend `now` en + /// paramètre plutôt que de lire l'horloge : c'est ce qui rend la cadence testable. + pub fn should_run(&mut self, now: Instant) -> bool { + match self.last { + Some(prev) if now.duration_since(prev) < self.interval => false, + _ => { + self.last = Some(now); + true + } + } + } +} + +/// Boîte d'échange à une place, qui écrase au lieu d'empiler. +/// +/// Si l'inférence prend du retard, la bonne réponse est de sauter des frames, pas d'en +/// accumuler : un masque en retard de trois frames est pire qu'un masque sauté, et une file +/// qui grandit finit par manger la mémoire. `submit` remplace donc silencieusement une frame +/// non consommée. +struct Slot { + frame: Mutex>>, + ready: Condvar, + stop: Mutex, +} + +/// Thread d'inférence : reçoit des frames RGB, publie des masques via un callback. +/// +/// Le callback est appelé depuis le thread du worker, pas depuis celui du rendu — c'est +/// `Compositor::set_webcam_mask` qui est prévu pour ça, le device étant multithread-protected. +pub struct SegmentationWorker { + slot: Arc, + handle: Option>, +} + +impl SegmentationWorker { + /// Démarre le worker. `on_mask` reçoit le masque et ses dimensions à chaque inférence. + pub fn spawn( + mut segmenter: Segmenter, + on_mask: impl Fn(&[u8], u32, u32) + Send + 'static, + ) -> Self { + let slot = Arc::new(Slot { + frame: Mutex::new(None), + ready: Condvar::new(), + stop: Mutex::new(false), + }); + let worker_slot = Arc::clone(&slot); + let handle = std::thread::Builder::new() + .name("openscreen-segmentation".into()) + .spawn(move || loop { + let frame = { + let mut guard = worker_slot.frame.lock().unwrap(); + while guard.is_none() { + if *worker_slot.stop.lock().unwrap() { + return; + } + let (g, timeout) = worker_slot + .ready + .wait_timeout(guard, Duration::from_millis(100)) + .unwrap(); + guard = g; + if timeout.timed_out() && guard.is_none() { + if *worker_slot.stop.lock().unwrap() { + return; + } + } + } + guard.take().expect("non vide, la boucle vient de le vérifier") + }; + match segmenter.run(&frame) { + Ok(mask) => on_mask(mask, MODEL_WIDTH, MODEL_HEIGHT), + // Une frame ratée est sautée, pas fatale : le masque précédent reste + // affiché, ce qui vaut mieux qu'un effet qui clignote. + Err(e) => eprintln!("[segmentation] frame ignorée : {e}"), + } + }) + .expect("le thread de segmentation doit démarrer"); + Self { slot, handle: Some(handle) } + } + + /// Dépose une frame à segmenter. Écrase celle qui attendait, s'il y en avait une. + pub fn submit(&self, rgb: &[u8]) { + let mut guard = self.slot.frame.lock().unwrap(); + *guard = Some(rgb.to_vec()); + self.slot.ready.notify_one(); + } +} + +impl Drop for SegmentationWorker { + fn drop(&mut self) { + *self.slot.stop.lock().unwrap() = true; + self.slot.ready.notify_all(); + if let Some(h) = self.handle.take() { + let _ = h.join(); + } + } +} diff --git a/crates/compositor/src/shaders.hlsl b/crates/compositor/src/shaders.hlsl index b9d438d7d..a507abb64 100644 --- a/crates/compositor/src/shaders.hlsl +++ b/crates/compositor/src/shaders.hlsl @@ -39,6 +39,10 @@ VSOut vs_main(uint vid : SV_VertexID) Texture2D texY : register(t0); Texture2D texUV : register(t1); Texture2D texImg : register(t2); // wallpaper image RGBA (fond, mode 6) +// Masque de segmentation du sujet, 0 = fond, 1 = sujet. Produit par `segmentation.rs` a la +// resolution du modele (256x144) ; l'upscale vers la resolution webcam est fait par le sampler +// lineaire, ce qui est exactement le filtrage qu'on veut sur un masque. +Texture2D texMask : register(t3); SamplerState samp : register(s0); // BT.709 limited -> RGB (§7 E1), matrice en dur, range mesuré en S1. @@ -154,6 +158,26 @@ float3 quad_inverse_bilinear(float2 P, float2 c00, float2 c10, float2 c11, float return (r0.z > 0.5) ? r0 : r1; } +// Fond floute pour le mode "blur" de la webcam. Rayon en UV pour rester isotrope quel que soit +// le rect source. 25 taps ponderes par la distance : assez doux pour un fond, assez court pour +// tenir dans le budget d'une frame webcam (qui n'occupe qu'une fraction de la sortie). +float3 blur_webcam_bg(float2 uv, float intensity, float2 qpx) +{ + float2 step = (max(intensity, 0.0) * 12.0 + 2.0) / max(qpx, 1.0); + float3 sum = 0.0; + float total = 0.0; + [unroll] for (int dy = -2; dy <= 2; dy++) + { + [unroll] for (int dx = -2; dx <= 2; dx++) + { + float w = 1.0 / (1.0 + length(float2(dx, dy))); + sum += sample_yuv(saturate(uv + float2(dx, dy) * step)) * w; + total += w; + } + } + return sum / max(total, 1e-4); +} + float4 ps_main(VSOut i) : SV_Target { // mode 13 : SPRITE DE CURSEUR posé sur l'écran incliné. Même warp que le mode 8, mais @@ -410,6 +434,8 @@ float4 ps_main(VSOut i) : SV_Target } float3 rgb; + // 1 sauf en mode detourage, ou il porte le masque du sujet (cf. la branche fx.z ci-dessous). + float alpha_mask = 1.0; if (mode < 0.5) { // flou de mouvement par vélocité (§8) : pour CE pixel sortie, uv à la frame @@ -435,13 +461,41 @@ float4 ps_main(VSOut i) : SV_Target } rgb = acc / (float) taps; } + + // Effet d'arriere-plan webcam. fx.z : 1 = detourage, 2 = flou, 3 = fond personnalise. + // `color` porte la couleur de fond du mode 3, fx.w l'intensite du flou du mode 2. + // fx.xy porte l'etendue VALIDE de la texture webcam (wcw/wtw, wch/wth) : le masque a + // ete produit sur la frame ENTIERE, pas sur le sous-rect dessine, pour que le modele + // ne se fasse pas amputer le sujet par un crop utilisateur. Il faut donc ramener uv, + // qui vit dans l'espace source, dans cet espace-la. + // Le masque est absent (texture 1x1 noire) tant que la segmentation n'a pas produit sa + // 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) + { + float2 mask_uv = uv_now / max(fx.xy, 1e-6); + float person = saturate(texMask.Sample(samp, mask_uv)); + if (effect > 2.5) + { + rgb = lerp(color.rgb, rgb, person); + } + else if (effect > 1.5) + { + rgb = lerp(blur_webcam_bg(uv_now, fx.w, quad_px), rgb, person); + } + else + { + alpha_mask = person; + } + } } else { rgb = color.rgb; } - float alpha = color.a; + float alpha = color.a * alpha_mask; 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..62f657680 100644 --- a/crates/compositor/src/shaders.metal +++ b/crates/compositor/src/shaders.metal @@ -219,11 +219,36 @@ inline float3 quad_inverse_bilinear(float2 P, float2 c00, float2 c10, float2 c11 // Identique à `ps_main` côté HLSL ligne pour ligne (à la syntaxe MSL près). // ================================================================================= +// Fond floute du mode "blur" webcam. Miroir de `blur_webcam_bg` cote HLSL : memes 25 taps, +// memes poids, meme rayon — les deux back-ends doivent rendre le meme pixel. +inline float3 blur_webcam_bg(float2 uv, float intensity, float2 qpx, + texture2d texY, + texture2d texUV) +{ + float2 step = (max(intensity, 0.0) * 12.0 + 2.0) / max(qpx, float2(1.0)); + float3 sum = float3(0.0); + float total = 0.0; + for (int dy = -2; dy <= 2; dy++) + { + for (int dx = -2; dx <= 2; dx++) + { + float w = 1.0 / (1.0 + length(float2(dx, dy))); + sum += sample_yuv(saturate(uv + float2(dx, dy) * step), texY, texUV) * w; + total += w; + } + } + return sum / max(total, 1e-4); +} + fragment float4 ps_main(VSOut i [[stage_in]], constant Layer &layer [[buffer(0)]], texture2d texY [[texture(0)]], texture2d texUV [[texture(1)]], - texture2d texImg [[texture(2)]]) + texture2d texImg [[texture(2)]], + // Masque de segmentation du sujet webcam. Non lie tant qu'aucun + // masque n'existe : Metal rend alors 0, ce qui est sans effet + // puisque la branche n'est prise que si layer.fx.z > 0.5. + texture2d texMask [[texture(3)]]) { // mode 13 : SPRITE DE CURSEUR posé sur l'écran incliné. Cf. commentaires HLSL. if (layer.mode > 12.5) @@ -467,6 +492,8 @@ fragment float4 ps_main(VSOut i [[stage_in]], } float3 rgb; + // 1 sauf en detourage, ou il porte le masque du sujet. Cf. la branche fx.z plus bas. + float alpha_mask = 1.0; if (layer.mode < 0.5) { // flou de mouvement par vélocité (§8) @@ -490,13 +517,35 @@ fragment float4 ps_main(VSOut i [[stage_in]], } rgb = acc / float(taps); } + + // 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. + float effect = layer.fx.z; + 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); + if (effect > 2.5) + { + rgb = mix(layer.color.rgb, rgb, person); + } + else if (effect > 1.5) + { + rgb = mix(blur_webcam_bg(uv_now, layer.fx.w, layer.quad_px, texY, texUV), rgb, person); + } + else + { + alpha_mask = person; + } + } } else { rgb = layer.color.rgb; } - float alpha = layer.color.a; + float alpha = layer.color.a * alpha_mask; if (layer.radius_px > 0.0) { float2 halfsz = layer.quad_px * 0.5; diff --git a/crates/compositor/src/vk_shaders/layer.wgsl b/crates/compositor/src/vk_shaders/layer.wgsl index 6fb2a73ed..1f764bc59 100644 --- a/crates/compositor/src/vk_shaders/layer.wgsl +++ b/crates/compositor/src/vk_shaders/layer.wgsl @@ -34,6 +34,9 @@ struct Layer { @group(0) @binding(1) var texY: texture_2d; // R8Unorm, sample .r @group(0) @binding(2) var texUV: texture_2d; // Rg8Unorm, sample .rg @group(0) @binding(3) var samp: sampler; +// Masque de segmentation du sujet webcam, R8. Une vue 1x1 est liee quand aucun masque +// n'existe : la branche n'est de toute facon prise que si layer.fx.z > 0.5. +@group(0) @binding(4) var texMask: texture_2d; struct VsOut { @builtin(position) pos: vec4, @@ -197,10 +200,29 @@ fn quad_inverse_bilinear(P: vec2, c00: vec2, c10: vec2, c11: vec2 return r1; } +// Fond floute du mode "blur" webcam. Miroir de `blur_webcam_bg` cote HLSL et MSL : memes +// 25 taps, memes poids, meme rayon — les trois back-ends doivent rendre le meme pixel. +fn blur_webcam_bg(uv: vec2, intensity: f32, qpx: vec2) -> vec3 { + let step = (max(intensity, 0.0) * 12.0 + 2.0) / max(qpx, vec2(1.0)); + var sum = vec3(0.0); + var total = 0.0; + for (var dy: i32 = -2; dy <= 2; dy = dy + 1) { + for (var dx: i32 = -2; dx <= 2; dx = dx + 1) { + let d = vec2(f32(dx), f32(dy)); + let w = 1.0 / (1.0 + length(d)); + sum = sum + sample_yuv(clamp(uv + d * step, vec2(0.0), vec2(1.0))) * w; + total = total + w; + } + } + return sum / max(total, 1e-4); +} + @fragment fn fs_main(i: VsOut) -> @location(0) vec4 { var rgb: vec3; var alpha: f32; + // 1 sauf en detourage, ou il porte le masque du sujet. Cf. la branche fx.z plus bas. + var alpha_mask = 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 +256,22 @@ fn fs_main(i: VsOut) -> @location(0) vec4 { rgb = acc / f32(taps); } } + + // 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. + let effect = layer.fx.z; + 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 { + rgb = mix(layer.color.rgb, rgb, person); + } else if effect > 1.5 { + rgb = mix(blur_webcam_bg(i.uv, layer.fx.w, layer.quad_px), rgb, person); + } else { + alpha_mask = person; + } + } } else if layer.mode < 1.5 { // Mode 1 — couleur pleine. rgb = layer.color.rgb; @@ -427,7 +465,7 @@ fn fs_main(i: VsOut) -> @location(0) vec4 { return vec4(layer.color.rgb * a, a); } - alpha = layer.color.a; + alpha = layer.color.a * alpha_mask; if layer.radius_px > 0.0 { // Feather ~1.5 px sur le bord du quad — parité exacte avec le HLSL diff --git a/electron/native-bridge/services/compositorViewService.test.ts b/electron/native-bridge/services/compositorViewService.test.ts index 1569816af..5dbf6cff7 100644 --- a/electron/native-bridge/services/compositorViewService.test.ts +++ b/electron/native-bridge/services/compositorViewService.test.ts @@ -259,6 +259,9 @@ describe("resolveSceneAssetPaths", () => { resources = fs.mkdtempSync(path.join(os.tmpdir(), "openscreen-scene-assets-")); fs.mkdirSync(path.join(resources, "wallpapers"), { recursive: true }); fs.writeFileSync(path.join(resources, "wallpapers", "wallpaper1.jpg"), "jpg"); + const modelDir = path.join(resources, "mediapipe", "selfie_segmentation"); + fs.mkdirSync(modelDir, { recursive: true }); + fs.writeFileSync(path.join(modelDir, "selfie_segmentation_landscape.onnx"), "onnx"); const assetPaths = [ ...Object.values(themed?.assets ?? {}).map((a) => a.assetPath), ...Object.values(DEFAULT_CURSOR_SPRITES).map((s) => s.assetPath), @@ -299,6 +302,34 @@ describe("resolveSceneAssetPaths", () => { * service declares for the sprite map it builds. */ type ResolvedSprite = { path: string; hotspotX: number; hotspotY: number }; + // The renderer asks for an effect and knows nothing about the disk; this process answers + // where the model is. Same division as the wallpaper and the cursor sprites above. + it("fills in the segmentation model path when the scene asks for an effect", () => { + const out = resolved({ webcamEffect: { mode: "blur", blurIntensity: 0.5 } }); + expect(out.webcamEffect.modelPath).toBe( + path.join( + resources, + "mediapipe", + "selfie_segmentation", + "selfie_segmentation_landscape.onnx", + ), + ); + }); + + it("leaves the model path alone when no effect is requested", () => { + expect(resolved({ webcamEffect: { mode: "none" } }).webcamEffect.modelPath).toBeUndefined(); + expect(resolved({ background: { kind: "color", color: "#000" } }).webcamEffect).toBeUndefined(); + }); + + // A model that does not resolve must turn the effect off in the compositor, not fail the + // scene — the same contract a missing cursor sprite has. + it("leaves the model path unset rather than inventing one when the file is absent", () => { + fs.rmSync(path.join(resources, "mediapipe"), { recursive: true, force: true }); + const out = resolved({ webcamEffect: { mode: "transparent" } }); + expect(out.webcamEffect.modelPath).toBeUndefined(); + expect(out.webcamEffect.mode).toBe("transparent"); + }); + it("resolves a bundled wallpaper to the extraResources copy, not the unreadable asar path", () => { const out = resolved({ background: { kind: "image", path: "/wallpapers/wallpaper1.jpg" } }); diff --git a/electron/native-bridge/services/compositorViewService.ts b/electron/native-bridge/services/compositorViewService.ts index 9efc302ec..4fc7e9198 100644 --- a/electron/native-bridge/services/compositorViewService.ts +++ b/electron/native-bridge/services/compositorViewService.ts @@ -114,6 +114,11 @@ function resolveCursorSpritePaths( return resolved; } +/** Where the segmentation model sits under `public/`, and therefore under `dist/` once Vite + * has copied it. Resolved here rather than in the renderer: the compositor runs in this + * process, and the renderer has no business knowing the on-disk layout. */ +const SEGMENTATION_MODEL_ASSET = "mediapipe/selfie_segmentation/selfie_segmentation_landscape.onnx"; + export function resolveSceneAssetPaths(sceneJson: string): string { try { const scene = JSON.parse(sceneJson) as { @@ -122,6 +127,7 @@ export function resolveSceneAssetPaths(sceneJson: string): string { theme?: string; cursorSprites?: Record; }; + webcamEffect?: { mode?: string; modelPath?: string }; }; let changed = false; const bg = scene.background; @@ -137,6 +143,17 @@ export function resolveSceneAssetPaths(sceneJson: string): string { scene.cursor.cursorSprites = resolveCursorSpritePaths(scene.cursor.theme); changed = true; } + // The scene asks for an effect; this process says where the model is. A model that + // does not resolve leaves `modelPath` unset, which turns the effect off in the + // compositor rather than failing the scene — same contract as a missing cursor sprite. + const effect = scene.webcamEffect; + if (effect && typeof effect.mode === "string" && effect.mode !== "none") { + const resolved = resolveSceneAssetPath(SEGMENTATION_MODEL_ASSET); + if (resolved) { + effect.modelPath = resolved; + changed = true; + } + } return changed ? JSON.stringify(scene) : sceneJson; } catch { return sceneJson; @@ -335,6 +352,37 @@ function ensureFfmpegSharedDllsOnPath(appRoot: string): void { process.env.PATH = `${dir}${path.delimiter}${current}`; } +/** The ONNX Runtime shared library's file name for this platform. */ +function ortLibName(): string { + if (process.platform === "win32") return "onnxruntime.dll"; + if (process.platform === "darwin") return "libonnxruntime.dylib"; + return "libonnxruntime.so"; +} + +/** + * Points `ORT_DYLIB_PATH` at the staged ONNX Runtime, which the addon loads dynamically for + * the webcam segmentation mask. + * + * It lives in the same arch-tagged `electron/native/bin//` directory the addon itself + * ships from, next to the ffmpeg DLLs — the convention `whisper-stt` already established for + * native sidecars. The crate links `ort` with `load-dynamic`, so the library is resolved at + * runtime rather than at build time: absent, `Segmenter::load` fails, the compositor logs one + * line and draws the webcam unsegmented. That is why this is best-effort and never throws. + */ +function ensureOnnxRuntimeOnPath(appRoot: string): void { + if (process.env.ORT_DYLIB_PATH) { + return; + } + const lib = ortLibName(); + for (const dir of ffmpegSharedBinCandidates(appRoot)) { + const candidate = path.join(dir, lib); + if (fs.existsSync(candidate)) { + process.env.ORT_DYLIB_PATH = candidate; + return; + } + } +} + function tryLoadAddon(candidates: string[]): CompositorViewAddon | null { for (const candidate of candidates) { try { @@ -382,6 +430,7 @@ export class CompositorViewService { const isPackaged = this.options.isPackaged ?? defaultIsPackaged(); ensureFfmpegSharedDllsOnPath(appRoot); + ensureOnnxRuntimeOnPath(appRoot); const candidates = buildCandidatePaths(appRoot, isPackaged, envOverride); const loaded = tryLoadAddon(candidates); if (!loaded) { diff --git a/nix/package.nix b/nix/package.nix index 9e5dd1e23..02e9eb10a 100644 --- a/nix/package.nix +++ b/nix/package.nix @@ -51,7 +51,7 @@ buildNpmPackage { ); }; - npmDepsHash = "sha256-Vr6Sw/WKmX22eT4a22+Xr3/miMzZr2uAwiYx12toU/E="; + npmDepsHash = "sha256-LkKX1edTPHZq5nQRrbLAn11oVw36kb0smNQMmVRMEPA="; env.ELECTRON_SKIP_BINARY_DOWNLOAD = "1"; diff --git a/package-lock.json b/package-lock.json index e0ea6e09c..e1322c336 100644 --- a/package-lock.json +++ b/package-lock.json @@ -13,6 +13,7 @@ "@langchain/core": "^1.1.44", "@langchain/mistralai": "^1.0.8", "@langchain/openai": "^1.4.2", + "@mediapipe/selfie_segmentation": "^0.1.1675465747", "@radix-ui/react-accordion": "^1.2.12", "@radix-ui/react-dialog": "^1.1.15", "@radix-ui/react-dropdown-menu": "^2.1.16", @@ -2141,6 +2142,12 @@ "node": ">= 10.0.0" } }, + "node_modules/@mediapipe/selfie_segmentation": { + "version": "0.1.1675465747", + "resolved": "https://registry.npmjs.org/@mediapipe/selfie_segmentation/-/selfie_segmentation-0.1.1675465747.tgz", + "integrity": "sha512-IxYxNhwE5VwOm52L1yoFWYLP7q9Pd+NJjzOC5tlepfvEGaY3o9hslhUrx9BgseqdfZtKSDtd/4NfCSMjNzQalA==", + "license": "Apache-2.0" + }, "node_modules/@mistralai/mistralai": { "version": "2.2.1", "resolved": "https://registry.npmjs.org/@mistralai/mistralai/-/mistralai-2.2.1.tgz", diff --git a/public/mediapipe/selfie_segmentation/README.md b/public/mediapipe/selfie_segmentation/README.md new file mode 100644 index 000000000..f39999be2 --- /dev/null +++ b/public/mediapipe/selfie_segmentation/README.md @@ -0,0 +1,46 @@ +# MediaPipe Selfie Segmentation — model weights + +Only the model weights live here. The MediaPipe **JavaScript** solution (the `.js` glue and the +two ~5.6 MB `.wasm` builds) was removed when segmentation moved into the native compositor: the +renderer no longer runs inference at all, so nothing loaded them. + +Upstream: , Apache-2.0. + +The `.tflite` files are kept because the `.onnx` below is **derived from them** — they are the +provenance, not dead weight. + +## `selfie_segmentation_landscape.onnx` — derived, not vendored + +The `.onnx` beside the `.tflite` files is **generated from them**, by +[`scripts/convert-selfie-segmentation-to-onnx.py`](../../../scripts/convert-selfie-segmentation-to-onnx.py). +No weights were downloaded; it is a derived work of the MediaPipe model already vendored here +(Apache-2.0). + +It exists because the realtime path runs inference through ONNX Runtime rather than the +MediaPipe JS solution. Regenerate with: + +``` +pip install "numpy<2" "tensorflow==2.13.1" "tf2onnx==1.16.1" "onnx==1.16.2" "protobuf<4" +python scripts/convert-selfie-segmentation-to-onnx.py landscape +``` + +**The conversion is not mechanical.** `tf2onnx` exits 0 while leaving 12 operators that ONNX +Runtime cannot load — 11 `HardSwish` emitted into an opset-13 graph, and MediaPipe's custom +`TFL_Convolution2DTransposeBias`. The script repairs both; the reasoning is in its docstring. +If you regenerate, re-check the mask on a real frame rather than trusting the exit code. + +| | | +|---|---| +| input | `input_1` `[1, 144, 256, 3]` float32, **NHWC**, RGB scaled to 0..1 | +| output | `segment_back` `[1, 144, 256, 1]` float32, already sigmoid-activated | +| feeds | the compositor's `t3` mask slot (256x144 R8) | + +The graph is fully convolutional and resolution-agnostic, so the input dimensions can be +rewritten in place — but **both dimensions must be divisible by 16**, or the skip-connection +`Add`s fail on mismatched extents. Measured quality below 192x112 degrades visibly on a +full-screen camera, and at 64x48 the model stops producing a mask at all. + +> **Packaging:** this file currently sits under `public/`, which is bundled into `app.asar`. +> Anything that resolves a filesystem path for native code cannot read it from there — see +> `scripts/before-pack.cjs` and the compositor's asset handling. Whoever wires the loader +> should decide whether it moves to `extraResources` or is read through the renderer. diff --git a/public/mediapipe/selfie_segmentation/selfie_segmentation.tflite b/public/mediapipe/selfie_segmentation/selfie_segmentation.tflite new file mode 100644 index 000000000..374c0720d Binary files /dev/null and b/public/mediapipe/selfie_segmentation/selfie_segmentation.tflite differ diff --git a/public/mediapipe/selfie_segmentation/selfie_segmentation_landscape.onnx b/public/mediapipe/selfie_segmentation/selfie_segmentation_landscape.onnx new file mode 100644 index 000000000..1fdedb18c Binary files /dev/null and b/public/mediapipe/selfie_segmentation/selfie_segmentation_landscape.onnx differ diff --git a/public/mediapipe/selfie_segmentation/selfie_segmentation_landscape.tflite b/public/mediapipe/selfie_segmentation/selfie_segmentation_landscape.tflite new file mode 100644 index 000000000..4ea3f8a10 Binary files /dev/null and b/public/mediapipe/selfie_segmentation/selfie_segmentation_landscape.tflite differ diff --git a/scripts/convert-selfie-segmentation-to-onnx.py b/scripts/convert-selfie-segmentation-to-onnx.py new file mode 100644 index 000000000..3cb617af2 --- /dev/null +++ b/scripts/convert-selfie-segmentation-to-onnx.py @@ -0,0 +1,137 @@ +#!/usr/bin/env python3 +"""Convert the vendored MediaPipe SelfieSegmentation .tflite to ONNX. + +This is the script that produced `selfie_segmentation_landscape.onnx`. It is checked in so +the artifact is reproducible and auditable rather than an opaque binary: the conversion is +NOT a mechanical one-liner, it needs two hand repairs (below), and anyone reviewing the model +needs to be able to see and re-run them. + +It is not part of any build. Nothing in the app runs Python; this is provenance tooling, run +by hand on the rare occasion the model is regenerated. + + pip install "numpy<2" "tensorflow==2.13.1" "tf2onnx==1.16.1" "onnx==1.16.2" "protobuf<4" + python scripts/convert-selfie-segmentation-to-onnx.py landscape + +Why the repairs are needed +-------------------------- +`tf2onnx` reports success on this model but leaves 12 operators that ONNX Runtime cannot load: + +1. **11 x HardSwish emitted into an opset-13 graph.** `HardSwish` is opset 14+, so the graph + is invalid as declared. Fixed by raising the opset to 16. + +2. **1 x TFL_Convolution2DTransposeBias** — a MediaPipe *custom* operator with no ONNX + equivalent, so tf2onnx passes it through under the default domain where it does not exist. + It is the last convolution before the output sigmoid: a 2x2 stride-2 transposed + convolution, 16 channels in, 1 out, plus a bias. Rewritten here as a native + `ConvTranspose` + bias, with the weights transposed from TFLite's + `[C_out, kH, kW, C_in]` to ONNX's `[C_in, C_out/group, kH, kW]`. + + tf2onnx also inserts an NHWC `Transpose` to feed that custom node. Since the replacement + consumes NCHW directly, the transpose is dropped and the layout flip moves after the + sigmoid (sigmoid is elementwise, so the order is equivalent). + +The result passes `onnx.checker.check_model(..., full_check=True)` and, on a real webcam +frame, produces a mask identical between the CPU and DirectML execution providers. + +The graph is fully convolutional and resolution-agnostic (every `Reshape` target is +channel-only, every `Resize` uses scales rather than sizes), so the input dimensions can be +rewritten after the fact -- but **both dimensions must be divisible by 16** or the +skip-connection `Add`s fail on mismatched extents. + +Source model: `public/mediapipe/selfie_segmentation/*.tflite`, vendored from MediaPipe +(Apache-2.0). This conversion is a derived work of that file; no weights are downloaded. +""" +import argparse +import pathlib +import subprocess +import sys + +import numpy as np +import onnx +from onnx import helper, numpy_helper, shape_inference + +HERE = pathlib.Path(__file__).resolve().parent +MODELS = HERE.parent / "public" / "mediapipe" / "selfie_segmentation" + +VARIANTS = { + "landscape": ("selfie_segmentation_landscape.tflite", "selfie_segmentation_landscape.onnx"), + "square": ("selfie_segmentation.tflite", "selfie_segmentation.onnx"), +} + + +def repair(src: pathlib.Path, dst: pathlib.Path) -> None: + model = onnx.load(str(src)) + graph = model.graph + + for opset in model.opset_import: + if opset.domain in ("", "ai.onnx"): + opset.version = 16 # HardSwish is opset 14+ + + init = {i.name: numpy_helper.to_array(i) for i in graph.initializer} + custom = next(n for n in graph.node if n.op_type == "TFL_Convolution2DTransposeBias") + feed_transpose = next(n for n in graph.node if n.output[0] == custom.input[0]) + source = feed_transpose.input[0] # NCHW feature map + sigmoid = next(n for n in graph.node if custom.output[0] in n.input) + assert sigmoid.op_type == "Sigmoid", sigmoid.op_type + + weights = init[custom.input[1]] # [C_out, kH, kW, C_in] + assert weights.shape == (1, 2, 2, 16), weights.shape + graph.initializer.append( + numpy_helper.from_array(np.transpose(weights, (3, 0, 1, 2)).copy(), "convT_W") + ) + + out_name = graph.output[0].name + nodes = [n for n in graph.node if n not in (feed_transpose, custom, sigmoid)] + nodes += [ + helper.make_node( + "ConvTranspose", [source, "convT_W", custom.input[2]], ["convT_out"], + name="conv2d_transpose_native", + kernel_shape=[2, 2], strides=[2, 2], pads=[0, 0, 0, 0], + ), + helper.make_node("Sigmoid", ["convT_out"], ["mask_nchw"], name="segment_sigmoid"), + helper.make_node("Transpose", ["mask_nchw"], [out_name], name="mask_to_nhwc", + perm=[0, 2, 3, 1]), + ] + del graph.node[:] + graph.node.extend(nodes) + del graph.value_info[:] + + model = shape_inference.infer_shapes(model, strict_mode=True) + onnx.checker.check_model(model, full_check=True) + onnx.save(model, str(dst)) + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("variant", choices=sorted(VARIANTS), nargs="?", default="landscape") + args = parser.parse_args() + + tflite_name, onnx_name = VARIANTS[args.variant] + tflite = MODELS / tflite_name + if not tflite.exists(): + print(f"missing source model: {tflite}", file=sys.stderr) + return 1 + + raw = MODELS / f".{onnx_name}.raw" + subprocess.run( + [sys.executable, "-m", "tf2onnx.convert", "--tflite", str(tflite), + "--output", str(raw), "--opset", "13"], + check=True, + ) + # tf2onnx exits 0 while leaving 12 unloadable operators behind -- see the module docstring. + repair(raw, MODELS / onnx_name) + raw.unlink(missing_ok=True) + + model = onnx.load(str(MODELS / onnx_name)) + gi, go = model.graph.input[0], model.graph.output[0] + shape = lambda v: [d.dim_value for d in v.type.tensor_type.shape.dim] + leftover = [n.op_type for n in model.graph.node if n.domain not in ("", "ai.onnx")] + print(f"wrote {MODELS / onnx_name}") + print(f" input {gi.name} {shape(gi)}") + print(f" output {go.name} {shape(go)}") + print(f" nodes {len(model.graph.node)} non-standard ops: {leftover or 'none'}") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/src/cli/CliExportRunner.tsx b/src/cli/CliExportRunner.tsx index 31f12233e..83cf31aaa 100644 --- a/src/cli/CliExportRunner.tsx +++ b/src/cli/CliExportRunner.tsx @@ -263,11 +263,16 @@ async function runExport(request: CliExportRequest): Promise { aspectRatioValue, }); - const clips = buildNativeClipList(axcutDocument); - if (clips.length === 0) { + const builtClips = buildNativeClipList(axcutDocument); + if (builtClips.length === 0) { throw new Error("The project's timeline has no visible clips to export"); } - const sceneJson = JSON.stringify(buildSceneDescription(axcutDocument)); + const sceneDesc = buildSceneDescription(axcutDocument); + + // The webcam background effect is applied by the compositor from the scene, so the clip + // list needs no pre-rendering pass. + const clips = builtClips; + const sceneJson = JSON.stringify(sceneDesc); // Progress: native pushes raw encoded-frame counts; totals and pacing are // computed here, mirroring the ExportDialog. diff --git a/src/components/ai-edition/ExportDialog.tsx b/src/components/ai-edition/ExportDialog.tsx index c661933f1..fbc4d9362 100644 --- a/src/components/ai-edition/ExportDialog.tsx +++ b/src/components/ai-edition/ExportDialog.tsx @@ -309,14 +309,20 @@ export function ExportDialog({ open, onClose, document }: ExportDialogProps) { }); }); try { - const sceneJson = JSON.stringify(buildSceneDescription(document)); + const sceneDesc = buildSceneDescription(document); + + // The webcam background effect is applied by the compositor from the scene, + // so the clip list needs no pre-rendering pass. + const exportClips = clips; + + const sceneJson = JSON.stringify(sceneDesc); const outDims = tierOutputDims(quality); - if (clips.length === 0) { + if (exportClips.length === 0) { throw new Error(t("exportDialog.nothingToExport")); } const stats = format === "gif" - ? await exportGifNative(clips, pickedPath, sceneJson, { + ? await exportGifNative(exportClips, pickedPath, sceneJson, { // GIF is 256-colour and grows fast; cap the long edge at the // chosen preset rather than exporting at source size. ...gifOutputDims(gifSize, outDims), @@ -324,7 +330,7 @@ export function ExportDialog({ open, onClose, document }: ExportDialogProps) { // 0 = infinite, the historical GIF default; 1 = play once. loopCount: gifLoop ? 0 : 1, }) - : await exportMultiNative(clips, pickedPath, sceneJson, { + : await exportMultiNative(exportClips, pickedPath, sceneJson, { width: outDims?.width, height: outDims?.height, fps, diff --git a/src/components/ai-edition/NativeCompositorOverlay.tsx b/src/components/ai-edition/NativeCompositorOverlay.tsx index 710e90c74..e4af64102 100644 --- a/src/components/ai-edition/NativeCompositorOverlay.tsx +++ b/src/components/ai-edition/NativeCompositorOverlay.tsx @@ -81,6 +81,17 @@ export function NativeCompositorOverlay() { // `null` = document pas encore chargé (on attend) ; `{}` = chargé sans asset (→ fixture) ; // `{screenPath,…}` = vraies sources de l'asset primaire. + const settings = useMemo(() => getEditorSettings(document), [document]); + + // The real camera path, independent of whether NATIVE is the one drawing it: the scene + // still needs it to look up the probed webcam size, which shapes the PiP box. + const cameraPath = useMemo(() => { + if (!document) return undefined; + const primary = + document.assets.find((a) => a.id === document.project.primaryAssetId) ?? document.assets[0]; + return primary ? assetCameraSource(primary).path || undefined : undefined; + }, [document]); + const sources = useMemo(() => { if (!document) { return null; @@ -132,8 +143,7 @@ export function NativeCompositorOverlay() { return; } try { - const activeWebcamPath = sources && "webcamPath" in sources ? sources.webcamPath : undefined; - const webcamSourceSize = activeWebcamPath ? getWebcamNativeSize(activeWebcamPath) : null; + const webcamSourceSize = cameraPath ? getWebcamNativeSize(cameraPath) : null; const scene = buildSceneDescription(document, webcamSourceSize); setNativeScene(JSON.stringify(scene)); } catch (error) { @@ -164,7 +174,6 @@ export function NativeCompositorOverlay() { // n'a pas d'importance. Et ca ne peut pas lutter contre un drag de slider : // `setLive` passe par `setDocument`, donc `document` a deja la NOUVELLE // valeur a chaque tick -- la meme que celle que le handler vient de pousser. - const settings = useMemo(() => getEditorSettings(document), [document]); useEffect(() => { const push = () => pushAllNativeParams(settings); push(); diff --git a/src/components/ai-edition/RightPanes.tsx b/src/components/ai-edition/RightPanes.tsx index d12fe248a..4efb3ef20 100644 --- a/src/components/ai-edition/RightPanes.tsx +++ b/src/components/ai-edition/RightPanes.tsx @@ -214,60 +214,22 @@ export function isSupportedBackgroundImage(type: string, fileName: string): bool return IMAGE_EXTENSIONS.some((extension) => name.endsWith(extension)); } -// Wallpaper picker — image / solid color / gradient tabs. -// -// Wallpapers round-trip through the legacyEditor envelope exactly as they did -// in the v2 editor: gradient strings stay as-is, colors as `#hex`, and image -// paths are restricted to `/wallpapers/...` or the user's own data: URLs from -// the upload custom flow. -function BackgroundSection() { +/** + * The "upload custom wallpaper" concern: a hidden `` plus the reader + * that turns the pick into a `data:` URL. + * + * A hook rather than part of `WallpaperPicker` because WHERE the input may be mounted is + * the caller's problem. `BackgroundSection` renders the picker inside a Popover, and + * opening the OS file dialog takes focus, which closes the Popover — an input mounted + * inside it would unmount mid-pick and drop the file. That caller mounts `input` outside + * the Popover; inline callers mount it next to the picker. + */ +function useWallpaperFileInput(onPicked: (dataUrl: string) => void): { + pick: () => void; + input: ReactNode; +} { const ts = useScopedT("settings"); - const { settings, set, setLive, commit, hasDocument } = useEditorSettings(); - const [pickerOpen, setPickerOpen] = useState(false); - // Seeded from what the project is actually using, so the picker opens on the tab the - // user is already in rather than always on Image. - const [tab, setTab] = useState<"image" | "color" | "gradient">( - () => classifyWallpaper(settings.wallpaper).kind, - ); - const fileInputRef = useRef(null); - const customUrls = useMemoCustomWallpapers(settings.wallpaper); - - // The custom gradient editor emits continuously while the user drags a - // color point / angle knob / brightness slider, so mirror the SliderCell - // model: preview live with setLive, then persist once the changes settle. - const gradientCommitTimer = useRef(null); - const handleGradientChange = useCallback( - (state: GradientEditorState) => { - setLive({ wallpaper: buildGradientFromEditor(state) }); - if (gradientCommitTimer.current !== null) { - window.clearTimeout(gradientCommitTimer.current); - } - gradientCommitTimer.current = window.setTimeout(() => { - gradientCommitTimer.current = null; - void commit(); - }, 400); - }, - [setLive, commit], - ); - useEffect( - () => () => { - if (gradientCommitTimer.current !== null) { - window.clearTimeout(gradientCommitTimer.current); - } - }, - [], - ); - - const isSelected = (value: string) => settings.wallpaper === value; - - const handleTabChange = (next: "image" | "color" | "gradient") => { - setTab(next); - }; - - const handlePickFile = () => { - if (!hasDocument) return; - fileInputRef.current?.click(); - }; + const ref = useRef(null); const handleFileSelected = (e: ChangeEvent) => { const file = e.target.files?.[0]; @@ -284,12 +246,40 @@ function BackgroundSection() { toast.error(ts("background.imageReadFailed")); return; } - void set({ wallpaper: dataUrl }); + onPicked(dataUrl); }; reader.onerror = () => toast.error(ts("background.imageReadFailed")); reader.readAsDataURL(file); }; + return { + pick: () => ref.current?.click(), + input: ( + + ), + }; +} + +// Wallpaper picker — image / solid color / gradient tabs. +// +// Wallpapers round-trip through the legacyEditor envelope exactly as they did +// in the v2 editor: gradient strings stay as-is, colors as `#hex`, and image +// paths are restricted to `/wallpapers/...` or the user's own data: URLs from +// the upload custom flow. +function BackgroundSection() { + const ts = useScopedT("settings"); + const { settings, set, setLive, commit, hasDocument } = useEditorSettings(); + const [pickerOpen, setPickerOpen] = useState(false); + const { pick: handlePickFile, input: fileInput } = useWallpaperFileInput((dataUrl) => + set({ wallpaper: dataUrl }), + ); + return ( <>
{ts("background.title")}
@@ -328,117 +318,21 @@ function BackgroundSection() { className="w-auto border-0 bg-transparent p-0 shadow-none" >
- {/* role="tab" + aria-selected are what make the tablist above mean - anything: without them a screen reader announces three plain - buttons and never says which one is current. */} -
- - - -
- {tab === "image" ? ( - <> - -
- {customUrls.map((url) => ( -
- - ) : tab === "color" ? ( - void set({ wallpaper: color })} - /> - ) : ( - <> -
- {GRAD_PRESETS.map((bg, i) => ( -
- {hasDocument ? : null} - - )} + void set({ wallpaper: url })} + onLiveChange={(url) => setLive({ wallpaper: url })} + onCommit={commit} + onPickFile={handlePickFile} + />
{/* Stays mounted OUTSIDE the popover: opening the OS file dialog takes focus, which closes the popover and would unmount the input mid-pick, dropping the file. It has no layout to cost us here. */} - + {fileInput} {/* Reads in the order it acts: pick a background, then blur it. Lived under "Effects" while that was a separate facet, which is how a control named "Blur BG" ended up in the tab that doesn't say background. */} @@ -502,16 +396,26 @@ function useMemoCustomWallpapers(current: string): string[] { return cached; } +function normaliseHex(raw: string): string | null { + const trimmed = raw.trim(); + if (!trimmed) return null; + const withHash = trimmed.startsWith("#") ? trimmed : `#${trimmed}`; + if (!/^#([0-9a-fA-F]{3}|[0-9a-fA-F]{6})$/.test(withHash)) return null; + return withHash.toLowerCase(); +} + function BackgroundColorTab({ value, hasDocument, isSelected, onPick, + updateNative = true, }: { value: string; hasDocument: boolean; isSelected: (v: string) => boolean; onPick: (next: string) => void; + updateNative?: boolean; }) { const ts = useScopedT("settings"); const [hexDraft, setHexDraft] = useState(value.startsWith("#") ? value : "#000000"); @@ -522,7 +426,7 @@ function BackgroundColorTab({ const next = normaliseHex(hexDraft); if (next) { onPick(next); - if (isNativeCompositorActive()) { + if (updateNative && isNativeCompositorActive()) { setNativeParam("backgroundColor", next); } } @@ -540,7 +444,7 @@ function BackgroundColorTab({ disabled={!hasDocument} onClick={() => { onPick(c); - if (isNativeCompositorActive()) { + if (updateNative && isNativeCompositorActive()) { setNativeParam("backgroundColor", c); } }} @@ -595,12 +499,166 @@ function BackgroundColorTab({ ); } -function normaliseHex(raw: string): string | null { - const trimmed = raw.trim(); - if (!trimmed) return null; - const withHash = trimmed.startsWith("#") ? trimmed : `#${trimmed}`; - if (!/^#([0-9a-fA-F]{3}|[0-9a-fA-F]{6})$/.test(withHash)) return null; - return withHash.toLowerCase(); +export interface WallpaperPickerProps { + value: string; + hasDocument: boolean; + onChange: (val: string) => void; + onLiveChange?: (val: string) => void; + onCommit?: () => void; + updateNativeBackground?: boolean; + /** Opens the OS file dialog. The hidden `` it clicks belongs to the caller + * (see `useWallpaperFileInput`): where it may be mounted depends on the caller. */ + onPickFile: () => void; +} + +export function WallpaperPicker({ + value, + hasDocument, + onChange, + onLiveChange, + onCommit, + updateNativeBackground = true, + onPickFile, +}: WallpaperPickerProps) { + const ts = useScopedT("settings"); + // Seeded from what is actually in use, so the picker opens on the tab the user is + // already in rather than always on Image. + const [tab, setTab] = useState<"image" | "color" | "gradient">( + () => classifyWallpaper(value).kind, + ); + const customUrls = useMemoCustomWallpapers(value); + + const gradientCommitTimer = useRef(null); + const handleGradientChange = useCallback( + (state: GradientEditorState) => { + const grad = buildGradientFromEditor(state); + if (onLiveChange) onLiveChange(grad); + else onChange(grad); + if (gradientCommitTimer.current !== null) { + window.clearTimeout(gradientCommitTimer.current); + } + gradientCommitTimer.current = window.setTimeout(() => { + gradientCommitTimer.current = null; + if (onCommit) void onCommit(); + }, 400); + }, + [onChange, onLiveChange, onCommit], + ); + useEffect( + () => () => { + if (gradientCommitTimer.current !== null) { + window.clearTimeout(gradientCommitTimer.current); + } + }, + [], + ); + + const isSelected = (candidate: string) => value === candidate; + + const handleTabChange = (next: "image" | "color" | "gradient") => { + setTab(next); + }; + + return ( + <> + {/* role="tab" + aria-selected are what make the tablist mean anything: without + them a screen reader announces three plain buttons and never says which one + is current. */} +
+ + + +
+ {tab === "image" ? ( + <> + +
+ {customUrls.map((url) => ( +
+ + ) : tab === "color" ? ( + onChange(color)} + updateNative={updateNativeBackground} + /> + ) : ( + <> +
+ {GRAD_PRESETS.map((bg, i) => ( +
+ {hasDocument ? : null} + + )} + + ); } /** Which clip a transcript cut lands on. The clip id is what makes the cut land on ONE @@ -1828,9 +1886,55 @@ const CAMERA_SHAPES: Array<{ }, ]; +const CAMERA_BACKGROUND_MODES: Array<{ + value: "none" | "transparent" | "blur" | "custom"; + labelKey: string; + icon: ReactNode; +}> = [ + { + value: "none", + labelKey: "layout.bgModes.none", + icon: , + }, + { + value: "transparent", + labelKey: "layout.bgModes.transparent", + icon: ( + <> + + + + ), + }, + { + value: "blur", + labelKey: "layout.bgModes.blur", + icon: ( + <> + + + + ), + }, + { + value: "custom", + labelKey: "layout.bgModes.custom", + icon: ( + <> + + + + + ), + }, +]; + export function LayoutPane() { const ts = useScopedT("settings"); const { settings, set, setLive, commit, hasDocument } = useEditorSettings(); + const { pick: handlePickWebcamWallpaper, input: webcamWallpaperInput } = useWallpaperFileInput( + (dataUrl) => set({ webcamWallpaper: dataUrl }), + ); const document = useProjectStore((s) => s.document); // A project can hold clips with no camera attached at all (plain imports or a // recording made without a webcam). Keep the saved camera preference for later, but @@ -2031,6 +2135,78 @@ export function LayoutPane() { ) : null} +
{ts("layout.webcamBackground")}
+
+ {CAMERA_BACKGROUND_MODES.map((mode) => { + const isActive = settings.webcamBackgroundMode === mode.value; + return ( + + ); + })} +
+ {settings.webcamBackgroundMode === "blur" ? ( +
+ setLive({ webcamBlurIntensity: next / 100 })} + onCommit={() => void commit()} + /> +
+ ) : null} + {settings.webcamBackgroundMode === "custom" ? ( +
+ void set({ webcamWallpaper: url })} + onLiveChange={(url) => setLive({ webcamWallpaper: url })} + onCommit={commit} + updateNativeBackground={false} + onPickFile={handlePickWebcamWallpaper} + /> + {webcamWallpaperInput} +
+ ) : null}
{ts("layout.webcamFraming")}
{ afterEach(() => { cleanup(); @@ -154,4 +177,32 @@ describe("WebcamOverlay (per-clip camera resolution)", () => { rerender(); expect(container.querySelector("video")).toBeTruthy(); }); + + // The webcam background effect is composited by the native compositor from the scene, + // not by this component: the mask reaches the shader as a texture. So the overlay renders + // the same thing whatever the mode — a