diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 46be1ddd8..b7e7a5763 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -353,6 +353,16 @@ jobs: - name: Build Metal compositor addon run: npm run build:native:compositor:mac + # Third step this job has to spell out, same reason as the two above: it is in + # `npm run build:mac`, which this job does not run — it needs `--dir` plus a + # hand-rolled DMG. Windows and Linux get it free from `build:win` / `build:linux`. + # Unlike the compositor addon, a missing ONNX Runtime does not fail the pack: the + # camera-background control just does nothing, on every shipped Mac, with nothing + # in CI raising a word. No-ops on x64 — upstream publishes no osx-x64 asset, so the + # script says so and exits 0 rather than failing that build. + - name: Stage ONNX Runtime + run: npm run fetch:onnxruntime + - name: Package .app bundle run: npx electron-builder --mac --${{ matrix.arch }} --dir --publish never env: diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 7fcb0c20b..5ee421be9 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -159,9 +159,19 @@ jobs: # [env] block (cargo has no [target..env] — the macOS section in that file # is inert and cargo warns "unused key"), so before that change this job pointed # bindgen at the win64 tree and could never have gone green. + # + # Without a library on ORT_DYLIB_PATH, `runtime_available()` is false and every + # segmentation test returns early — the suite goes green having exercised no + # inference at all, which is exactly how the `ort`-panics-when-absent bug got + # in. Staging it here is what makes `the_whole_loop_produces_a_mask_from_ + # compose_frame_alone` a real test on this runner instead of a skipped one. + # ~30 MB, next to nothing beside `brew install ffmpeg` above. + - name: Stage ONNX Runtime + run: node scripts/fetch-onnxruntime.mjs - name: cargo test (compositor, aarch64-apple-darwin) env: MAC_FFMPEG_DIR: /opt/homebrew/opt/ffmpeg + ORT_DYLIB_PATH: ${{ github.workspace }}/electron/native/bin/darwin-arm64/libonnxruntime.dylib run: | cd crates cargo test -p openscreen-compositor --lib --tests @@ -266,11 +276,21 @@ jobs: # et rendre une erreur qui ne designe pas la cause non plus. test -n "$libclang" || { echo "libclang introuvable apres l'installation"; exit 1; } echo "LIBCLANG_PATH=$(dirname "$libclang")" >> "$GITHUB_ENV" + # Meme raison que sur le job macOS : sans bibliotheque sur ORT_DYLIB_PATH, + # `runtime_available()` est faux et chaque test de segmentation rend la main + # tout de suite — la suite passe au vert sans avoir exerce la moindre + # inference, ce qui est exactement par ou le bug « ort panique quand elle + # manque » est entre. C'est ce qui fait de + # `the_whole_loop_produces_a_mask_from_compose_frame_alone` un vrai test ici + # plutot qu'un test saute. Builtins node uniquement, comme fetch:ffmpeg:sdk. + - name: Stage ONNX Runtime + run: node scripts/fetch-onnxruntime.mjs - name: cargo test (compositor) env: # Les .so ffmpeg vendorises ne sont dans aucun chemin systeme : sans ca # le binaire de test se lance puis meurt sur `libavformat.so.62`. LD_LIBRARY_PATH: ${{ github.workspace }}/crates/thirdparty/ffmpeg-linux64-lgpl-shared/lib + ORT_DYLIB_PATH: ${{ github.workspace }}/electron/native/bin/linux-x64/libonnxruntime.so # Fait ECHOUER `cpu_backend_linux.rs` s'il n'obtient pas le backend CPU, # au lieu de le sauter en silence comme sur un poste sans lavapipe. OPENSCREEN_REQUIRE_CPU_BACKEND: "1" diff --git a/THIRD-PARTY-NOTICES.md b/THIRD-PARTY-NOTICES.md index 330bf7492..f10c009c6 100644 --- a/THIRD-PARTY-NOTICES.md +++ b/THIRD-PARTY-NOTICES.md @@ -49,6 +49,42 @@ distributed by their own registries, not redistributed inside our binaries. - The speech model (`ggml-*.bin`) is **not** bundled — it is downloaded into the user's data directory on first use by `electron/stt/modelManager.ts`. +## ONNX Runtime (Windows and Apple Silicon macOS) + +- **Component**: `onnxruntime.dll` / `libonnxruntime.dylib`, under + `resources/electron/native/bin/-/`. +- **License**: MIT — . +- Not built here: the pinned upstream release archive is downloaded, SHA-256 + verified and unpacked by `scripts/fetch-onnxruntime.mjs`, which also checks the + archive's own LICENSE really is MIT before vendoring anything. +- **Why it ships**: the native compositor segments the webcam subject with it, on + the CPU execution provider, to drive the camera background cutout/blur/custom + modes. The `gpu_cuda*` builds are deliberately not used — they are an order of + magnitude larger and carry NVIDIA redistribution terms. +- **Not on Intel macOS**: upstream publishes no `osx-x86_64` asset from 1.27 on, + so the x64 DMG ships without it and the camera background effects are simply + absent there. Not shipped on Linux either, where the compositor has no capture + path for the mask yet. +- The segmentation model it runs is a separate component, immediately below. + +## MediaPipe Selfie Segmentation — model weights + +- **Components**: `selfie_segmentation.tflite`, + `selfie_segmentation_landscape.tflite` and the `selfie_segmentation_landscape.onnx` + derived from them, shipped inside `app.asar` under `dist/mediapipe/`. +- **License**: Apache-2.0 — . + Copyright The MediaPipe Authors. +- The `.onnx` is a **derived work**, generated from the vendored `.tflite` by + `scripts/convert-selfie-segmentation-to-onnx.py`. No third-party weights are + downloaded at build time. +- **Why it is listed here**: these weights are redistributed inside the installer, + and Apache-2.0 §4 asks that the attribution travel with them. The provenance note + in `public/mediapipe/selfie_segmentation/README.md` does not — electron-builder's + `"!*.md"` filter strips it from the package — so this file is the only copy a user + ever receives. +- The MediaPipe **JavaScript** solution and its two ~5.6 MB WASM builds are no longer + bundled: inference moved into the native compositor, and nothing loaded them. + ## Microsoft OpenMP runtime — `vcomp140.dll` (Windows only) - **Component**: `resources/electron/native/bin/win32-x64/vcomp140.dll`. 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..242421cd8 100644 --- a/crates/compositor/src/compositor_linux.rs +++ b/crates/compositor/src/compositor_linux.rs @@ -21,6 +21,13 @@ //! par les memes primitives (`draw_layer`) et arrivent par iterations, comme le //! port Metal les a ajoutes -- chacun reutilise `layer.wgsl` (modes deja portes) //! ou une passe dediee (`blur.wgsl`). +//! +//! **Segmentation du sujet webcam.** Les quatre etages tournent ici comme sur les +//! deux autres back-ends : `capture_webcam_rgb` rend la camera dans une cible +//! 256x144 et la relit, `segmentation.rs` (partage, EP CPU d'ONNX Runtime) produit +//! le masque sur son propre thread, `set_webcam_mask` le televerse en R8, et +//! `layer.wgsl` branche dessus sur `fx.z`. Cf. +//! `technical-documentation/engineering/webcam-segmentation.md`. use std::cell::RefCell; @@ -50,6 +57,22 @@ fn layer_bytes(cb: &LayerCB) -> &[u8] { unsafe { std::slice::from_raw_parts(cb as *const LayerCB as *const u8, 128) } } +/// Un calque de fond deja lie, en attente de son `draw`. `_buf`/`_tex`/`_view` +/// ne sont jamais relus : ils gardent en vie ce que le bind group reference +/// jusqu'au submit. Ce backend encode toute la frame avant de la soumettre, la +/// ou D3D11 dessine au fil de l'eau ; d'ou cette boite, la que Windows n'a pas +/// besoin d'equivalent. +/// +/// Vit au niveau module (et non dans `compose_frame`) parce que le fond d'ecran +/// ET le fond de la bulle webcam sont desormais construits par les memes +/// methodes. +struct BgDraw { + _buf: wgpu::Buffer, + _tex: Option, + _view: Option, + bind: wgpu::BindGroup, +} + /// Une copie RT -> staging DEJA SOUMISE, dont le mapping est arme mais pas /// encore recolte. On garde `idx` (l'index de soumission rendu par /// `Queue::submit`) pour n'attendre QUE cette soumission-la, et les dimensions @@ -101,6 +124,52 @@ struct ReadbackRing { pending: std::collections::VecDeque, } +// --------------------------------------------------------------------------- +// Segmentation du sujet webcam +// --------------------------------------------------------------------------- + +/// Cadence de l'inference. Meme valeur et meme raison que +/// `compositor_windows::SEGMENTATION_HZ` : une silhouette ne bouge pas de facon +/// perceptible en 16 ms, et c'est le seul levier mesure qui divise le cout par +/// deux sans toucher au modele. +const SEGMENTATION_HZ: u32 = 30; + +/// Cible RGBA + buffer de staging pour extraire la frame webcam a la resolution +/// du modele. Pendant wgpu de `compositor_windows::SegCapture`. +/// +/// La divergence tient au `bpr`. D3D11 rend un row pitch decide par le driver et +/// Metal accepte la largeur nue ; `copy_texture_to_buffer` exige, lui, un +/// `bytes_per_row` multiple de 256. Il est donc padde ICI, a la creation, et +/// depadde a la lecture — exactement ce que `ReadbackRing` fait deja pour le RT. +/// A 256 px de large le padding est nul (1024 est deja aligne), mais rien dans +/// cette structure ne le suppose : c'est `width` qui decide, pas le modele. +struct SegCapture { + /// Cible de la passe de capture, et source de la copie vers `staging`. + rt: wgpu::Texture, + view: wgpu::TextureView, + /// Buffer de staging REUTILISE d'une capture a l'autre : a 30 Hz, en + /// reallouer un par tour serait un cout gratuit. + staging: wgpu::Buffer, + width: u32, + height: u32, + bpr: u32, +} + +/// Texture du masque de segmentation, recreee seulement quand la resolution du +/// modele change — c'est-a-dire jamais, en regime etabli. +/// +/// La vue vit A COTE de la texture plutot que d'etre recreee par draw : +/// `make_bind` lie le binding 4 sur CHAQUE draw de calque (le layout l'exige, cf. +/// `tex_entry(4)`), donc une vue par draw ferait une dizaine d'allocations par +/// frame pour rien. La texture, elle, reste indispensable : `write_texture` +/// prend une `Texture`, pas une `TextureView`. +struct WebcamMask { + tex: wgpu::Texture, + view: wgpu::TextureView, + width: u32, + height: u32, +} + pub struct Compositor { gpu: Gpu, render_w: u32, @@ -172,6 +241,35 @@ pub struct Compositor { /// frame. La longueur de la source sert de temoin de changement, comme cote /// macOS. ann_img_cache: RefCell>, + + // --- Segmentation du sujet webcam (cf. `pump_segmentation`) --- + /// Masque du sujet, R8 a la resolution du modele. Ecrit par + /// `set_webcam_mask`, lu par `make_bind` au moment de construire chaque bind + /// group. `None` tant qu'aucune frame n'a ete segmentee — l'effet reste + /// alors eteint plutot que de rendre une webcam invisible en detourage. + webcam_mask: RefCell>, + /// Cible + staging de la capture, crees a la premiere capture et jamais + /// redimensionnes : le modele a une entree fixe. + seg_capture: RefCell>, + /// Worker d'inference, absent tant que `enable_segmentation` n'a pas ete + /// appele. + seg_worker: RefCell>, + /// Segmenteur tenu SUR LE THREAD DE RENDU, utilise a la place du worker en + /// mode deterministe. Voir `set_segmentation_deterministic`. + seg_sync: RefCell>, + /// Export : cadence par frame et inference synchrone, au lieu de l'horloge + /// et du worker. + seg_deterministic: std::cell::Cell, + /// Boite aux lettres du worker. Le masque est depose depuis le thread + /// d'inference et televerse depuis le thread de rendu : aucun appel wgpu ne + /// traverse de thread, ce qui compte ici puisque `Compositor` n'est ni `Send` + /// ni `Sync` (tout son etat vit dans des `RefCell`). + seg_inbox: std::sync::Arc>>>, + seg_rate: RefCell, + /// Frame RGB reutilisee d'une capture a l'autre. + seg_scratch: RefCell>, + /// Le chargement du modele a echoue : ne pas reessayer a chaque frame. + seg_failed: RefCell, } impl Compositor { @@ -238,6 +336,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 { @@ -465,6 +569,15 @@ impl Compositor { ann_copy_view, ann_copy_mips, ann_img_cache: RefCell::new(std::collections::HashMap::new()), + webcam_mask: RefCell::new(None), + seg_capture: RefCell::new(None), + seg_worker: RefCell::new(None), + seg_sync: RefCell::new(None), + seg_deterministic: std::cell::Cell::new(false), + 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), }) } @@ -925,6 +1038,15 @@ impl Compositor { usage: wgpu::BufferUsages::UNIFORM, }); let (y, uv) = planes.unwrap_or((dummy, dummy)); + // Le masque est lie sur TOUS les draws, pas seulement celui de la camera. + // wgpu valide le bind group contre le layout : le binding 4 est declare + // (`tex_entry(4)`), donc une entree absente ferait echouer CHAQUE draw et + // pas seulement ceux qui l'echantillonnent. Le lier partout ne coute rien + // — la branche du shader n'est prise que si `fx.z > 0.5`, et seul le + // calque webcam leve `fx.z`. `dummy` reste le repli tant qu'aucune frame + // n'a ete segmentee. + let mask = self.webcam_mask.borrow(); + let mask_view = mask.as_ref().map_or(dummy, |m| &m.view); let bind = self.gpu.device.create_bind_group(&wgpu::BindGroupDescriptor { label: Some("layer"), layout: &self.bind_group_layout, @@ -945,6 +1067,10 @@ impl Compositor { binding: 3, resource: wgpu::BindingResource::Sampler(&self.sampler), }, + wgpu::BindGroupEntry { + binding: 4, + resource: wgpu::BindingResource::TextureView(mask_view), + }, ], }); (uniform, bind) @@ -993,6 +1119,556 @@ impl Compositor { Ok((tex, w, h)) } + /// Calque image (mode 6) couvrant `dst`, en cover-fit contre `aspect` -- le + /// ratio du RECT vise, et non celui de la sortie : le rognage se calcule + /// contre la zone qu'on remplit, ce qui permet a la bulle webcam d'emprunter + /// le chemin du fond d'ecran au lieu d'en refaire un. + /// + /// Err plutot qu'un repli maison : chaque appelant a son propre message et + /// son propre repli, et un echec silencieux redonnerait le noir qu'on corrige. + fn image_bg_draw( + &self, + path: &str, + dst: [f32; 4], + quad_px: [f32; 2], + radius_px: f32, + aspect: f32, + dummy: &wgpu::TextureView, + ) -> Result { + // Charge (ou recupere du cache) l'image. Emprunt isole AVANT le + // borrow_mut (piege du double emprunt 1re frame, cf. macOS). + let cached = self.img_cache.borrow().get(path).cloned(); + let (tex, iw, ih) = match cached { + Some(v) => v, + None => { + let v = self.load_image_texture(path)?; + self.img_cache.borrow_mut().insert(path.to_string(), v.clone()); + v + } + }; + // Cover-fit : l'image remplit tout le rect, on rogne l'axe long. + let ai = iw as f32 / ih.max(1) as f32; + let src = if ai > aspect { + let vis = aspect / ai; + [(1.0 - vis) * 0.5, 0.0, 1.0 - (1.0 - vis) * 0.5, 1.0] + } else { + let vis = ai / aspect; + [0.0, (1.0 - vis) * 0.5, 1.0, 1.0 - (1.0 - vis) * 0.5] + }; + let cb = LayerCB { + dst, + src, + quad_px, + radius_px, + mode: 6.0, + ..Default::default() + }; + let view = tex.create_view(&wgpu::TextureViewDescriptor::default()); + let (buf, bind) = self.make_bind(&cb, Some((&view, &view)), dummy); + Ok(BgDraw { _buf: buf, _tex: Some(tex), _view: Some(view), bind }) + } + + /// Prepare le fond du mode « personnalise », peint DANS la bulle webcam juste + /// avant que la camera n'y soit decoupee par-dessus. + /// + /// Le shader ne sait peindre qu'une couleur plate sous le masque, donc un + /// degrade ou une image y tombaient sur du noir -- et le defaut EST une image + /// (`DEFAULT_WALLPAPER`), si bien que le mode ne rendait jamais ce que le + /// selecteur montrait. Peindre le fond puis composer la camera en detourage + /// donne exactement le meme resultat (`lerp(fond, camera, personne)`, ici par + /// le melange alpha) pour les trois sortes de fond, en reutilisant les chemins + /// deja eprouves du fond d'ecran, et sans rien ajouter aux trois shaders. + /// + /// `quad_px` / `radius_px` sont ceux de la bulle : le fond doit epouser ses + /// coins arrondis, sinon un rectangle deborde derriere la camera. + fn webcam_bg_draw( + &self, + bg: Option<&SceneBackground>, + dst: [f32; 4], + quad_px: [f32; 2], + radius_px: f32, + dummy: &wgpu::TextureView, + ) -> BgDraw { + const BLACK: [f32; 4] = [0.0, 0.0, 0.0, 1.0]; + let flat = |cb: LayerCB| { + let (buf, bind) = self.make_bind(&cb, None, dummy); + BgDraw { _buf: buf, _tex: None, _view: None, bind } + }; + let solid = |color: [f32; 4]| LayerCB { + dst, + quad_px, + radius_px, + mode: 1.0, + color, + ..Default::default() + }; + match bg { + Some(SceneBackground::Color { color }) => { + flat(solid(parse_hex(color).unwrap_or(BLACK))) + } + Some(SceneBackground::Gradient { angle_deg, stops }) => { + let c0 = stops.first().and_then(|s| parse_hex(s)).unwrap_or(BLACK); + let c1 = stops.last().and_then(|s| parse_hex(s)).unwrap_or(c0); + // angle CSS -> direction unitaire, meme convention que le fond + // d'ecran (dont la direction se lit en espace SORTIE : le degrade + // traverse le cadre, la bulle n'en montre que sa tranche). + let a = angle_deg.to_radians(); + flat(LayerCB { + dst, + src: [c1[0], c1[1], c1[2], c1[3]], + quad_px, + radius_px, + mode: 5.0, + color: c0, + fx: [a.sin(), -a.cos(), 0.0, 0.0], + ..Default::default() + }) + } + Some(SceneBackground::Image { path }) => { + // Le cover-fit se mesure sur la BULLE, pas sur la sortie : c'est + // elle que l'image doit remplir sans etirement. + let aspect = if quad_px[1] > 0.0 { quad_px[0] / quad_px[1] } else { 1.0 }; + match self.image_bg_draw(path, dst, quad_px, radius_px, aspect, dummy) { + Ok(d) => d, + Err(e) => { + // Meme contrat que le fond d'ecran : un chemin casse est + // logge puis remplace par du noir. Un repli silencieux + // redonnerait le bug qu'on corrige. + eprintln!("[fond webcam] \"{path}\" : {e:#}"); + flat(solid(BLACK)) + } + } + } + // Personnalise sans fond : noir, comme avant -- mais c'est desormais + // le seul chemin qui y mene, au lieu de l'etre pour toute image et + // tout degrade. + None => flat(solid(BLACK)), + } + } + + // -- segmentation du sujet webcam -- + + /// Extrait la frame webcam en RGB8 a la resolution du modele, dans `out`. + /// + /// Pendant wgpu de `compositor_windows::capture_webcam_rgb`, avec les memes + /// contraintes d'appel. Comme cote Metal, rien n'est « requisitionne » : la + /// passe s'ouvre sur `SegCapture::view` et se referme. La contrainte d'ordre + /// tient malgre tout, et pour une autre raison — cette methode ATTEND sa + /// propre soumission, donc l'appeler une fois la passe de composition + /// encodee serialiserait CPU et GPU sur exactement le chemin que cette + /// conception garde recouvert. Elle tourne donc dans le prologue de + /// `compose_frame`, avant le moindre encodeur. + /// + /// `src` est le rect source en UV. L'appelant y passe la frame ENTIERE et non + /// le sous-rect dessine — cf. `pump_segmentation`. + /// + /// # Le readback + /// + /// Meme forme que `ReadbackRing`, en plus simple parce qu'il n'y a rien a + /// recouvrir : une seule copie, attendue tout de suite. Ce qui EST repris de + /// la ring, parce que c'est la lecon qu'elle porte, c'est + /// `WaitForSubmissionIndex` — jamais `Maintain::Wait`, qui absorberait toute + /// la file GPU (3,8 a 6,2 ms mesurees en 1080p, cf. l'en-tete de + /// `ReadbackRing`) au lieu de la seule copie de 147 Ko demandee ici. + /// + /// C'est le second readback synchrone du chemin de preview, qui en paie deja + /// un a profondeur 1 (`live.rs`). C'est le seul cout que ce portage ajoute au + /// rendu, et il ne se paie que quand un effet est demande. + /// + /// A l'EXPORT, ou la ring tourne a profondeur 2, il faut etre honnete sur ce + /// que cette attente coute : une file GPU se termine dans l'ordre, donc + /// attendre CETTE soumission, c'est attendre aussi la copie de la frame + /// precedente que la ring gardait justement en vol. Le travail CPU de la + /// frame courante ne la recouvre donc plus. Ce n'est pas gratuit, c'est + /// seulement borne : 30 Hz et non 60, et zero quand aucun effet n'est demande. + /// Aucune des deux mesures §C.2 n'a ete faite — cf. « Still open » dans + /// `webcam-segmentation.md`. + pub unsafe fn capture_webcam_rgb( + &self, + wy: &wgpu::TextureView, + wuv: &wgpu::TextureView, + src: [f32; 4], + width: u32, + height: u32, + out: &mut Vec, + ) -> Result<()> { + if width == 0 || height == 0 { + anyhow::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 rt = self.gpu.device.create_texture(&wgpu::TextureDescriptor { + label: Some("seg-capture"), + size: wgpu::Extent3d { width, height, depth_or_array_layers: 1 }, + mip_level_count: 1, + sample_count: 1, + dimension: wgpu::TextureDimension::D2, + // Meme format que le RT : c'est celui que `mk_layer` a cable + // dans la cible couleur du pipeline de calque, et une passe + // dont la piece jointe ne l'a pas est refusee. + format: wgpu::TextureFormat::Rgba8Unorm, + usage: wgpu::TextureUsages::RENDER_ATTACHMENT + | wgpu::TextureUsages::COPY_SRC, + view_formats: &[], + }); + let view = rt.create_view(&wgpu::TextureViewDescriptor::default()); + let bpr = (width * 4).div_ceil(256) * 256; + let staging = self.gpu.device.create_buffer(&wgpu::BufferDescriptor { + label: Some("seg-capture-staging"), + size: u64::from(bpr) * u64::from(height), + usage: wgpu::BufferUsages::COPY_DST | wgpu::BufferUsages::MAP_READ, + mapped_at_creation: false, + }); + *slot = Some(SegCapture { rt, view, staging, width, height, bpr }); + } + } + let slot = self.seg_capture.borrow(); + let cap = slot.as_ref().expect("cree juste au-dessus"); + + // Plein cadre de la cible, sans coins ni motion blur : le modele veut + // l'image, pas la mise en forme. `fx` reste a zero — la branche de masque + // du shader ne doit surtout pas se prendre sur la capture qui l'alimente. + // `color.a = 1` n'est pas decoratif : `fs_main` calcule son alpha en + // `layer.color.a * alpha_mask`, donc le defaut (0) rendrait un quad + // entierement transparent. + let (_uniform, bind) = self.make_bind( + &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() + }, + Some((wy, wuv)), + &self.dummy_view(), + ); + + let mut encoder = self.gpu.device.create_command_encoder( + &wgpu::CommandEncoderDescriptor { label: Some("seg-capture") }, + ); + { + let mut rpass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor { + label: Some("seg-capture-pass"), + color_attachments: &[Some(wgpu::RenderPassColorAttachment { + view: &cap.view, + resolve_target: None, + ops: wgpu::Operations { + load: wgpu::LoadOp::Clear(wgpu::Color::BLACK), + store: wgpu::StoreOp::Store, + }, + })], + depth_stencil_attachment: None, + timestamp_writes: None, + occlusion_query_set: None, + }); + rpass.set_pipeline(&self.pipeline); + rpass.set_bind_group(0, &bind, &[]); + rpass.draw(0..4, 0..1); + } + encoder.copy_texture_to_buffer( + wgpu::TexelCopyTextureInfo { + texture: &cap.rt, + mip_level: 0, + origin: wgpu::Origin3d::ZERO, + aspect: wgpu::TextureAspect::All, + }, + wgpu::TexelCopyBufferInfo { + buffer: &cap.staging, + layout: wgpu::TexelCopyBufferLayout { + offset: 0, + bytes_per_row: Some(cap.bpr), + rows_per_image: Some(height), + }, + }, + wgpu::Extent3d { width, height, depth_or_array_layers: 1 }, + ); + let idx = self.gpu.context.submit(std::iter::once(encoder.finish())); + let (tx, rx) = std::sync::mpsc::channel(); + cap.staging.slice(..).map_async(wgpu::MapMode::Read, move |r| { + let _ = tx.send(r); + }); + // `WaitForSubmissionIndex` et JAMAIS `Maintain::Wait` : cf. l'en-tete de + // `ReadbackRing`, qui est le proces-verbal de cette regression-la. + self.gpu.device.poll(wgpu::Maintain::WaitForSubmissionIndex(idx)); + rx.recv() + .map_err(|_| anyhow::anyhow!("map_async channel (capture webcam)"))? + .map_err(|e| anyhow::anyhow!("map_async (capture webcam): {e:?}"))?; + let slice = cap.staging.slice(..); + let mapped = slice.get_mapped_range(); + + let (w, h, bpr) = (width as usize, height as usize, cap.bpr as usize); + // `clear` + `reserve` plutot qu'un `Vec` neuf : la capacite survit d'une + // capture a l'autre, donc apres le premier tour plus une seule + // reallocation. A 30 Hz ce n'est pas une coquetterie. + out.clear(); + out.reserve(w * h * 3); + for row in 0..h { + // La ligne fait `w * 4` octets utiles dans un pas de `bpr` : le + // padding d'alignement se saute ici, il n'a jamais de sens pour le + // modele. + for px in mapped[row * bpr..row * bpr + w * 4].chunks_exact(4) { + // RGBA -> RGB : le modele n'a pas de canal alpha en entree. + out.push(px[0]); + out.push(px[1]); + out.push(px[2]); + } + } + drop(mapped); + // Sans `unmap`, la capture suivante echouerait a re-armer `map_async` sur + // un buffer deja mappe. + cap.staging.unmap(); + Ok(()) + } + + /// Publie le masque de segmentation du sujet webcam (R8, `width`x`height`, + /// 0 = fond). + /// + /// `Queue::write_texture` et non une copie par buffer : il n'impose aucun + /// alignement de ligne (c'est `copy_texture_to_buffer` qui exige 256, cf. + /// `SegCapture`), et c'est deja par lui que `linux_frames` televerse les plans + /// NV12 avec les strides SIMD de swscale. La texture n'est recreee que si la + /// resolution du modele change, ce qui n'arrive pas en regime etabli. + /// + /// Pas de double buffer, et pour la meme raison que cote Metal : quand + /// `pump_segmentation` appelle ceci, la frame precedente est deja drainee — + /// `capture_webcam_rgb` attend sa soumission, et la preview comme l'export + /// passent par `readback_take`, qui attend la sienne. Si cet invariant + /// changeait, c'est ce code-ci qui casserait. + pub fn set_webcam_mask(&self, data: &[u8], width: u32, height: u32) -> Result<()> { + if width == 0 || height == 0 { + anyhow::bail!("masque webcam de dimensions nulles ({width}x{height})"); + } + let expected = (width as usize) * (height as usize); + if data.len() < expected { + anyhow::bail!( + "masque webcam trop court : {} octets pour {width}x{height}", + data.len() + ); + } + + let mut slot = self.webcam_mask.borrow_mut(); + if !matches!(slot.as_ref(), Some(m) if m.width == width && m.height == height) { + let tex = self.gpu.device.create_texture(&wgpu::TextureDescriptor { + label: Some("webcam-mask"), + size: wgpu::Extent3d { width, height, depth_or_array_layers: 1 }, + mip_level_count: 1, + sample_count: 1, + dimension: wgpu::TextureDimension::D2, + format: wgpu::TextureFormat::R8Unorm, + usage: wgpu::TextureUsages::TEXTURE_BINDING | wgpu::TextureUsages::COPY_DST, + view_formats: &[], + }); + let view = tex.create_view(&wgpu::TextureViewDescriptor::default()); + *slot = Some(WebcamMask { tex, view, width, height }); + } + let mask = slot.as_ref().expect("alloue juste au-dessus"); + self.gpu.context.write_texture( + wgpu::TexelCopyTextureInfo { + texture: &mask.tex, + mip_level: 0, + origin: wgpu::Origin3d::ZERO, + aspect: wgpu::TextureAspect::All, + }, + // `data` peut etre plus long que le masque (le garde ci-dessus est un + // minimum) : on ne televerse que ce que la texture porte. + &data[..expected], + wgpu::TexelCopyBufferLayout { + offset: 0, + bytes_per_row: Some(width), + rows_per_image: Some(height), + }, + wgpu::Extent3d { width, height, depth_or_array_layers: 1 }, + ); + Ok(()) + } + + /// Un tour de segmentation : televerse le masque pret, puis soumet une + /// nouvelle frame si la cadence l'autorise. Port de + /// `compositor_windows::pump_segmentation` — worker, boite aux lettres, + /// limiteur de cadence et demarrage paresseux sont independants de la + /// plateforme, seuls les deux appels GPU changent. + /// + /// Les deux moities sont volontairement desynchronisees. Le masque televerse + /// ici vient de la frame precedente — une frame de retard sur une silhouette + /// est invisible, alors qu'attendre l'inference bloquerait le rendu, ce qui + /// est exactement le cout que toute cette conception cherche a ne pas payer. + unsafe fn pump_segmentation( + &self, + wy: &wgpu::TextureView, + wuv: &wgpu::TextureView, + valid: [f32; 2], + ) -> Result<()> { + if *self.seg_failed.borrow() { + return Ok(()); + } + // Rien a faire si aucun effet n'est demande : ni capture, ni inference, + // ni masque. Le cout de la fonctionnalite 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(()); + } + + // Demarrage paresseux, pilote par la scene : personne n'a a appeler + // `enable_segmentation` a la main, et un modele introuvable eteint l'effet + // au lieu de faire tomber le rendu. + if self.seg_worker.borrow().is_none() && self.seg_sync.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] desactivee : {e}"); + // Une scene qui reste identique retenterait a chaque frame ; on + // leve le verrou plutot que de journaliser 60 fois par seconde. + *self.seg_failed.borrow_mut() = true; + return Ok(()); + } + // En preview on rend cette frame sans masque : le worker vient de + // demarrer et l'effet apparaitra dans quelques millisecondes, ce que + // personne ne voit. A l'export cette frame part dans le fichier — on + // enchaine donc sur la capture et l'inference plutot que de la laisser + // sortir non detouree. + if !self.seg_deterministic.get() { + 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, + )?; + } + + // La cadence horloge est le bon reglage en preview et le mauvais a + // l'export, ou les frames defilent aussi vite que la machine decode : le + // nombre de frames couvertes par un masque dependrait alors de la charge. + // En deterministe, une inference par frame. + if !self.seg_deterministic.get() + && !self.seg_rate.borrow_mut().should_run(std::time::Instant::now()) + { + return Ok(()); + } + let mut scratch = self.seg_scratch.borrow_mut(); + // La frame ENTIERE, pas le sous-rect dessine : un crop utilisateur serre + // amputerait le sujet en entree du modele, et le masque serait faux la ou + // il compte le plus. Le shader ramene ses coordonnees 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 self.seg_deterministic.get() { + // Synchrone : le masque doit exister avant que cette frame ne soit + // composee, sinon on retombe sur le defaut qu'on corrige. Une + // inference ratee laisse le masque precedent, comme le fait le worker. + let mut sync = self.seg_sync.borrow_mut(); + if let Some(seg) = sync.as_mut() { + match seg.run(&scratch) { + Ok(mask) => { + // `run` rend une tranche empruntee au segmenteur : copier + // puis relacher, sinon `set_webcam_mask` reemprunterait + // `seg_sync` encore emprunte ici. + let mask = mask.to_vec(); + drop(sync); + self.set_webcam_mask( + &mask, + crate::segmentation::MODEL_WIDTH, + crate::segmentation::MODEL_HEIGHT, + )?; + } + Err(e) => eprintln!("[segmentation] frame ignoree : {e}"), + } + } + } else if let Some(w) = self.seg_worker.borrow().as_ref() { + w.submit(&scratch); + } + Ok(()) + } + + /// Demarre la segmentation du sujet webcam pour ce compositeur. + /// + /// Idempotent. Tant qu'elle n'est pas appelee, `compose_frame` ne fait rien de + /// plus et la webcam se dessine comme avant — c'est ce qui rend l'effet inerte + /// plutot que casse sur une build sans modele. + pub fn enable_segmentation(&self, model_path: &std::path::Path) -> Result<()> { + if self.seg_worker.borrow().is_some() || self.seg_sync.borrow().is_some() { + return Ok(()); + } + let segmenter = crate::segmentation::Segmenter::load(model_path)?; + // En deterministe, le segmenteur reste ici : l'inference tourne sur le + // thread de rendu, donc le masque de la frame N est pret AVANT qu'elle ne + // soit composee. Le worker est un choix de preview — ne jamais bloquer + // l'affichage — et c'est exactement ce qui rend l'export irreproductible, + // le masque arrivant quelques frames plus tard selon la charge. + if self.seg_deterministic.get() { + *self.seg_sync.borrow_mut() = Some(segmenter); + return Ok(()); + } + let inbox = std::sync::Arc::clone(&self.seg_inbox); + let worker = + crate::segmentation::SegmentationWorker::spawn(segmenter, move |mask, _, _| { + // Ecrase le masque precedent s'il n'a pas encore ete televerse : + // c'est le plus recent qui vaut, jamais une file. + *inbox.lock().unwrap() = Some(mask.to_vec()); + }); + *self.seg_worker.borrow_mut() = Some(worker); + Ok(()) + } + + /// Bascule la segmentation en mode reproductible, pour l'export. + /// + /// En preview, la cadence suit l'horloge (30 Hz reels) et l'inference tourne + /// sur un worker : c'est le bon choix, l'affichage ne doit jamais attendre. A + /// l'export les frames sont rendues aussi vite que la machine decode, sans + /// rapport avec le temps reel — et ces deux choix deviennent alors des bugs. + /// La cadence horloge fait dependre le nombre de frames couvertes par un + /// masque de la vitesse de la machine, et le worker asynchrone rend les + /// premieres frames AVANT que le premier masque n'existe : elles partent dans + /// le fichier avec le vrai arriere-plan de la webcam. Deux exports du meme + /// projet ne donnent donc pas les memes pixels, ce qui casse l'invariant + /// « l'export est identique a la preview ». + /// + /// En deterministe : une inference PAR FRAME, synchrone. Plus couteux + /// (~3 ms/frame), mais l'export est hors ligne et chaque frame porte le masque + /// calcule depuis SA propre image. + /// + /// A appeler avant la premiere frame — c'est ce qui decide comment + /// `enable_segmentation` s'installe. + pub fn set_segmentation_deterministic(&self, on: bool) { + if self.seg_deterministic.get() == on { + return; + } + self.seg_deterministic.set(on); + // Changer de mode change le MOTEUR, et `enable_segmentation` est idempotent sur la + // PRESENCE d'un moteur : sans demonter celui qui ne correspond plus, le drapeau + // mentirait. Un compositeur qui a deja servi en preview garderait son worker, + // `seg_sync` resterait vide, et l'export entier ne ferait AUCUNE inference. Le + // demarrage paresseux de `pump_segmentation` reinstalle le bon moteur a la frame + // suivante. + *self.seg_worker.borrow_mut() = None; + *self.seg_sync.borrow_mut() = None; + // Et le masque que le worker demonte avait peut-etre deja depose : il vient de l'autre + // mode, il n'a rien a faire sur la premiere frame de celui-ci. + *self.seg_inbox.lock().unwrap() = None; + } + + /// Eteint l'effet : la webcam se redessine telle quelle a la frame suivante. + pub fn clear_webcam_mask(&self) { + *self.webcam_mask.borrow_mut() = None; + } + /// Rend une frame dans le RT interne. Le screen `screen`/`webcam` sont des /// carriers `linux_frames` ; la geometrie vient de `plan_frame`. Coeur : /// fond uni + ecran cover-fit. `readback_direct` lit ensuite le RT. @@ -1018,6 +1694,36 @@ impl Compositor { let u_max = scw / (stw.max(1)) as f32; let v_max = sch / (sth.max(1)) as f32; let (rw, rh) = (self.render_w as f32, self.render_h as f32); + // Etendue valide de la texture webcam : les decodeurs allouent des + // textures alignees (`linux_frames` arrondit deja aux dimensions paires), + // donc la frame n'occupe pas forcement toute la texture. `.max(1)` au + // denominateur — `tex_dims` rend (1, 1) sur une webcam absente, la ou le + // chemin Windows divise sans garde parce qu'il a toujours les deux frames. + let w_valid = [wcw / (wtw.max(1)) as f32, wch / (wth.max(1)) as f32]; + + // Segmentation, AVANT le moindre encodeur de composition : + // `capture_webcam_rgb` attend sa propre soumission, et attendre au milieu + // de la frame serialiserait CPU et GPU. Dernier point ou `wtw/wth/wcw/wch` + // sont en portee sans emprunt de `self.scene` — `pump_segmentation` + // emprunte la scene lui-meme. + // + // L'effet est teste ICI en plus de l'etre dans `pump_segmentation` : sur + // ce backend `nv12_srvs` ALLOUE deux `TextureView` a chaque appel (il n'y + // a pas de cache, cf. `clear_srv_cache`), et la fonctionnalite doit couter + // exactement zero quand elle est eteinte — ce qui est le cas general. + let wants_seg = self + .scene + .borrow() + .as_ref() + .and_then(|s| s.webcam_effect.as_ref()) + .is_some_and(|e| e.shader_code() > 0.0); + 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). + if let Ok((wy, wuv)) = self.nv12_srvs(webcam) { + self.pump_segmentation(&wy, &wuv, w_valid)?; + } + } let scene_ref = self.scene.borrow(); let cursor_ref = self.cursor.borrow(); @@ -1154,54 +1860,23 @@ impl Compositor { }); // Fond (gradient mode 5 OU image mode 6), dessine dans la passe de fond. - // `_tex`/`_view` gardent l'image en vie pendant le pass. - struct BgDraw { - _buf: wgpu::Buffer, - _tex: Option, - _view: Option, - bind: wgpu::BindGroup, - } let bg_draw = bg_layer.and_then(|bl| match bl { BgLayer::Gradient(cb) => { let (buf, bind) = self.make_bind(&cb, None, &dummy); Some(BgDraw { _buf: buf, _tex: None, _view: None, bind }) } + // Le wallpaper couvre tout le cadre, donc dst plein et pas de coins : + // `image_bg_draw` sert aussi la bulle webcam, qui elle en a. BgLayer::Image(path) => { - // Charge (ou recupere du cache) le wallpaper. Emprunt isole AVANT - // le borrow_mut (piege du double emprunt 1re frame, cf. macOS). - let cached = self.img_cache.borrow().get(path.as_str()).cloned(); - let (tex, iw, ih) = match cached { - Some(v) => v, - None => match self.load_image_texture(&path) { - Ok(v) => { - self.img_cache.borrow_mut().insert(path.clone(), v.clone()); - v - } - Err(e) => { - eprintln!("[fond image] \"{path}\" : {e:#}"); - return None; - } - }, - }; - // Cover-fit : l'image remplit tout le cadre, on rogne l'axe long. - let ai = iw as f32 / ih.max(1) as f32; - let ao = rw / rh; - let src = if ai > ao { - let vis = ao / ai; - [(1.0 - vis) * 0.5, 0.0, 1.0 - (1.0 - vis) * 0.5, 1.0] - } else { - let vis = ai / ao; - [0.0, (1.0 - vis) * 0.5, 1.0, 1.0 - (1.0 - vis) * 0.5] - }; - let cb = LayerCB { - dst: [0.0, 0.0, 1.0, 1.0], - src, - mode: 6.0, - ..Default::default() - }; - let view = tex.create_view(&wgpu::TextureViewDescriptor::default()); - let (buf, bind) = self.make_bind(&cb, Some((&view, &view)), &dummy); - Some(BgDraw { _buf: buf, _tex: Some(tex), _view: Some(view), bind }) + match self + .image_bg_draw(&path, [0.0, 0.0, 1.0, 1.0], [0.0, 0.0], 0.0, rw / rh, &dummy) + { + Ok(d) => Some(d), + Err(e) => { + eprintln!("[fond image] \"{path}\" : {e:#}"); + None + } + } } }); @@ -1219,6 +1894,55 @@ impl Compositor { } else { None }; + // Effet d'arriere-plan : le mode vient de la scene, le masque par pixel de + // l'inference. Les DEUX sont requis — un mode sans masque rendrait la + // webcam invisible en detourage, donc tant que rien n'a ete segmente on + // dessine la piste telle quelle. C'est aussi ce qui rend le premier + // 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 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) + .map(|e| (e.shader_code(), e)) + .filter(|(code, _)| *code > 0.0); + match effect { + // 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 + // « mode 3 » du shader calculait, mais pour les TROIS sortes de + // fond. Le shader ne sait peindre qu'une couleur plate sous le + // masque ; degrades et images y tombaient sur du noir, et le defaut + // EST une image. + Some((code, e)) if code > 2.5 => { + // Sans piste webcam le fond peindrait un rectangle seul dans le + // cadre : il ne se prepare que si la camera se dessine. + let bg = webcam_planes.is_some().then(|| { + self.webcam_bg_draw( + e.background.as_ref(), + g.w_dst, + g.w_px, + g.w_radius, + &dummy, + ) + }); + (1.0, 0.0, bg) + } + Some((code, e)) => (code, e.blur_intensity.clamp(0.0, 1.0), None), + None => (0.0, 0.0, None), + } + }; + // 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 webcam_draw = webcam_planes.as_ref().map(|(wy, wuv)| { // 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 @@ -1255,12 +1979,21 @@ 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], + // `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 + // trois back-ends, cf. `layer.wgsl` et `webcam-segmentation.md`. + 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, 1.0, 1.0, 0.0], ..Default::default() }; + // Le masque est lie par `make_bind` sur tous les draws, pas seulement + // celui-ci : le layout l'exige (cf. `tex_entry(4)`). self.make_bind(&cb, Some((wy, wuv)), &dummy) }); @@ -1268,9 +2001,14 @@ impl Compositor { // vertical-stack) : la camera y est collee a l'ecran comme une tuile, // et une ombre entre les deux dessinerait une couture. Meme condition // que macOS. + // + // Pas en detourage non plus : l'ombre appartient a la bulle PiP, et en + // detourage il n'y a plus de bulle — une ombre portee par un rectangle + // devenu invisible se lit comme un artefact. let webcam_shadow = (cfg.shadow && g.shape_fade > 0.0 && webcam_draw.is_some() + && !is_cutout && !matches!( g.scene_preset.as_deref(), Some("dual-frame") | Some("vertical-stack") @@ -1881,6 +2619,14 @@ impl Compositor { rpass.set_bind_group(0, bind, &[]); rpass.draw(0..4, 0..1); } + // Fond personnalise : ENTRE l'ombre et la camera. C'est ce sandwich qui + // remplace la branche « mode 3 » du shader — la camera, decoupee, se + // fond dessus par alpha ; l'ombre reste dessous, elle appartient a la + // bulle et non a son contenu. + if let Some(bg) = &webcam_bg { + rpass.set_bind_group(0, &bg.bind, &[]); + rpass.draw(0..4, 0..1); + } if let Some((_buf, bind)) = &webcam_draw { rpass.set_bind_group(0, bind, &[]); rpass.draw(0..4, 0..1); @@ -2205,3 +2951,817 @@ impl Compositor { last.ok_or_else(|| anyhow::anyhow!("readback_direct: aucune frame recoltee")) } } + +// --------------------------------------------------------------------------- +// Tests +// +// Tous rendent de VRAIS pixels sur le device de la machine, et tous sauf un se +// lisent SANS ONNX Runtime : le masque y est pose a la main par +// `set_webcam_mask` et l'inference n'est pas ce qu'ils testent. C'est delibere — +// ce que ce portage ajoute cote GPU doit etre verifiable la ou la bibliotheque +// n'est pas installee, ce qui est le cas de la CI. Meme parti que +// `compositor_macos::tests`, dont ceci est le pendant. +// +// `poc-d3d` etant `cfg(windows)`, le banc `--cfg C8 --scene` qui a prouve le +// chemin Windows n'existe pas ici : ces tests en tiennent lieu, plus le harnais +// visuel opt-in en fin de fichier pour ce qu'une assertion ne peut pas dire. +// --------------------------------------------------------------------------- +#[cfg(test)] +mod tests { + use super::*; + use crate::d3d::Gpu; + use crate::ffi::AVFrame; + + /// NV12 « limited range » (BT.709), les memes valeurs que `yuv709_limited` + /// inverse : 16 rend du noir franc, 235 du blanc franc, 128 une chroma nulle. + const Y_WHITE: u8 = 235; + const Y_BLACK: u8 = 16; + const UV_NEUTRAL: u8 = 128; + + /// `create_auto` et NON `create` : la CI (`rust-linux-compositor-check`, + /// ubuntu-latest) n'a pas de GPU et rend sur lavapipe. Avec la creation + /// hardware-stricte, tous ces tests s'y sauteraient en silence — c'est-a-dire + /// que le seul endroit ou ils tournent automatiquement ne les executerait pas. + fn gpu() -> Option { + match Gpu::create_auto(false) { + Ok(g) => Some(g), + Err(e) => { + eprintln!("pas d'adaptateur Vulkan ({e:#}) — test saute"); + None + } + } + } + + /// Deux `TextureView` NV12-split, comme `linux_frames::nv12_planes` en rend. + /// + /// Les textures ne sont pas retournees : en wgpu une `TextureView` garde la + /// sienne en vie (c'est deja ce dont depend la pyramide de blur du + /// compositeur, qui n'existe que sous forme de vues). + fn nv12_views( + gpu: &Gpu, + w: u32, + h: u32, + luma: impl Fn(u32, u32) -> u8, + ) -> (wgpu::TextureView, wgpu::TextureView) { + let mut y = vec![0u8; (w * h) as usize]; + for row in 0..h { + for col in 0..w { + y[(row * w + col) as usize] = luma(col, row); + } + } + let (ytex, uvtex) = nv12_textures(gpu, w, h, &y, &vec![UV_NEUTRAL; (w * (h / 2)) as usize]); + ( + ytex.create_view(&wgpu::TextureViewDescriptor::default()), + uvtex.create_view(&wgpu::TextureViewDescriptor::default()), + ) + } + + /// Le couple de textures NV12-split (Y `R8Unorm`, UV entrelacee `Rg8Unorm`) + /// exactement comme `linux_frames::CpuFrames::ensure_textures` les alloue. + fn nv12_textures( + gpu: &Gpu, + w: u32, + h: u32, + y: &[u8], + uv: &[u8], + ) -> (wgpu::Texture, wgpu::Texture) { + let mk = |label: &str, format, tw: u32, th: u32| { + gpu.device.create_texture(&wgpu::TextureDescriptor { + label: Some(label), + size: wgpu::Extent3d { width: tw, height: th, depth_or_array_layers: 1 }, + mip_level_count: 1, + sample_count: 1, + dimension: wgpu::TextureDimension::D2, + format, + usage: wgpu::TextureUsages::TEXTURE_BINDING | wgpu::TextureUsages::COPY_DST, + view_formats: &[], + }) + }; + let ytex = mk("test-nv12-y", wgpu::TextureFormat::R8Unorm, w, h); + let uvtex = mk("test-nv12-uv", wgpu::TextureFormat::Rg8Unorm, w / 2, h / 2); + let write = |tex: &wgpu::Texture, data: &[u8], bpr: u32, tw: u32, th: u32| { + gpu.context.write_texture( + wgpu::TexelCopyTextureInfo { + texture: tex, + mip_level: 0, + origin: wgpu::Origin3d::ZERO, + aspect: wgpu::TextureAspect::All, + }, + data, + wgpu::TexelCopyBufferLayout { + offset: 0, + bytes_per_row: Some(bpr), + rows_per_image: Some(th), + }, + wgpu::Extent3d { width: tw, height: th, depth_or_array_layers: 1 }, + ); + }; + write(&ytex, y, w, w, h); + // UV : `w / 2` texels de 2 octets par ligne, soit `w` octets — la meme + // valeur que pour Y, par coincidence arithmetique et non par symetrie. + write(&uvtex, uv, w, w / 2, h / 2); + (ytex, uvtex) + } + + /// Masque 0 sur la moitie gauche, 255 sur la droite. La frontiere tombe pile + /// au milieu, donc un echantillon pris au quart et un aux trois quarts sont + /// loin du degrade que le filtrage lineaire pose sur la couture. + fn half_mask(w: u32, h: u32) -> Vec { + (0..w * h).map(|i| if i % w < w / 2 { 0u8 } else { 255u8 }).collect() + } + + /// Dessine UN calque plein cadre sur le RT, par-dessus `clear`, et rend le + /// RGBA relu. + /// + /// Court-circuite `compose_frame` a dessein : ces tests-ci isolent le shader + /// et la liaison du masque, pas la geometrie que `plan_frame` decide. + fn draw_one_layer( + comp: &Compositor, + clear: wgpu::Color, + cb: &LayerCB, + planes: (&wgpu::TextureView, &wgpu::TextureView), + ) -> (u32, u32, Vec) { + let dummy = comp.dummy_view(); + let (_buf, bind) = comp.make_bind(cb, Some(planes), &dummy); + let mut encoder = comp.gpu.device.create_command_encoder( + &wgpu::CommandEncoderDescriptor { label: Some("test-layer") }, + ); + { + let mut rpass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor { + label: Some("test-layer-pass"), + color_attachments: &[Some(wgpu::RenderPassColorAttachment { + view: &comp.rt_view, + resolve_target: None, + ops: wgpu::Operations { + load: wgpu::LoadOp::Clear(clear), + store: wgpu::StoreOp::Store, + }, + })], + depth_stencil_attachment: None, + timestamp_writes: None, + occlusion_query_set: None, + }); + rpass.set_pipeline(&comp.pipeline); + rpass.set_bind_group(0, &bind, &[]); + rpass.draw(0..4, 0..1); + } + comp.gpu.context.submit(std::iter::once(encoder.finish())); + unsafe { comp.readback_direct().expect("readback_direct") } + } + + // ----------------------------------------------------------------------- + // La capture + // ----------------------------------------------------------------------- + + #[test] + fn the_webcam_capture_comes_back_as_interleaved_rgb_at_model_resolution() { + let Some(gpu) = gpu() else { return }; + let comp = Compositor::new_sized(&gpu, 320, 180).expect("Compositor::new_sized"); + // Moitie gauche noire, moitie droite blanche : la capture doit rendre les + // deux dans le bon sens. Une inversion d'axe passerait un test de taille + // sans se voir. + let (y, uv) = nv12_views(&gpu, 64, 64, |col, _| if col < 32 { Y_BLACK } else { Y_WHITE }); + + let mut out = Vec::new(); + unsafe { + comp.capture_webcam_rgb( + &y, + &uv, + [0.0, 0.0, 1.0, 1.0], + crate::segmentation::MODEL_WIDTH, + crate::segmentation::MODEL_HEIGHT, + &mut out, + ) + .expect("capture_webcam_rgb"); + } + + let (w, h) = ( + crate::segmentation::MODEL_WIDTH as usize, + crate::segmentation::MODEL_HEIGHT as usize, + ); + assert_eq!(out.len(), w * h * 3, "le modele veut du RGB8 entrelace, sans alpha"); + + let px = |buf: &[u8], col: usize, row: usize| -> [u8; 3] { + let i = (row * w + col) * 3; + [buf[i], buf[i + 1], buf[i + 2]] + }; + let left = px(&out, w / 4, h / 2); + let right = px(&out, 3 * w / 4, h / 2); + assert!(left.iter().all(|&c| c < 24), "moitie gauche pas noire : {left:?}"); + assert!(right.iter().all(|&c| c > 231), "moitie droite pas blanche : {right:?}"); + + // Deuxieme capture sur le meme buffer : c'est le regime etabli (30 fois + // par seconde), et il ne doit ni reallouer ni trainer les octets du tour + // precedent. + let capacity = out.capacity(); + unsafe { + comp.capture_webcam_rgb( + &y, + &uv, + [0.0, 0.0, 1.0, 1.0], + crate::segmentation::MODEL_WIDTH, + crate::segmentation::MODEL_HEIGHT, + &mut out, + ) + .expect("deuxieme capture"); + } + assert_eq!(out.len(), w * h * 3); + assert_eq!(out.capacity(), capacity, "le scratch se realloue d'une frame a l'autre"); + assert_eq!(px(&out, w / 4, h / 2), left); + assert_eq!(px(&out, 3 * w / 4, h / 2), right); + } + + /// Le piege PROPRE a ce backend : `copy_texture_to_buffer` exige un + /// `bytes_per_row` multiple de 256, et le depadder est a la charge de + /// l'appelant. A la resolution livree (256 px, 1024 octets) le padding est nul + /// — donc la resolution livree n'exerce JAMAIS ce chemin. Il faut une largeur + /// qui le fasse : 100 px = 400 octets utiles dans un pas de 512. + /// + /// Un depad rate ne rend pas du bruit, il rend un CISAILLEMENT : chaque ligne + /// glisse de 28 px sur la precedente. D'ou l'echantillonnage sur plusieurs + /// lignes plutot que sur une seule. + #[test] + fn a_capture_whose_rows_need_padding_is_depadded_correctly() { + let Some(gpu) = gpu() else { return }; + let comp = Compositor::new_sized(&gpu, 320, 180).expect("Compositor::new_sized"); + let (y, uv) = nv12_views(&gpu, 64, 64, |col, _| if col < 32 { Y_BLACK } else { Y_WHITE }); + + let (w, h) = (100usize, 56usize); + assert_ne!((w * 4) % 256, 0, "cette largeur doit justement ETRE mal alignee"); + let mut out = Vec::new(); + unsafe { + comp.capture_webcam_rgb(&y, &uv, [0.0, 0.0, 1.0, 1.0], w as u32, h as u32, &mut out) + .expect("capture_webcam_rgb"); + } + assert_eq!(out.len(), w * h * 3, "le padding d'alignement a fuit dans la sortie"); + + let px = |col: usize, row: usize| -> [u8; 3] { + let i = (row * w + col) * 3; + [out[i], out[i + 1], out[i + 2]] + }; + for row in [0usize, h / 3, h / 2, h - 1] { + let left = px(w / 4, row); + let right = px(3 * w / 4, row); + assert!(left.iter().all(|&c| c < 24), "ligne {row}, gauche pas noire : {left:?}"); + assert!(right.iter().all(|&c| c > 231), "ligne {row}, droite pas blanche : {right:?}"); + } + } + + #[test] + fn a_capture_of_zero_size_is_refused_rather_than_rendered() { + let Some(gpu) = gpu() else { return }; + let comp = Compositor::new_sized(&gpu, 320, 180).expect("Compositor::new_sized"); + let (y, uv) = nv12_views(&gpu, 16, 16, |_, _| Y_WHITE); + let mut out = Vec::new(); + let r = unsafe { comp.capture_webcam_rgb(&y, &uv, [0.0, 0.0, 1.0, 1.0], 0, 144, &mut out) }; + assert!(r.is_err(), "une cible de largeur nulle doit etre refusee"); + } + + // ----------------------------------------------------------------------- + // Le masque + // ----------------------------------------------------------------------- + + #[test] + fn the_mask_texture_is_allocated_once_and_a_short_buffer_is_refused() { + let Some(gpu) = gpu() else { return }; + let comp = Compositor::new_sized(&gpu, 320, 180).expect("Compositor::new_sized"); + let (w, h) = (crate::segmentation::MODEL_WIDTH, crate::segmentation::MODEL_HEIGHT); + let mask = vec![255u8; (w * h) as usize]; + + comp.set_webcam_mask(&mask, w, h).expect("premier televersement"); + let first = comp.webcam_mask.borrow().as_ref().map(|m| m.tex.clone()); + comp.set_webcam_mask(&mask, w, h).expect("deuxieme televersement"); + let second = comp.webcam_mask.borrow().as_ref().map(|m| m.tex.clone()); + assert_eq!( + first, second, + "la texture est recreee a chaque frame alors que la resolution du modele est fixe" + ); + + // Un masque trop court doit etre refuse, pas lu hors bornes. + assert!(comp.set_webcam_mask(&mask[..(w * h) as usize - 1], w, h).is_err()); + assert!(comp.set_webcam_mask(&mask, 0, h).is_err()); + comp.clear_webcam_mask(); + assert!(comp.webcam_mask.borrow().is_none()); + } + + /// Le test qui compte : le masque DECOUPE vraiment la camera. + /// + /// Il rend le calque webcam plein cadre avec `fx.z = 1` (detourage) et un + /// masque mi-fond mi-sujet, puis relit les pixels. Il couvre d'un coup les + /// trois choses que le portage ajoute et qu'aucune compilation ne verifie : + /// le televersement R8, la liaison de la texture au binding 4, et la branche + /// `fx.z` de `fs_main` sur un vrai device. + #[test] + fn the_mask_actually_cuts_the_camera_out() { + let Some(gpu) = gpu() else { return }; + let comp = Compositor::new_sized(&gpu, 64, 64).expect("Compositor::new_sized"); + comp.set_webcam_mask(&half_mask(8, 8), 8, 8).expect("set_webcam_mask"); + let (y, uv) = nv12_views(&gpu, 16, 16, |_, _| Y_WHITE); + + // Fond bleu franc : une couleur que la camera (blanche, chroma neutre) ne + // peut pas produire, donc « il reste du bleu » signifie « la camera a ete + // decoupee ici ». + let (rw, _, rgba) = draw_one_layer( + &comp, + wgpu::Color { r: 0.0, g: 0.0, b: 1.0, a: 1.0 }, + &LayerCB { + dst: [0.0, 0.0, 1.0, 1.0], + src: [0.0, 0.0, 1.0, 1.0], + quad_px: [64.0, 64.0], + mode: 0.0, + color: [0.0, 0.0, 0.0, 1.0], + // fx.xy = etendue valide (toute la texture ici), fx.z = 1 -> detourage. + fx: [1.0, 1.0, 1.0, 0.0], + src_prev: [0.0, 0.0, 1.0, 1.0], + dst_prev: [0.0, 0.0, 1.0, 1.0], + mb: [1.0, 1.0, 1.0, 0.0], + ..Default::default() + }, + (&y, &uv), + ); + + let px = |col: usize, row: usize| -> [u8; 4] { + let i = (row * rw as usize + col) * 4; + [rgba[i], rgba[i + 1], rgba[i + 2], rgba[i + 3]] + }; + assert_eq!(px(16, 32), [0, 0, 255, 255], "masque a 0 : le fond doit rester visible"); + assert_eq!(px(48, 32), [255, 255, 255, 255], "masque a 255 : la camera doit rester opaque"); + } + + /// Meme montage, mode fond personnalise (`fx.z = 3`) : la ou le masque dit + /// « fond », le shader doit peindre `color` — c'est le seul mode ou + /// `LayerCB::color` cesse d'etre du noir opaque decoratif et porte une valeur + /// que le portage doit transmettre. + #[test] + fn the_custom_background_colour_replaces_the_masked_out_pixels() { + let Some(gpu) = gpu() else { return }; + let comp = Compositor::new_sized(&gpu, 64, 64).expect("Compositor::new_sized"); + comp.set_webcam_mask(&half_mask(8, 8), 8, 8).expect("set_webcam_mask"); + let (y, uv) = nv12_views(&gpu, 16, 16, |_, _| Y_WHITE); + + let (rw, _, rgba) = draw_one_layer( + &comp, + wgpu::Color::BLACK, + &LayerCB { + dst: [0.0, 0.0, 1.0, 1.0], + src: [0.0, 0.0, 1.0, 1.0], + quad_px: [64.0, 64.0], + mode: 0.0, + color: [1.0, 0.0, 0.0, 1.0], + fx: [1.0, 1.0, 3.0, 0.0], + src_prev: [0.0, 0.0, 1.0, 1.0], + dst_prev: [0.0, 0.0, 1.0, 1.0], + mb: [1.0, 1.0, 1.0, 0.0], + ..Default::default() + }, + (&y, &uv), + ); + let px = |col: usize, row: usize| -> [u8; 4] { + let i = (row * rw as usize + col) * 4; + [rgba[i], rgba[i + 1], rgba[i + 2], rgba[i + 3]] + }; + assert_eq!(px(16, 32), [255, 0, 0, 255], "fond masque : la couleur custom doit peindre"); + assert_eq!(px(48, 32), [255, 255, 255, 255], "sujet : la camera doit rester intacte"); + } + + // ----------------------------------------------------------------------- + // `compose_frame` de bout en bout + // + // Les tests ci-dessus prouvent les pieces ; ceux-ci prouvent le CABLAGE — que + // `compose_frame` porte bien `fx`/`color` sur le calque webcam, qu'il lie le + // masque, et qu'il ne leve `fx.z` qu'une fois un masque reellement televerse. + // Ils passent par de vraies `AVFrame` porteuses d'un carrier `VkFrameTex`, + // donc par le MEME `nv12_srvs` que le decodeur : aucun raccourci n'est pris + // sur le seam de frame. + // ----------------------------------------------------------------------- + + /// Une `AVFrame` du backend Linux. `compose_frame` n'en lit que `format`, + /// `data[0]`, `width` et `height` : le reste peut rester a zero. + struct FakeFrame { + frame: Box, + } + + impl FakeFrame { + fn new(gpu: &Gpu, w: u32, h: u32, luma: impl Fn(u32, u32) -> u8) -> FakeFrame { + let mut y = vec![0u8; (w * h) as usize]; + for row in 0..h { + for col in 0..w { + y[(row * w + col) as usize] = luma(col, row); + } + } + FakeFrame::from_planes(gpu, w, h, &y, &vec![UV_NEUTRAL; (w * (h / 2)) as usize]) + } + + fn from_planes(gpu: &Gpu, w: u32, h: u32, y: &[u8], uv: &[u8]) -> FakeFrame { + let (ytex, uvtex) = nv12_textures(gpu, w, h, y, uv); + // Le carrier que `linux_frames::nv12_planes` et `carrier_dims` + // deballent. `Box::into_raw` ici, `Box::from_raw` dans `Drop` — c'est + // exactement la mecanique de `CpuFrames::attach_carrier`. + let carrier = Box::into_raw(Box::new(crate::linux_frames::VkFrameTex { + y: ytex, + uv: uvtex, + width: w, + height: h, + })) as *mut u8; + let mut frame: Box = Box::new(unsafe { std::mem::zeroed() }); + // Le sentinel « buffer GPU natif dans data[0] », le meme que pose + // `CpuFrames::present`. + frame.format = crate::ffi::AVPixelFormat::AV_PIX_FMT_D3D11 as i32; + frame.data[0] = carrier; + frame.width = w as i32; + frame.height = h as i32; + FakeFrame { frame } + } + + fn as_ptr(&self) -> *const AVFrame { + &*self.frame as *const AVFrame + } + } + + impl Drop for FakeFrame { + fn drop(&mut self) { + if !self.frame.data[0].is_null() { + unsafe { + drop(Box::from_raw( + self.frame.data[0] as *mut crate::linux_frames::VkFrameTex, + )); + } + self.frame.data[0] = std::ptr::null_mut(); + } + } + } + + /// Scene PiP minimale. `effect` est le JSON de `webcamEffect` (`"null"` pour + /// aucun). + /// + /// `effects.shadow` vaut 0 A DESSEIN : ce curseur ne pilote plus que l'ombre + /// de l'ecran, alors que celle du PiP est fixe (`WEBCAM_SHADOW_OPACITY`) et ne + /// depend que de `cfg.shadow`. Le mettre a zero est donc ce qui isole les + /// deux — sinon un test sur `cfg.shadow` mesure les deux ombres a la fois et + /// ne dit plus rien de la camera. + fn pip_scene_json(effect: &str) -> String { + format!( + r##"{{"clips":[], + "layout":{{"preset":"picture-in-picture","webcamSize":1,"webcamShape":"rectangle", + "webcamMirror":false,"webcamPosition":null,"webcamReactiveZoom":false}}, + "effects":{{"padding":0.18,"blur":false,"shadow":0,"roundnessFrac":0.05,"motionBlur":0}}, + "background":{{"kind":"color","color":"#0080ff"}}, + "zoomRegions":[],"annotations":[], + "cursor":{{"show":false,"size":1,"smoothing":0,"motionBlur":0,"clickBounce":0, + "clipToBounds":false,"theme":"default"}}, + "cropByClip":[], + "webcamEffect":{effect}, + "output":{{"width":1920,"height":1080,"fps":30}}}}"## + ) + } + + /// Compose une frame et rend le RGBA du RT. `screen` est gris moyen, `webcam` + /// blanche : le blanc franc devient alors la SIGNATURE de la camera, une + /// couleur qu'aucun autre calque de cette scene ne produit, donc comptable + /// sans connaitre la geometrie du PiP. + /// + /// Le fond est un bleu franc et NON du noir : le PiP par defaut tombe dans la + /// marge, hors de l'ecran, et une ombre noire sur un fond noir ne se voit + /// pas — le controle du test d'ombre passerait alors pour une suppression + /// reussie. + /// + /// `set_live_params(live_params_from_scene(..))` n'est PAS decoratif : padding, + /// effets et forme de la webcam transitent par `LiveParams` et non par la + /// scene brute. L'omettre laisse la scene parser correctement puis etre + /// ignoree, et le rendu tombe sur les defauts. + fn compose_pip( + comp: &Compositor, + gpu: &Gpu, + effect: &str, + shadow: bool, + ) -> Vec { + let scene = Scene::from_json(&pip_scene_json(effect)).expect("scene json"); + comp.set_live_params(live_params_from_scene(&scene)); + comp.set_has_webcam(true); + comp.set_scene(Some(scene)); + + let screen = FakeFrame::new(gpu, 128, 128, |_, _| 126); + let webcam = FakeFrame::new(gpu, 64, 64, |_, _| Y_WHITE); + let mut cfg = Cfg::c8(); + cfg.bg_blur = false; + cfg.zoom = false; + cfg.layout_anim = false; + cfg.cursor = false; + cfg.mblur_n = 1; + cfg.shadow = shadow; + unsafe { + comp.compose_frame(screen.as_ptr(), webcam.as_ptr(), 0.0, &cfg) + .expect("compose_frame"); + let (_, _, rgba) = comp.readback_direct().expect("readback_direct"); + rgba + } + } + + /// Pixels quasi blancs = pixels de camera encore visibles. + fn camera_pixels(rgba: &[u8]) -> usize { + rgba.chunks_exact(4) + .filter(|px| px[0] > 240 && px[1] > 240 && px[2] > 240) + .count() + } + + const NO_EFFECT: &str = "null"; + const CUTOUT: &str = + r#"{"mode":"transparent","blurIntensity":0,"background":null,"modelPath":null}"#; + + /// Le piege que le brief nomme : un mode SANS masque ne doit rien changer. + /// + /// `effect_code` doit rester a 0 tant que rien n'a ete segmente, sinon le + /// detourage rend une webcam invisible sur les premieres frames — le temps que + /// l'inference rende son premier masque, c'est-a-dire a chaque ouverture de + /// l'editeur. L'assertion est octet pour octet : « inchange » ne souffre pas + /// d'a-peu-pres. + #[test] + fn a_mode_without_a_mask_composites_exactly_like_no_effect_at_all() { + let Some(gpu) = gpu() else { return }; + let comp = Compositor::new_sized(&gpu, 320, 180).expect("Compositor::new_sized"); + let plain = compose_pip(&comp, &gpu, NO_EFFECT, true); + let requested = compose_pip(&comp, &gpu, CUTOUT, true); + assert!( + comp.webcam_mask.borrow().is_none(), + "aucun masque n'a ete televerse : `modelPath` est absent, donc rien ne segmente" + ); + assert!( + camera_pixels(&plain) > 200, + "la camera n'est pas a l'ecran, le test ne prouve rien" + ); + assert_eq!(plain, requested, "un mode sans masque a change des pixels"); + } + + /// Et une fois le masque la, le detourage doit VRAIMENT decouper — dans la + /// bonne proportion. Le masque couvre la moitie de la camera, donc la moitie + /// de ses pixels doit disparaitre. Compter plutot que d'echantillonner un + /// point evite de coder en dur la geometrie du PiP, qui appartient a + /// `plan_frame` et non a ce portage. + #[test] + fn compose_frame_cuts_the_camera_out_once_a_mask_exists() { + let Some(gpu) = gpu() else { return }; + let comp = Compositor::new_sized(&gpu, 320, 180).expect("Compositor::new_sized"); + let whole = camera_pixels(&compose_pip(&comp, &gpu, NO_EFFECT, true)); + assert!(whole > 200, "la camera n'est pas a l'ecran, le test ne prouve rien"); + + let (mw, mh) = (crate::segmentation::MODEL_WIDTH, crate::segmentation::MODEL_HEIGHT); + comp.set_webcam_mask(&half_mask(mw, mh), mw, mh).expect("set_webcam_mask"); + let cut = camera_pixels(&compose_pip(&comp, &gpu, CUTOUT, true)); + + let expected = whole as f32 / 2.0; + assert!( + (cut as f32 - expected).abs() < expected * 0.15, + "detourage : {cut} pixels de camera restants pour ~{expected:.0} attendus \ + (entier : {whole})" + ); + } + + /// L'ombre portee du PiP doit disparaitre en detourage : une ombre projetee + /// par un rectangle devenu invisible se lit comme un artefact. Le test le + /// prouve sans jamais localiser l'ombre — en detourage, `cfg.shadow` ne doit + /// plus rien changer du tout. + /// + /// Le controle est ce qui empeche l'assertion d'etre vide : sans effet, + /// `cfg.shadow` DOIT changer des pixels, sinon la premiere moitie passerait + /// aussi pour une scene ou aucune ombre n'a jamais ete dessinee. + #[test] + fn the_pip_shadow_is_suppressed_in_cutout_mode() { + let Some(gpu) = gpu() else { return }; + let comp = Compositor::new_sized(&gpu, 320, 180).expect("Compositor::new_sized"); + assert_ne!( + compose_pip(&comp, &gpu, NO_EFFECT, true), + compose_pip(&comp, &gpu, NO_EFFECT, false), + "controle : sans effet, l'ombre du PiP doit bel et bien se voir" + ); + + let (mw, mh) = (crate::segmentation::MODEL_WIDTH, crate::segmentation::MODEL_HEIGHT); + comp.set_webcam_mask(&half_mask(mw, mh), mw, mh).expect("set_webcam_mask"); + assert_eq!( + compose_pip(&comp, &gpu, CUTOUT, true), + compose_pip(&comp, &gpu, CUTOUT, false), + "en detourage, l'ombre est encore dessinee" + ); + } + + /// Le tour complet, celui qui a besoin d'ONNX Runtime : capture -> inference + /// -> masque -> composite, entraine par `compose_frame` seul. Se saute + /// proprement sans la bibliotheque, ce que fait la CI — cf. + /// `segmentation::runtime_available`. + #[test] + fn the_whole_loop_produces_a_mask_from_compose_frame_alone() { + if !crate::segmentation::runtime_available() { + eprintln!("ONNX Runtime absent (ORT_DYLIB_PATH) — test saute"); + return; + } + let model = std::path::Path::new(env!("CARGO_MANIFEST_DIR")) + .join("../../public/mediapipe/selfie_segmentation/selfie_segmentation_landscape.onnx"); + if !model.is_file() { + eprintln!("modele absent ({}) — test saute", model.display()); + return; + } + let Some(gpu) = gpu() else { return }; + let comp = Compositor::new_sized(&gpu, 320, 180).expect("Compositor::new_sized"); + let effect = format!( + r#"{{"mode":"transparent","blurIntensity":0,"background":null,"modelPath":{}}}"#, + serde_json::to_string(&model.to_string_lossy()).expect("chemin serialisable") + ); + + // Le limiteur est a 30 Hz : une frame par tour ne suffirait pas, et + // l'inference est asynchrone. On laisse au worker le temps de rendre un + // masque, sans jamais l'attendre dans le rendu — ce qui est precisement le + // contrat. + let mut uploaded = false; + for _ in 0..40 { + let _ = compose_pip(&comp, &gpu, &effect, true); + if comp.webcam_mask.borrow().is_some() { + uploaded = true; + break; + } + std::thread::sleep(std::time::Duration::from_millis(40)); + } + assert!( + uploaded, + "aucun masque n'est remonte : la boucle capture -> inference -> upload est rompue" + ); + assert!(!*comp.seg_failed.borrow(), "la segmentation s'est eteinte d'elle-meme"); + } + + // ----------------------------------------------------------------------- + // Harnais visuel (opt-in) + // + // Les tests ci-dessus prouvent le mecanisme sur des images synthetiques, ou le + // masque est pose a la main et donc trivialement juste. Ils ne peuvent rien + // dire de la QUALITE du masque que le modele produit sur une vraie camera — et + // « un masque qui composite » n'est pas la meme affirmation que « un masque qui + // est correct ». + // + // Meme forme d'opt-in que `tests/compose_linux.rs` (variable d'environnement + + // skip propre), et pour la meme raison : ca rend sur GPU et ca lit un fichier + // que le depot ne porte pas. + // + // ``` + // ORT_DYLIB_PATH=/chemin/libonnxruntime.so \ + // OPENSCREEN_SEG_CAM=camera.png \ + // OPENSCREEN_SEG_VISUAL=target/seg \ + // cargo test -p openscreen-compositor --lib seg_visual -- --nocapture + // ``` + // ----------------------------------------------------------------------- + + /// RGB8 -> NV12 BT.709 limited. Inverse EXACT de `yuv709_limited` dans + /// `layer.wgsl` : une autre matrice ferait deriver les couleurs du rendu et on + /// croirait a un bug du compositeur la ou il n'y aurait qu'une conversion + /// d'entree fausse. + fn rgb_to_nv12(rgb: &[u8], w: u32, h: u32) -> (Vec, Vec) { + let luma = |i: usize| -> (f32, f32, f32, f32) { + let (r, g, b) = ( + rgb[i * 3] as f32 / 255.0, + rgb[i * 3 + 1] as f32 / 255.0, + rgb[i * 3 + 2] as f32 / 255.0, + ); + (r, g, b, 0.2126 * r + 0.7152 * g + 0.0722 * b) + }; + let mut y = vec![0u8; (w * h) as usize]; + for i in 0..(w * h) as usize { + let (_, _, _, yl) = luma(i); + y[i] = (16.0 + 219.0 * yl).round().clamp(0.0, 255.0) as u8; + } + // Chroma au plus proche voisin : l'echantillon en haut a gauche de chaque + // bloc 2x2. Un vrai filtre ne changerait rien a ce que ce harnais donne a + // voir. + let mut uv = vec![0u8; (w * (h / 2)) as usize]; + for row in 0..h / 2 { + for col in 0..w / 2 { + let (r, _, b, yl) = luma(((row * 2) * w + col * 2) as usize); + let cb = 128.0 + 224.0 * ((b - yl) / 1.8556); + let cr = 128.0 + 224.0 * ((r - yl) / 1.5748); + let o = (row * w + col * 2) as usize; + uv[o] = cb.round().clamp(0.0, 255.0) as u8; + uv[o + 1] = cr.round().clamp(0.0, 255.0) as u8; + } + } + (y, uv) + } + + fn frame_from_png(gpu: &Gpu, path: &std::path::Path) -> FakeFrame { + let img = image::open(path) + .unwrap_or_else(|e| panic!("{} : {e}", path.display())) + .to_rgb8(); + // NV12 veut des dimensions paires ; on rogne d'un pixel plutot que de + // reechantillonner. + let (w, h) = (img.width() & !1, img.height() & !1); + let src = img.as_raw(); + let mut rgb = vec![0u8; (w * h * 3) as usize]; + for row in 0..h { + let (d, s) = ((row * w * 3) as usize, (row * img.width() * 3) as usize); + rgb[d..d + (w * 3) as usize].copy_from_slice(&src[s..s + (w * 3) as usize]); + } + let (y, uv) = rgb_to_nv12(&rgb, w, h); + FakeFrame::from_planes(gpu, w, h, &y, &uv) + } + + #[test] + fn seg_visual_renders_the_four_modes_from_a_real_photo() { + let (Ok(out_dir), Ok(cam)) = ( + std::env::var("OPENSCREEN_SEG_VISUAL"), + std::env::var("OPENSCREEN_SEG_CAM"), + ) else { + eprintln!( + "harnais visuel : OPENSCREEN_SEG_VISUAL + OPENSCREEN_SEG_CAM absents — saute" + ); + return; + }; + if !crate::segmentation::runtime_available() { + eprintln!("ONNX Runtime absent (ORT_DYLIB_PATH) — saute"); + return; + } + let model = std::path::Path::new(env!("CARGO_MANIFEST_DIR")) + .join("../../public/mediapipe/selfie_segmentation/selfie_segmentation_landscape.onnx"); + let Some(gpu) = gpu() else { return }; + std::fs::create_dir_all(&out_dir).expect("dossier de sortie"); + + let (rw, rh) = (1280u32, 720u32); + let comp = Compositor::new_sized(&gpu, rw, rh).expect("Compositor::new_sized"); + let webcam = frame_from_png(&gpu, std::path::Path::new(&cam)); + let screen = match std::env::var("OPENSCREEN_SEG_SCREEN") { + Ok(p) => frame_from_png(&gpu, std::path::Path::new(&p)), + // Sans capture d'ecran sous la main, un damier : il rend le detourage + // lisible, la ou un aplat laisserait croire a un fond simplement peint. + Err(_) => FakeFrame::new(&gpu, 640, 360, |col, row| { + if (col / 40 + row / 40) % 2 == 0 { 180 } else { 60 } + }), + }; + let model_json = serde_json::to_string(&model.to_string_lossy()).expect("chemin"); + + let mut wrote = Vec::new(); + for (name, effect) in [ + ("00-none", "null".to_string()), + ("01-cutout", format!(r#"{{"mode":"transparent","blurIntensity":0,"background":null,"modelPath":{model_json}}}"#)), + ("02-blur", format!(r#"{{"mode":"blur","blurIntensity":0.8,"background":null,"modelPath":{model_json}}}"#)), + ("03-custom", format!(r##"{{"mode":"custom","blurIntensity":0,"background":{{"kind":"color","color":"#ff2d95"}},"modelPath":{model_json}}}"##)), + ] { + // Le masque arrive de facon asynchrone : on tourne jusqu'a ce qu'il + // soit la, ce qui est aussi une verification en soi — la boucle du + // rendu ne l'attend jamais. + let mut rgba = Vec::new(); + for _ in 0..60 { + rgba = compose_visual(&comp, &screen, &webcam, &effect); + if effect == "null" || comp.webcam_mask.borrow().is_some() { + break; + } + std::thread::sleep(std::time::Duration::from_millis(30)); + } + let path = format!("{out_dir}/seg-{name}.png"); + image::RgbaImage::from_raw(rw, rh, rgba) + .expect("dimensions du readback") + .save(&path) + .unwrap_or_else(|e| panic!("ecriture {path} : {e}")); + wrote.push(path); + } + for p in &wrote { + println!("wrote {p}"); + } + assert!( + comp.webcam_mask.borrow().is_some(), + "aucun masque n'a ete produit : les trois modes d'effet sont sans objet" + ); + } + + /// Camera grand format (rect force via `webcamRect`), pour que le masque + /// occupe une bonne part de l'image et se juge a taille reelle. + fn compose_visual( + comp: &Compositor, + screen: &FakeFrame, + webcam: &FakeFrame, + effect: &str, + ) -> Vec { + let json = format!( + r##"{{"clips":[], + "layout":{{"preset":"picture-in-picture","webcamSize":1,"webcamShape":"rectangle", + "webcamMirror":false,"webcamPosition":null,"webcamReactiveZoom":false, + "webcamRect":{{"x":0.06,"y":0.10,"width":0.55,"height":0.72}}}}, + "effects":{{"padding":0.10,"blur":false,"shadow":1,"roundnessFrac":0.02,"motionBlur":0}}, + "background":{{"kind":"gradient","angleDeg":45,"stops":["#1b2a4a","#0b0f1a"]}}, + "zoomRegions":[],"annotations":[], + "cursor":{{"show":false,"size":1,"smoothing":0,"motionBlur":0,"clickBounce":0, + "clipToBounds":false,"theme":"default"}}, + "cropByClip":[], + "webcamEffect":{effect}, + "output":{{"width":1280,"height":720,"fps":30}}}}"## + ); + let scene = Scene::from_json(&json).expect("scene json"); + comp.set_live_params(live_params_from_scene(&scene)); + comp.set_has_webcam(true); + comp.set_scene(Some(scene)); + let mut cfg = Cfg::c8(); + cfg.zoom = false; + cfg.layout_anim = false; + cfg.cursor = false; + cfg.mblur_n = 1; + unsafe { + comp.compose_frame(screen.as_ptr(), webcam.as_ptr(), 0.0, &cfg) + .expect("compose_frame"); + let (_, _, rgba) = comp.readback_direct().expect("readback_direct"); + rgba + } + } +} diff --git a/crates/compositor/src/compositor_macos.rs b/crates/compositor/src/compositor_macos.rs index cffc9395d..4507586b8 100644 --- a/crates/compositor/src/compositor_macos.rs +++ b/crates/compositor/src/compositor_macos.rs @@ -197,6 +197,42 @@ impl Drop for CVMetalTextureCache { } } +// --------------------------------------------------------------------------- +// Segmentation du sujet webcam +// --------------------------------------------------------------------------- + +/// Cadence de l'inférence. Même valeur et même raison que +/// `compositor_windows::SEGMENTATION_HZ` : 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 + miroir de lecture pour extraire la frame webcam à la résolution du modèle. +/// +/// Deux textures, pas une : `rt` est `Private` parce que c'est une cible de rendu, et +/// `get_bytes` n'est légal que sur du `Shared`. C'est exactement le couple +/// `nv12_y`/`nv12_read_y` du chemin d'encodage, en RGBA et à 256x144 — cf. l'en-tête du +/// module. `Managed` n'a pas sa place ici : rien dans ce fichier n'en utilise, et c'est le +/// seul mode de stockage qui exigerait un `synchronizeResource` avant la lecture. +struct SegCapture { + /// Cible de la passe de capture. `Private` : écrite par le GPU, jamais lue par le CPU. + rt: metal::Texture, + /// Miroir `Shared` de `rt`, rempli par blit dans le même command buffer. + read: metal::Texture, + width: u32, + height: u32, +} + +/// Texture du masque de segmentation, recréée seulement quand la résolution du modèle +/// change — c'est-à-dire jamais, en régime établi. Pendant Metal de +/// `compositor_windows::WebcamMask` : pas de vue à côté de la texture, un `MTLTexture` est +/// déjà ce que `set_fragment_texture` prend. +struct WebcamMask { + tex: metal::Texture, + width: u32, + height: u32, +} + // --------------------------------------------------------------------------- // Compositor // --------------------------------------------------------------------------- @@ -280,6 +316,30 @@ pub struct Compositor { /// Textes rastérisés, indexés par ID, avec la `cache_key` du spec pour invalider. text_cache: RefCell>, text_raster: Option, + + // --- Segmentation du sujet webcam (cf. `pump_segmentation`) --- + /// Masque du sujet, R8 à la résolution du modèle. Écrit par `set_webcam_mask`, 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>, + /// Cible + miroir de la capture, créés à la première capture et jamais redimensionnés : + /// 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>, + /// Segmenteur tenu SUR LE THREAD DE RENDU, utilisé à la place du worker en mode + /// déterministe. Voir `set_segmentation_deterministic`. + seg_sync: RefCell>, + /// Export : cadence par frame et inférence synchrone, au lieu de l'horloge et du worker. + seg_deterministic: std::cell::Cell, + /// 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 Metal ne traverse de thread. + 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, } /// Descripteur de texture — les six cibles ne diffèrent que par format, taille et @@ -561,6 +621,15 @@ impl Compositor { ann_img_cache: RefCell::new(std::collections::HashMap::new()), text_cache: RefCell::new(std::collections::HashMap::new()), text_raster: crate::text::TextRasterizer::new().ok(), + webcam_mask: RefCell::new(None), + seg_capture: RefCell::new(None), + seg_worker: RefCell::new(None), + seg_sync: RefCell::new(None), + seg_deterministic: std::cell::Cell::new(false), + 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), }) } @@ -792,6 +861,22 @@ impl Compositor { enc: &metal::RenderCommandEncoderRef, path: &str, output_aspect: f32, + ) -> Result<()> { + self.draw_image_in(enc, path, [0.0, 0.0, 1.0, 1.0], [0.0, 0.0], 0.0, output_aspect) + } + + /// `draw_image_bg` pour un rect quelconque — la bulle webcam s'en sert avec ses coins + /// arrondis. `output_aspect` est le ratio du RECT visé, pas celui de la sortie : le crop + /// « cover » se calcule contre la zone qu'on remplit. + #[allow(clippy::too_many_arguments)] + unsafe fn draw_image_in( + &self, + enc: &metal::RenderCommandEncoderRef, + path: &str, + dst: [f32; 4], + quad_px: [f32; 2], + radius_px: f32, + output_aspect: f32, ) -> Result<()> { // Emprunt isolé dans un `let` pour qu'il soit relâché AVANT le `borrow_mut` — // même piège que côté Windows (double emprunt RefCell à la première frame image). @@ -817,8 +902,10 @@ impl Compositor { self.draw_solid( enc, &LayerCB { - dst: [0.0, 0.0, 1.0, 1.0], + dst, src: [u0, v0, u1, v1], + quad_px, + radius_px, mode: 6.0, ..Default::default() }, @@ -826,7 +913,72 @@ impl Compositor { Ok(()) } - + /// Peint le fond du mode « personnalisé » DANS la bulle webcam, avant que la caméra n'y soit + /// découpée par-dessus. + /// + /// Le shader ne sait peindre qu'une couleur plate sous le masque, donc un dégradé ou une + /// image y tombaient sur du noir — et le défaut EST une image (`DEFAULT_WALLPAPER`), si bien + /// que le mode ne rendait jamais ce que le sélecteur montrait. Peindre le fond puis composer + /// la caméra en détourage donne exactement le même résultat (`lerp(fond, caméra, personne)`, + /// ici par le mélange alpha) pour les trois sortes de fond, en réutilisant les chemins déjà + /// éprouvés du fond d'écran, et sans rien ajouter aux trois shaders. + /// + /// `quad_px` / `radius_px` sont ceux de la bulle : le fond doit épouser ses coins arrondis, + /// sinon un rectangle déborde derrière la caméra. + unsafe fn draw_webcam_bg( + &self, + enc: &metal::RenderCommandEncoderRef, + bg: Option<&SceneBackground>, + dst: [f32; 4], + quad_px: [f32; 2], + radius_px: f32, + ) { + const BLACK: [f32; 4] = [0.0, 0.0, 0.0, 1.0]; + let solid = |color: [f32; 4]| LayerCB { + dst, + quad_px, + radius_px, + mode: 1.0, + color, + ..Default::default() + }; + match bg { + Some(SceneBackground::Color { color }) => { + self.draw_solid(enc, &solid(parse_hex(color).unwrap_or(BLACK))); + } + Some(SceneBackground::Gradient { angle_deg, stops }) => { + let c0 = stops.first().and_then(|s| parse_hex(s)).unwrap_or(BLACK); + let c1 = stops.last().and_then(|s| parse_hex(s)).unwrap_or(c0); + // angle CSS → direction unitaire, même convention que le fond d'écran. + let a = angle_deg.to_radians(); + self.draw_solid( + enc, + &LayerCB { + dst, + src: [c1[0], c1[1], c1[2], c1[3]], + quad_px, + radius_px, + mode: 5.0, + color: c0, + fx: [a.sin(), -a.cos(), 0.0, 0.0], + ..Default::default() + }, + ); + } + Some(SceneBackground::Image { path }) => { + // Même contrat que le fond d'écran : un chemin cassé est loggé puis remplacé par + // du noir. Un fallback silencieux redonnerait le bug qu'on corrige. + let aspect = if quad_px[1] > 0.0 { quad_px[0] / quad_px[1] } else { 1.0 }; + if let Err(e) = self.draw_image_in(enc, path, dst, quad_px, radius_px, aspect) { + eprintln!("[compositor] fond webcam \"{path}\" : {e:#}"); + self.draw_solid(enc, &solid(BLACK)); + } + } + // Personnalisé sans fond : noir, comme avant — mais c'est désormais le seul chemin + // qui y mène, au lieu de l'être pour toute image et tout dégradé. + None => self.draw_solid(enc, &solid(BLACK)), + } + } /// Une passe plein écran : `source` -> `target` avec `pipeline`, `fx` dans le LayerCB. /// Le viewport découle de la taille de l'attachement, donc pas de `RSSetViewports`. @@ -1208,6 +1360,363 @@ impl Compositor { } + /// Extrait la frame webcam en RGB8 à la résolution du modèle, dans `out`. + /// + /// Pendant Metal de `compositor_windows::capture_webcam_rgb`, avec les mêmes contraintes + /// d'appel et une seule divergence de mécanique : là où D3D11 réquisitionne la cible du + /// contexte persistant, Metal ouvre une passe sur `SegCapture::rt` et la referme, donc + /// rien n'est « réquisitionné ». La contrainte d'ordre reste malgré tout : cette méthode + /// **doit tourner avant que le command buffer de composition ne soit créé**, parce + /// qu'elle attend son propre buffer et qu'attendre au milieu d'une frame sérialiserait + /// CPU et GPU sur exactement le chemin que cette conception veut garder recouvert. + /// + /// `src` est le rect source en UV. L'appelant y passe la frame ENTIÈRE et non le + /// sous-rect dessiné — cf. `pump_segmentation`. + /// + /// # Le readback + /// + /// Trois étapes, la forme prescrite par l'en-tête du module et déjà tenue par + /// `render_nv12` + `read_nv12_scaled` : rendu dans une cible `Private`, blit vers un + /// miroir `Shared`, `get_bytes`. Pas de `Managed`, donc pas de `synchronizeResource` — + /// c'est le seul mode de stockage qui l'exigerait, et rien dans ce fichier n'en utilise. + /// + /// Le buffer `out` est réutilisé d'un appel à l'autre : il est dimensionné au RGBA lu + /// puis compacté sur place en RGB, ce qui laisse sa capacité au maximum des deux et ne + /// réalloue donc plus après la première capture. + pub unsafe fn capture_webcam_rgb( + &self, + wy: &metal::Texture, + wuv: &metal::Texture, + src: [f32; 4], + width: u32, + height: u32, + out: &mut Vec, + ) -> Result<()> { + if width == 0 || height == 0 { + return Err(anyhow!( + "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) { + *slot = Some(SegCapture { + rt: make_texture( + &self.gpu.device, + metal::MTLPixelFormat::RGBA8Unorm, + width, + height, + metal::MTLStorageMode::Private, + metal::MTLTextureUsage::RenderTarget | metal::MTLTextureUsage::ShaderRead, + ), + read: make_texture( + &self.gpu.device, + metal::MTLPixelFormat::RGBA8Unorm, + width, + height, + metal::MTLStorageMode::Shared, + metal::MTLTextureUsage::ShaderRead, + ), + width, + height, + }); + } + } + let slot = self.seg_capture.borrow(); + let cap = slot.as_ref().expect("créé juste au-dessus"); + + // Command buffer PROPRE, et surtout PAS `submit`/`sync` : `sync` attend `last_cmd`, + // et `read_nv12_scaled` compte sur `last_cmd` pour être le buffer de `render_nv12`. + // Le remplacer ici ferait attendre la capture au lieu de la conversion NV12, et le + // readback d'encodage lirait des plans que rien n'a encore écrits. + let cmd_buf = self.gpu.context.new_command_buffer(); + { + // Plein cadre de la cible, sans coins ni motion blur : le modèle veut l'image, + // pas la mise en forme. `fx` reste à zéro — la branche de masque du shader ne + // doit surtout pas se prendre sur la capture qui l'alimente. + let enc = self.begin_pass( + cmd_buf, + &cap.rt, + Some(metal::MTLClearColor::new(0.0, 0.0, 0.0, 1.0)), + &self.pipeline_main, + )?; + self.draw_video( + enc, + &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, + ); + enc.end_encoding(); + } + let blit = cmd_buf.new_blit_command_encoder(); + blit.copy_from_texture( + &cap.rt, + 0, + 0, + metal::MTLOrigin { x: 0, y: 0, z: 0 }, + metal::MTLSize { width: width as u64, height: height as u64, depth: 1 }, + &cap.read, + 0, + 0, + metal::MTLOrigin { x: 0, y: 0, z: 0 }, + ); + blit.end_encoding(); + cmd_buf.commit(); + cmd_buf.wait_until_completed(); + + let (w, h) = (width as usize, height as usize); + out.resize(w * h * 4, 0); + cap.read.get_bytes( + out.as_mut_ptr() as *mut std::ffi::c_void, + (w * 4) as u64, + metal::MTLRegion { + origin: metal::MTLOrigin { x: 0, y: 0, z: 0 }, + size: metal::MTLSize { width: w as u64, height: h as u64, depth: 1 }, + }, + 0, + ); + // RGBA → RGB sur place : le modèle n'a pas de canal alpha en entrée. La destination + // (`3i`) court derrière la source (`4i`), donc aucune écriture n'écrase un octet pas + // encore lu. + for i in 0..w * h { + let (r, g, b) = (out[i * 4], out[i * 4 + 1], out[i * 4 + 2]); + out[i * 3] = r; + out[i * 3 + 1] = g; + out[i * 3 + 2] = b; + } + out.truncate(w * h * 3); + Ok(()) + } + + /// Publie le masque de segmentation du sujet webcam (R8, `width`x`height`, 0 = fond). + /// + /// La texture est `Shared` et réécrite en place par `replace_region` ; elle n'est + /// recréée que si la résolution du modèle change, ce qui n'arrive pas en régime établi. + /// + /// Réécrire une texture que le GPU pourrait encore lire serait une course — ici il ne + /// le peut pas : les trois chemins de frame macOS drainent la file avant de rendre la + /// main (`readback_direct` et `rgb_to_nv12` font `submit` + `sync`, `read_nv12_scaled` + /// fait `sync`), donc plus rien n'est en vol quand `compose_frame` rappelle + /// `pump_segmentation`. C'est ce qui dispense d'un double buffer, pas la chance. + pub fn set_webcam_mask(&self, data: &[u8], width: u32, height: u32) -> Result<()> { + if width == 0 || height == 0 { + return Err(anyhow!("masque webcam de dimensions nulles ({width}x{height})")); + } + let expected = (width as usize) * (height as usize); + if data.len() < expected { + return Err(anyhow!( + "masque webcam trop court : {} octets pour {width}x{height}", + data.len() + )); + } + + let mut slot = self.webcam_mask.borrow_mut(); + if !matches!(slot.as_ref(), Some(m) if m.width == width && m.height == height) { + *slot = Some(WebcamMask { + tex: make_texture( + &self.gpu.device, + metal::MTLPixelFormat::R8Unorm, + width, + height, + metal::MTLStorageMode::Shared, + metal::MTLTextureUsage::ShaderRead, + ), + width, + height, + }); + } + let mask = slot.as_ref().expect("alloué juste au-dessus"); + mask.tex.replace_region( + metal::MTLRegion { + origin: metal::MTLOrigin { x: 0, y: 0, z: 0 }, + size: metal::MTLSize { width: width as u64, height: height as u64, depth: 1 }, + }, + 0, + data.as_ptr() as *const std::ffi::c_void, + width as u64, + ); + Ok(()) + } + + /// Un tour de segmentation : téléverse le masque prêt, puis soumet une nouvelle frame si + /// la cadence l'autorise. Port de `compositor_windows::pump_segmentation` — worker, + /// boîte aux lettres, limiteur de cadence et démarrage paresseux sont indépendants de la + /// plateforme, seuls les deux appels GPU changent. + /// + /// 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: &metal::Texture, + wuv: &metal::Texture, + 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() && self.seg_sync.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 lève le + // verrou plutôt que de journaliser 60 fois par seconde. + *self.seg_failed.borrow_mut() = true; + return Ok(()); + } + // En preview on rend cette frame sans masque : le worker vient de démarrer et + // l'effet apparaîtra dans quelques millisecondes, ce que personne ne voit. À + // l'export cette frame part dans le fichier — on enchaîne donc sur la capture et + // l'inférence plutôt que de la laisser sortir non détourée. + if !self.seg_deterministic.get() { + 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, + )?; + } + + // La cadence horloge est le bon réglage en preview et le mauvais à l'export, où les + // frames défilent aussi vite que la machine décode : le nombre de frames couvertes par + // un masque dépendrait alors de la charge. En déterministe, une inférence par frame. + if !self.seg_deterministic.get() + && !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 self.seg_deterministic.get() { + // Synchrone : le masque doit exister avant que cette frame ne soit composée, sinon + // on retombe sur le défaut qu'on corrige. Une inférence ratée laisse le masque + // précédent, comme le fait le worker. + let mut sync = self.seg_sync.borrow_mut(); + if let Some(seg) = sync.as_mut() { + match seg.run(&scratch) { + Ok(mask) => { + let mask = mask.to_vec(); + drop(sync); + self.set_webcam_mask( + &mask, + crate::segmentation::MODEL_WIDTH, + crate::segmentation::MODEL_HEIGHT, + )?; + } + Err(e) => eprintln!("[segmentation] frame ignorée : {e}"), + } + } + } else 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() || self.seg_sync.borrow().is_some() { + return Ok(()); + } + let segmenter = crate::segmentation::Segmenter::load(model_path)?; + // En déterministe, le segmenteur reste ici : l'inférence tourne sur le thread de rendu, + // donc le masque de la frame N est prêt AVANT qu'elle ne soit composée. Le worker est un + // choix de preview — ne jamais bloquer l'affichage — et c'est exactement ce qui rend + // l'export irreproductible, le masque arrivant quelques frames plus tard selon la charge. + if self.seg_deterministic.get() { + *self.seg_sync.borrow_mut() = Some(segmenter); + return Ok(()); + } + 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(()) + } + + /// Bascule la segmentation en mode reproductible, pour l'export. + /// + /// En preview, la cadence suit l'horloge (30 Hz réels) et l'inférence tourne sur un worker : + /// c'est le bon choix, l'affichage ne doit jamais attendre. À l'export les frames sont rendues + /// aussi vite que la machine décode, sans rapport avec le temps réel — et ces deux choix + /// deviennent alors des bugs. La cadence horloge fait dépendre le nombre de frames couvertes + /// par un masque de la vitesse de la machine, et le worker asynchrone rend les premières + /// frames AVANT que le premier masque n'existe : elles partent dans le fichier avec le vrai + /// arrière-plan de la webcam. Deux exports du même projet ne donnent donc pas les mêmes + /// pixels, ce qui casse l'invariant « l'export est identique à la preview ». + /// + /// En déterministe : une inférence PAR FRAME, synchrone. Plus coûteux (~3 ms/frame), mais + /// l'export est hors ligne et chaque frame porte le masque calculé depuis SA propre image. + /// + /// À appeler avant la première frame — c'est ce qui décide comment `enable_segmentation` + /// s'installe. + pub fn set_segmentation_deterministic(&self, on: bool) { + if self.seg_deterministic.get() == on { + return; + } + self.seg_deterministic.set(on); + // Changer de mode change le MOTEUR, et `enable_segmentation` est idempotent sur la + // PRÉSENCE d'un moteur : sans démonter celui qui ne correspond plus, le drapeau mentirait. + // Un compositeur qui a déjà servi en preview garderait son worker, `seg_sync` resterait + // vide, et l'export entier ne ferait AUCUNE inférence. Le démarrage paresseux de + // `pump_segmentation` réinstalle le bon moteur à la frame suivante. + *self.seg_worker.borrow_mut() = None; + *self.seg_sync.borrow_mut() = None; + // Et le masque que le worker démonté avait peut-être déjà déposé : il vient de l'autre + // mode, il n'a rien à faire sur la première frame de celui-ci. + *self.seg_inbox.lock().unwrap() = None; + } + + /// Éteint l'effet : la webcam se redessine telle quelle à la frame suivante. + pub fn clear_webcam_mask(&self) { + *self.webcam_mask.borrow_mut() = None; + } + /// Soumet sans attendre, et retient le buffer pour `sync`. fn submit(&self, cmd: &metal::CommandBufferRef) { cmd.commit(); @@ -1391,6 +1900,19 @@ impl Compositor { let u_max = scw / (stw.max(1)) as f32; let v_max = sch / (sth.max(1)) as f32; let (rw, rh) = (self.render_w as f32, self.render_h 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. `.max(1)` au dénominateur — + // `tex_dims` rend (0, 0) sur une webcam absente, là où le chemin Windows divise sans + // garde parce qu'il a toujours les deux frames. + let w_valid = [wcw / (wtw.max(1)) as f32, wch / (wth.max(1)) as f32]; + + // Segmentation, AVANT d'ouvrir le command buffer de composition : `capture_webcam_rgb` + // attend son propre buffer, et attendre au milieu de la frame sérialiserait CPU et GPU. + // Dernier point où `wtw/wth/wcw/wch` sont en portée sans emprunt de `self.scene` — + // `pump_segmentation` emprunte la scène lui-même. + if let Some((wy, wuv)) = webcam_tex.as_ref() { + self.pump_segmentation(wy, wuv, w_valid)?; + } let scene_ref = self.scene.borrow(); let cursor_ref = self.cursor.borrow(); @@ -1618,7 +2140,25 @@ impl Compositor { g.scene_preset.as_deref(), Some("dual-frame") | Some("vertical-stack") ); - if cfg.shadow && !webcam_is_block && g.shape_fade > 0.0 { + // 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); + + // 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. Le test porte + // 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); + if cfg.shadow && !webcam_is_block && !is_cutout && g.shape_fade > 0.0 { self.draw_shadow( enc, g.w_dst, @@ -1629,6 +2169,35 @@ impl Compositor { WEBCAM_SHADOW_OPACITY * g.shape_fade, ); } + + // Fond personnalisé : on PEINT le fond dans la bulle, puis on y découpe la caméra + // par-dessus — le mélange alpha donne `lerp(fond, caméra, personne)`, soit exactement + // 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. L'ordre est imposé : + // ombre, puis fond, puis caméra. + let (effect_code, blur_intensity) = match effect { + 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) + } + Some((code, e)) => (code, e.blur_intensity.clamp(0.0, 1.0)), + None => (0.0, 0.0), + }; + + // Metal tolère l'index 3 non lié tant que `fx.z` reste à 0 : la branche n'est + // pas prise, la texture n'est pas échantillonnée. Dès qu'il monte, elle doit + // l'être sur TOUT draw capable de la prendre — ici il n'y en a qu'un. L'état + // d'un encodeur est rémanent, donc lier avant le draw suffit, et l'ombre puis le + // fond qui précèdent sont en modes 1/2/5/6, que `ps_main` garde hors de la branche + // (`mode < 0.5`). + // + // Pas de déliaison après coup, contrairement au chemin Windows qui remet le slot + // t3 à `None` : cet état meurt avec l'encodeur, et les annotations en ouvrent un + // autre. Il n'y a rien sur quoi fuir. + if let Some(m) = mask.as_ref() { + enc.set_fragment_texture(3, Some(&m.tex)); + } self.draw_video( enc, &LayerCB { @@ -1637,7 +2206,10 @@ 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], + 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, 1.0, 1.0, 0.0], @@ -1935,7 +2507,718 @@ impl Compositor { #[cfg(test)] mod tests { - + use super::*; + + // ----------------------------------------------------------------------- + // Segmentation du sujet webcam + // + // Il n'y a PAS de banc hors Windows : `poc-d3d` est `cfg(windows)` dans son propre + // `Cargo.toml`, donc le `--cfg C8 --scene …` qui a prouvé le chemin Windows n'existe + // pas ici. Ce sont ces tests qui tiennent le rôle, et ils rendent de vrais pixels sur + // le device Metal du système plutôt que d'inspecter des champs : ce que le portage + // ajoute (une capture relue, un upload R8, une liaison à l'index 3, une branche + // `fx.z`) est précisément ce qu'aucun `cargo build` ne peut vérifier. + // ----------------------------------------------------------------------- + + /// Luma BT.709 limited d'un gris neutre : `yuv709_limited` fait `(Y - 16) / 219` sur + /// les trois canaux quand la chroma vaut 128, donc 235 rend du blanc franc et 16 du + /// noir franc. Ces deux valeurs rendent les assertions de couleur calculables à la main. + const Y_WHITE: u8 = 235; + const Y_BLACK: u8 = 16; + const UV_NEUTRAL: u8 = 128; + + fn region(w: u32, h: u32) -> metal::MTLRegion { + metal::MTLRegion { + origin: metal::MTLOrigin { x: 0, y: 0, z: 0 }, + size: metal::MTLSize { width: w as u64, height: h as u64, depth: 1 }, + } + } + + /// Une paire de plans NV12 synthétiques, sous forme de `MTLTexture` — ce que + /// `nv12_srvs` produirait d'une vraie frame, sans avoir à décoder quoi que ce soit. + fn nv12_textures( + device: &metal::Device, + w: u32, + h: u32, + luma: impl Fn(u32, u32) -> u8, + ) -> (metal::Texture, metal::Texture) { + let y = make_texture( + device, + metal::MTLPixelFormat::R8Unorm, + w, + h, + metal::MTLStorageMode::Shared, + metal::MTLTextureUsage::ShaderRead, + ); + let mut plane = vec![0u8; (w * h) as usize]; + for row in 0..h { + for col in 0..w { + plane[(row * w + col) as usize] = luma(col, row); + } + } + y.replace_region(region(w, h), 0, plane.as_ptr() as *const std::ffi::c_void, w as u64); + + let (uw, uh) = (w / 2, h / 2); + let uv = make_texture( + device, + metal::MTLPixelFormat::RG8Unorm, + uw, + uh, + metal::MTLStorageMode::Shared, + metal::MTLTextureUsage::ShaderRead, + ); + let chroma = vec![UV_NEUTRAL; (uw * uh * 2) as usize]; + uv.replace_region( + region(uw, uh), + 0, + chroma.as_ptr() as *const std::ffi::c_void, + (uw * 2) as u64, + ); + (y, uv) + } + + /// Masque 0 sur la moitié gauche, 255 sur la droite. La frontière tombe pile au milieu, + /// donc un échantillon pris au quart et un aux trois quarts sont loin du dégradé que le + /// filtrage linéaire pose sur la couture. + fn half_mask(w: u32, h: u32) -> Vec { + (0..w * h).map(|i| if i % w < w / 2 { 0u8 } else { 255u8 }).collect() + } + + #[test] + fn the_webcam_capture_comes_back_as_interleaved_rgb_at_model_resolution() { + let Ok(gpu) = crate::d3d::Gpu::create(false) else { + eprintln!("pas de device Metal — test sauté"); + return; + }; + let comp = super::Compositor::new_sized(&gpu, 320, 180).expect("Compositor::new_sized"); + // Moitié gauche noire, moitié droite blanche : la capture doit rendre les deux dans + // le bon sens. Une inversion d'axe passerait un test de taille sans se voir. + let (y, uv) = nv12_textures(&gpu.device, 64, 64, |col, _| { + if col < 32 { Y_BLACK } else { Y_WHITE } + }); + + let mut out = Vec::new(); + unsafe { + comp.capture_webcam_rgb( + &y, + &uv, + [0.0, 0.0, 1.0, 1.0], + crate::segmentation::MODEL_WIDTH, + crate::segmentation::MODEL_HEIGHT, + &mut out, + ) + .expect("capture_webcam_rgb"); + } + + let (w, h) = ( + crate::segmentation::MODEL_WIDTH as usize, + crate::segmentation::MODEL_HEIGHT as usize, + ); + assert_eq!(out.len(), w * h * 3, "le modèle veut du RGB8 entrelacé, sans alpha"); + + let px = |buf: &[u8], col: usize, row: usize| -> [u8; 3] { + let i = (row * w + col) * 3; + [buf[i], buf[i + 1], buf[i + 2]] + }; + let left = px(&out, w / 4, h / 2); + let right = px(&out, 3 * w / 4, h / 2); + assert!(left.iter().all(|&c| c < 24), "moitié gauche pas noire : {left:?}"); + assert!(right.iter().all(|&c| c > 231), "moitié droite pas blanche : {right:?}"); + + // Deuxième capture sur le même buffer : c'est le régime établi (30 fois par + // seconde), et il ne doit ni réallouer ni traîner les octets du tour précédent. + let capacity = out.capacity(); + unsafe { + comp.capture_webcam_rgb( + &y, + &uv, + [0.0, 0.0, 1.0, 1.0], + crate::segmentation::MODEL_WIDTH, + crate::segmentation::MODEL_HEIGHT, + &mut out, + ) + .expect("deuxième capture"); + } + assert_eq!(out.len(), w * h * 3); + assert_eq!(out.capacity(), capacity, "le scratch se réalloue d'une frame à l'autre"); + assert_eq!(px(&out, w / 4, h / 2), left); + assert_eq!(px(&out, 3 * w / 4, h / 2), right); + } + + #[test] + fn a_capture_of_zero_size_is_refused_rather_than_rendered() { + let Ok(gpu) = crate::d3d::Gpu::create(false) else { + eprintln!("pas de device Metal — test sauté"); + return; + }; + let comp = super::Compositor::new_sized(&gpu, 320, 180).expect("Compositor::new_sized"); + let (y, uv) = nv12_textures(&gpu.device, 16, 16, |_, _| Y_WHITE); + let mut out = Vec::new(); + let err = unsafe { comp.capture_webcam_rgb(&y, &uv, [0.0, 0.0, 1.0, 1.0], 0, 144, &mut out) }; + assert!(err.is_err(), "une cible de largeur nulle doit être refusée"); + } + + #[test] + fn the_mask_texture_is_allocated_once_and_a_short_buffer_is_refused() { + let Ok(gpu) = crate::d3d::Gpu::create(false) else { + eprintln!("pas de device Metal — test sauté"); + return; + }; + let comp = super::Compositor::new_sized(&gpu, 320, 180).expect("Compositor::new_sized"); + let (w, h) = (crate::segmentation::MODEL_WIDTH, crate::segmentation::MODEL_HEIGHT); + let mask = vec![255u8; (w * h) as usize]; + + comp.set_webcam_mask(&mask, w, h).expect("premier téléversement"); + let first = comp.webcam_mask.borrow().as_ref().map(|m| m.tex.as_ptr()); + comp.set_webcam_mask(&mask, w, h).expect("deuxième téléversement"); + let second = comp.webcam_mask.borrow().as_ref().map(|m| m.tex.as_ptr()); + assert_eq!( + first, second, + "la texture est recréée à chaque frame alors que la résolution du modèle est fixe" + ); + + // Un masque trop court doit être refusé, pas lu hors bornes : `replace_region` lit + // `width` octets par ligne sans rien savoir de la longueur de la tranche. + assert!(comp.set_webcam_mask(&mask[..(w * h) as usize - 1], w, h).is_err()); + assert!(comp.set_webcam_mask(&mask, 0, h).is_err()); + assert!(comp.clear_webcam_mask() == () && comp.webcam_mask.borrow().is_none()); + } + + /// Le test qui compte : le masque DÉCOUPE vraiment la caméra. + /// + /// Il rend le calque webcam plein cadre sur le RT avec `fx.z = 1` (détourage) et un + /// masque mi-fond mi-sujet, puis relit les pixels. Il couvre d'un coup les trois choses + /// que le portage ajoute et qu'aucune compilation ne vérifie : l'upload R8, la liaison + /// de la texture à l'index 3, et la branche `fx.z` de `ps_main` sur un vrai device. + #[test] + fn the_mask_actually_cuts_the_camera_out() { + let Ok(gpu) = crate::d3d::Gpu::create(false) else { + eprintln!("pas de device Metal — test sauté"); + return; + }; + let comp = super::Compositor::new_sized(&gpu, 64, 64).expect("Compositor::new_sized"); + comp.set_webcam_mask(&half_mask(8, 8), 8, 8).expect("set_webcam_mask"); + let (y, uv) = nv12_textures(&gpu.device, 16, 16, |_, _| Y_WHITE); + + // Fond bleu franc : une couleur que la caméra (blanche, chroma neutre) ne peut pas + // produire, donc « il reste du bleu » signifie « la caméra a été découpée ici ». + let cmd = gpu.context.new_command_buffer(); + let enc = comp + .begin_pass( + cmd, + &comp.rt, + Some(metal::MTLClearColor::new(0.0, 0.0, 1.0, 1.0)), + &comp.pipeline_main, + ) + .expect("begin_pass"); + { + let mask = comp.webcam_mask.borrow(); + enc.set_fragment_texture(3, Some(&mask.as_ref().expect("masque posé").tex)); + } + unsafe { + comp.draw_video( + enc, + &LayerCB { + dst: [0.0, 0.0, 1.0, 1.0], + src: [0.0, 0.0, 1.0, 1.0], + quad_px: [64.0, 64.0], + mode: 0.0, + color: [0.0, 0.0, 0.0, 1.0], + // fx.xy = étendue valide (toute la texture ici), fx.z = 1 → détourage. + fx: [1.0, 1.0, 1.0, 0.0], + src_prev: [0.0, 0.0, 1.0, 1.0], + dst_prev: [0.0, 0.0, 1.0, 1.0], + mb: [1.0, 1.0, 1.0, 0.0], + ..Default::default() + }, + &y, + &uv, + ); + } + enc.end_encoding(); + comp.submit(cmd); + let (rw, rh, rgba) = unsafe { comp.readback_direct().expect("readback_direct") }; + assert_eq!((rw, rh), (64, 64)); + + let px = |col: usize, row: usize| -> [u8; 4] { + let i = (row * rw as usize + col) * 4; + [rgba[i], rgba[i + 1], rgba[i + 2], rgba[i + 3]] + }; + let cut = px(16, 32); + let kept = px(48, 32); + assert_eq!(cut, [0, 0, 255, 255], "masque à 0 : le fond doit rester visible"); + assert_eq!(kept, [255, 255, 255, 255], "masque à 255 : la caméra doit rester opaque"); + } + + /// Même montage, mode fond personnalisé (`fx.z = 3`) : là où le masque dit « fond », le + /// shader doit peindre `color` — c'est le seul mode où `LayerCB::color` cesse d'être + /// du noir opaque décoratif et porte une valeur que le portage doit transmettre. + #[test] + fn the_custom_background_colour_replaces_the_masked_out_pixels() { + let Ok(gpu) = crate::d3d::Gpu::create(false) else { + eprintln!("pas de device Metal — test sauté"); + return; + }; + let comp = super::Compositor::new_sized(&gpu, 64, 64).expect("Compositor::new_sized"); + comp.set_webcam_mask(&half_mask(8, 8), 8, 8).expect("set_webcam_mask"); + let (y, uv) = nv12_textures(&gpu.device, 16, 16, |_, _| Y_WHITE); + + let cmd = gpu.context.new_command_buffer(); + let enc = comp + .begin_pass( + cmd, + &comp.rt, + Some(metal::MTLClearColor::new(0.0, 0.0, 0.0, 1.0)), + &comp.pipeline_main, + ) + .expect("begin_pass"); + { + let mask = comp.webcam_mask.borrow(); + enc.set_fragment_texture(3, Some(&mask.as_ref().expect("masque posé").tex)); + } + unsafe { + comp.draw_video( + enc, + &LayerCB { + dst: [0.0, 0.0, 1.0, 1.0], + src: [0.0, 0.0, 1.0, 1.0], + quad_px: [64.0, 64.0], + mode: 0.0, + color: [1.0, 0.0, 0.0, 1.0], + fx: [1.0, 1.0, 3.0, 0.0], + src_prev: [0.0, 0.0, 1.0, 1.0], + dst_prev: [0.0, 0.0, 1.0, 1.0], + mb: [1.0, 1.0, 1.0, 0.0], + ..Default::default() + }, + &y, + &uv, + ); + } + enc.end_encoding(); + comp.submit(cmd); + let (rw, _, rgba) = unsafe { comp.readback_direct().expect("readback_direct") }; + let px = |col: usize, row: usize| -> [u8; 4] { + let i = (row * rw as usize + col) * 4; + [rgba[i], rgba[i + 1], rgba[i + 2], rgba[i + 3]] + }; + assert_eq!(px(16, 32), [255, 0, 0, 255], "fond masqué : la couleur custom doit peindre"); + assert_eq!(px(48, 32), [255, 255, 255, 255], "sujet : la caméra doit rester intacte"); + } + + + // ----------------------------------------------------------------------- + // `compose_frame` de bout en bout + // + // Les tests ci-dessus prouvent les pièces ; ceux-ci prouvent le CÂBLAGE — que + // `compose_frame` porte bien `fx`/`color` sur le calque webcam, qu'il lie le masque, et + // qu'il ne lève `fx.z` qu'une fois un masque réellement téléversé. Ils passent par de + // vraies `AVFrame` VideoToolbox (des `CVPixelBufferRef` IOSurface-backed), donc par le + // MÊME `nv12_srvs` que le décodeur : aucun raccourci n'est pris sur le seam de frame. + // + // Aucun n'a besoin d'ONNX Runtime : le masque est posé à la main par `set_webcam_mask`. + // C'est délibéré — ce que le portage ajoute côté GPU doit être vérifiable là où + // l'inférence n'est pas installée, ce qui est le cas de la CI. + // ----------------------------------------------------------------------- + + /// Une `AVFrame` VideoToolbox synthétique. `compose_frame` ne lit que `format`, + /// `data[3]`, `width` et `height` : le reste peut rester à zéro. + struct FakeFrame { + frame: Box, + _pb: crate::mac_frames::CVPixelBufferRef, + } + + impl FakeFrame { + fn new(w: u32, h: u32, luma: impl Fn(u32, u32) -> u8) -> FakeFrame { + let mut y = vec![0u8; (w * h) as usize]; + for row in 0..h { + for col in 0..w { + y[(row * w + col) as usize] = luma(col, row); + } + } + FakeFrame::from_planes(w, h, &y, &vec![UV_NEUTRAL; (w * (h / 2)) as usize]) + } + + fn from_planes(w: u32, h: u32, y: &[u8], uv: &[u8]) -> FakeFrame { + let pb = crate::mac_frames::nv12_pixel_buffer_from_planes(w, h, y, uv) + .expect("CVPixelBuffer NV12"); + let mut frame: Box = Box::new(unsafe { std::mem::zeroed() }); + frame.format = crate::ffi::AVPixelFormat::AV_PIX_FMT_VIDEOTOOLBOX as i32; + frame.data[3] = pb.as_ptr() as *mut u8; + frame.width = w as i32; + frame.height = h as i32; + FakeFrame { frame, _pb: pb } + } + + fn as_ptr(&self) -> *const AVFrame { + &*self.frame as *const AVFrame + } + } + + /// Scène PiP minimale. `effect` est le JSON de `webcamEffect` (`"null"` pour aucun). + /// + /// `effects.shadow` vaut 0 À DESSEIN : ce curseur ne pilote plus que l'ombre de l'écran, + /// alors que celle du PiP est fixe (`WEBCAM_SHADOW_OPACITY`) et ne dépend que de + /// `cfg.shadow`. Le mettre à zéro est donc ce qui isole les deux — sinon un test sur + /// `cfg.shadow` mesure les deux ombres à la fois et ne dit plus rien de la caméra. + fn pip_scene_json(effect: &str) -> String { + format!( + r##"{{"clips":[], + "layout":{{"preset":"picture-in-picture","webcamSize":1,"webcamShape":"rectangle", + "webcamMirror":false,"webcamPosition":null,"webcamReactiveZoom":false}}, + "effects":{{"padding":0.18,"blur":false,"shadow":0,"roundnessFrac":0.05,"motionBlur":0}}, + "background":{{"kind":"color","color":"#0080ff"}}, + "zoomRegions":[],"annotations":[], + "cursor":{{"show":false,"size":1,"smoothing":0,"motionBlur":0,"clickBounce":0, + "clipToBounds":false,"theme":"default"}}, + "cropByClip":[], + "webcamEffect":{effect}, + "output":{{"width":1920,"height":1080,"fps":30}}}}"## + ) + } + + /// Compose une frame et rend le RGBA du RT. `screen` est gris moyen, `webcam` blanche : + /// le blanc franc devient alors la SIGNATURE de la caméra, une couleur qu'aucun autre + /// calque de cette scène ne produit, donc comptable sans connaître la géométrie du PiP. + /// + /// Le fond est un bleu franc et NON du noir : le PiP par défaut tombe dans la marge, hors + /// de l'écran, et une ombre noire sur un fond noir ne se voit pas — le contrôle du test + /// d'ombre passerait alors pour une suppression réussie. + fn compose_pip(comp: &super::Compositor, effect: &str, shadow: bool) -> Vec { + let scene = crate::scene::Scene::from_json(&pip_scene_json(effect)).expect("scene json"); + comp.set_live_params(live_params_from_scene(&scene)); + comp.set_has_webcam(true); + comp.set_scene(Some(scene)); + + let screen = FakeFrame::new(128, 128, |_, _| 126); + let webcam = FakeFrame::new(64, 64, |_, _| Y_WHITE); + let mut cfg = crate::config::Cfg::c8(); + cfg.bg_blur = false; + cfg.zoom = false; + cfg.layout_anim = false; + cfg.cursor = false; + cfg.mblur_n = 1; + cfg.shadow = shadow; + unsafe { + comp.compose_frame(screen.as_ptr(), webcam.as_ptr(), 0.0, &cfg) + .expect("compose_frame"); + let (_, _, rgba) = comp.readback_direct().expect("readback_direct"); + rgba + } + } + + /// Pixels quasi blancs = pixels de caméra encore visibles. + fn camera_pixels(rgba: &[u8]) -> usize { + rgba.chunks_exact(4) + .filter(|px| px[0] > 240 && px[1] > 240 && px[2] > 240) + .count() + } + + const NO_EFFECT: &str = "null"; + const CUTOUT: &str = r#"{"mode":"transparent","blurIntensity":0,"background":null,"modelPath":null}"#; + + /// Le piège que le brief nomme : un mode SANS masque ne doit rien changer. + /// + /// `effect_code` doit rester à 0 tant que rien n'a été segmenté, sinon le détourage rend + /// une webcam invisible sur les premières frames — le temps que l'inférence rende son + /// premier masque, c'est-à-dire à chaque ouverture de l'éditeur. L'assertion est + /// octet pour octet : « inchangé » ne souffre pas d'à-peu-près. + #[test] + fn a_mode_without_a_mask_composites_exactly_like_no_effect_at_all() { + let Ok(gpu) = crate::d3d::Gpu::create(false) else { + eprintln!("pas de device Metal — test sauté"); + return; + }; + let comp = super::Compositor::new_sized(&gpu, 320, 180).expect("Compositor::new_sized"); + let plain = compose_pip(&comp, NO_EFFECT, true); + let requested = compose_pip(&comp, CUTOUT, true); + assert!( + comp.webcam_mask.borrow().is_none(), + "aucun masque n'a été téléversé : `modelPath` est absent, donc rien ne segmente" + ); + assert!(camera_pixels(&plain) > 200, "la caméra n'est pas à l'écran, le test ne prouve rien"); + assert_eq!(plain, requested, "un mode sans masque a changé des pixels"); + } + + /// Et une fois le masque là, le détourage doit VRAIMENT découper — dans la bonne + /// proportion. Le masque couvre la moitié de la caméra, donc la moitié de ses pixels + /// doit disparaître. Compter plutôt que d'échantillonner un point évite de coder en dur + /// la géométrie du PiP, qui appartient à `plan_frame` et non à ce portage. + #[test] + fn compose_frame_cuts_the_camera_out_once_a_mask_exists() { + let Ok(gpu) = crate::d3d::Gpu::create(false) else { + eprintln!("pas de device Metal — test sauté"); + return; + }; + let comp = super::Compositor::new_sized(&gpu, 320, 180).expect("Compositor::new_sized"); + let whole = camera_pixels(&compose_pip(&comp, NO_EFFECT, true)); + assert!(whole > 200, "la caméra n'est pas à l'écran, le test ne prouve rien"); + + let (mw, mh) = (crate::segmentation::MODEL_WIDTH, crate::segmentation::MODEL_HEIGHT); + comp.set_webcam_mask(&half_mask(mw, mh), mw, mh).expect("set_webcam_mask"); + let cut = camera_pixels(&compose_pip(&comp, CUTOUT, true)); + + let expected = whole as f32 / 2.0; + assert!( + (cut as f32 - expected).abs() < expected * 0.15, + "détourage : {cut} pixels de caméra restants pour ~{expected:.0} attendus \ + (entier : {whole})" + ); + } + + /// L'ombre portée du PiP doit disparaître en détourage : une ombre projetée par un + /// rectangle devenu invisible se lit comme un artefact. Le test le prouve sans jamais + /// localiser l'ombre — en détourage, `cfg.shadow` ne doit plus rien changer du tout. + /// + /// Le contrôle est ce qui empêche l'assertion d'être vide : sans effet, `cfg.shadow` + /// DOIT changer des pixels, sinon la première moitié passerait aussi pour une scène où + /// aucune ombre n'a jamais été dessinée. + #[test] + fn the_pip_shadow_is_suppressed_in_cutout_mode() { + let Ok(gpu) = crate::d3d::Gpu::create(false) else { + eprintln!("pas de device Metal — test sauté"); + return; + }; + let comp = super::Compositor::new_sized(&gpu, 320, 180).expect("Compositor::new_sized"); + assert_ne!( + compose_pip(&comp, NO_EFFECT, true), + compose_pip(&comp, NO_EFFECT, false), + "contrôle : sans effet, l'ombre du PiP doit bel et bien se voir" + ); + + let (mw, mh) = (crate::segmentation::MODEL_WIDTH, crate::segmentation::MODEL_HEIGHT); + comp.set_webcam_mask(&half_mask(mw, mh), mw, mh).expect("set_webcam_mask"); + assert_eq!( + compose_pip(&comp, CUTOUT, true), + compose_pip(&comp, CUTOUT, false), + "en détourage, l'ombre est encore dessinée" + ); + } + + /// Le tour complet, celui qui a besoin d'ONNX Runtime : capture → inférence → masque → + /// composite, entraîné par `compose_frame` seul. Se saute proprement sans la + /// bibliothèque, ce que fait la CI — cf. `segmentation::runtime_available`. + #[test] + fn the_whole_loop_produces_a_mask_from_compose_frame_alone() { + if !crate::segmentation::runtime_available() { + eprintln!("ONNX Runtime absent (ORT_DYLIB_PATH) — test sauté"); + return; + } + let model = std::path::Path::new(env!("CARGO_MANIFEST_DIR")) + .join("../../public/mediapipe/selfie_segmentation/selfie_segmentation_landscape.onnx"); + if !model.is_file() { + eprintln!("modèle absent ({}) — test sauté", model.display()); + return; + } + let Ok(gpu) = crate::d3d::Gpu::create(false) else { + eprintln!("pas de device Metal — test sauté"); + return; + }; + let comp = super::Compositor::new_sized(&gpu, 320, 180).expect("Compositor::new_sized"); + let effect = format!( + r#"{{"mode":"transparent","blurIntensity":0,"background":null,"modelPath":{}}}"#, + serde_json::to_string(&model.to_string_lossy()).expect("chemin sérialisable") + ); + + // Le limiteur est à 30 Hz : une frame par tour ne suffirait pas, et l'inférence est + // asynchrone. On laisse au worker le temps de rendre un masque, sans jamais + // l'attendre dans le rendu — ce qui est précisément le contrat. + let mut uploaded = false; + for _ in 0..40 { + let _ = compose_pip(&comp, &effect, true); + if comp.webcam_mask.borrow().is_some() { + uploaded = true; + break; + } + std::thread::sleep(std::time::Duration::from_millis(40)); + } + assert!( + uploaded, + "aucun masque n'est remonté : la boucle capture → inférence → upload est rompue" + ); + assert!( + !*comp.seg_failed.borrow(), + "la segmentation s'est éteinte d'elle-même" + ); + } + + // ----------------------------------------------------------------------- + // Harnais visuel (opt-in) + // + // Les tests ci-dessus prouvent le mécanisme sur des images synthétiques, où le masque + // est posé à la main et donc trivialement juste. Ils ne peuvent rien dire de la QUALITÉ + // du masque que le modèle produit sur une vraie caméra — et « un masque qui composite » + // n'est pas la même affirmation que « un masque qui est correct ». + // + // `poc-d3d` étant `cfg(windows)`, il n'existe aucun banc ici pour trancher ça. Ceci en + // tient lieu : on lui donne une photo, il rend les quatre modes et écrit des PNG à + // regarder. Même forme d'opt-in que `tests/compose_linux.rs` (variable d'environnement + // + skip propre), et pour la même raison : ça rend sur GPU et ça lit un fichier que le + // dépôt ne porte pas. + // + // ``` + // ORT_DYLIB_PATH=/chemin/libonnxruntime.dylib \ + // OPENSCREEN_SEG_CAM=camera.png \ + // OPENSCREEN_SEG_VISUAL=target/seg \ + // cargo test -p openscreen-compositor --lib seg_visual -- --nocapture + // ``` + // ----------------------------------------------------------------------- + + /// RGB8 → NV12 BT.709 limited. Inverse EXACT de `yuv709_limited` dans `shaders.metal` : + /// une autre matrice ferait dériver les couleurs du rendu et on croirait à un bug du + /// compositeur là où il n'y aurait qu'une conversion d'entrée fausse. + #[allow(clippy::type_complexity)] + fn rgb_to_nv12(rgb: &[u8], w: u32, h: u32) -> (Vec, Vec) { + let luma = |i: usize| -> (f32, f32, f32, f32) { + let (r, g, b) = ( + rgb[i * 3] as f32 / 255.0, + rgb[i * 3 + 1] as f32 / 255.0, + rgb[i * 3 + 2] as f32 / 255.0, + ); + (r, g, b, 0.2126 * r + 0.7152 * g + 0.0722 * b) + }; + let mut y = vec![0u8; (w * h) as usize]; + for i in 0..(w * h) as usize { + let (_, _, _, yl) = luma(i); + y[i] = (16.0 + 219.0 * yl).round().clamp(0.0, 255.0) as u8; + } + // Chroma au plus proche voisin : l'échantillon en haut à gauche de chaque bloc 2x2. + // Un vrai filtre ne changerait rien à ce que ce harnais donne à voir. + let mut uv = vec![0u8; (w * (h / 2)) as usize]; + for row in 0..h / 2 { + for col in 0..w / 2 { + let (r, _, b, yl) = luma(((row * 2) * w + col * 2) as usize); + let cb = 128.0 + 224.0 * ((b - yl) / 1.8556); + let cr = 128.0 + 224.0 * ((r - yl) / 1.5748); + let o = (row * w + col * 2) as usize; + uv[o] = cb.round().clamp(0.0, 255.0) as u8; + uv[o + 1] = cr.round().clamp(0.0, 255.0) as u8; + } + } + (y, uv) + } + + fn frame_from_png(path: &std::path::Path) -> FakeFrame { + let img = image::open(path) + .unwrap_or_else(|e| panic!("{} : {e}", path.display())) + .to_rgb8(); + // NV12 veut des dimensions paires ; on rogne d'un pixel plutôt que de rééchantillonner. + let (w, h) = (img.width() & !1, img.height() & !1); + let src = img.as_raw(); + let mut rgb = vec![0u8; (w * h * 3) as usize]; + for row in 0..h { + let (d, s) = ((row * w * 3) as usize, (row * img.width() * 3) as usize); + rgb[d..d + (w * 3) as usize].copy_from_slice(&src[s..s + (w * 3) as usize]); + } + let (y, uv) = rgb_to_nv12(&rgb, w, h); + FakeFrame::from_planes(w, h, &y, &uv) + } + + #[test] + fn seg_visual_renders_the_four_modes_from_a_real_photo() { + let (Ok(out_dir), Ok(cam)) = ( + std::env::var("OPENSCREEN_SEG_VISUAL"), + std::env::var("OPENSCREEN_SEG_CAM"), + ) else { + eprintln!("harnais visuel : OPENSCREEN_SEG_VISUAL + OPENSCREEN_SEG_CAM absents — sauté"); + return; + }; + if !crate::segmentation::runtime_available() { + eprintln!("ONNX Runtime absent (ORT_DYLIB_PATH) — sauté"); + return; + } + let model = std::path::Path::new(env!("CARGO_MANIFEST_DIR")) + .join("../../public/mediapipe/selfie_segmentation/selfie_segmentation_landscape.onnx"); + let Ok(gpu) = crate::d3d::Gpu::create(false) else { + eprintln!("pas de device Metal — sauté"); + return; + }; + std::fs::create_dir_all(&out_dir).expect("dossier de sortie"); + + let (rw, rh) = (1280u32, 720u32); + let comp = super::Compositor::new_sized(&gpu, rw, rh).expect("Compositor::new_sized"); + let webcam = frame_from_png(std::path::Path::new(&cam)); + let screen = match std::env::var("OPENSCREEN_SEG_SCREEN") { + Ok(p) => frame_from_png(std::path::Path::new(&p)), + // Sans capture d'écran sous la main, un damier : il rend le détourage lisible, + // là où un aplat laisserait croire à un fond simplement peint. + Err(_) => FakeFrame::new(640, 360, |col, row| { + if (col / 40 + row / 40) % 2 == 0 { 180 } else { 60 } + }), + }; + let model_json = serde_json::to_string(&model.to_string_lossy()).expect("chemin"); + + let mut wrote = Vec::new(); + for (name, effect) in [ + ("00-none", "null".to_string()), + ("01-cutout", format!(r#"{{"mode":"transparent","blurIntensity":0,"background":null,"modelPath":{model_json}}}"#)), + ("02-blur", format!(r#"{{"mode":"blur","blurIntensity":0.8,"background":null,"modelPath":{model_json}}}"#)), + ("03-custom", format!(r##"{{"mode":"custom","blurIntensity":0,"background":{{"kind":"color","color":"#ff2d95"}},"modelPath":{model_json}}}"##)), + ] { + // Le masque arrive de façon asynchrone : on tourne jusqu'à ce qu'il soit là, ce + // qui est aussi une vérification en soi — la boucle du rendu ne l'attend jamais. + let mut rgba = Vec::new(); + for _ in 0..60 { + rgba = compose_visual(&comp, &screen, &webcam, &effect); + if effect == "null" || comp.webcam_mask.borrow().is_some() { + break; + } + std::thread::sleep(std::time::Duration::from_millis(30)); + } + let path = format!("{out_dir}/seg-{name}.png"); + image::RgbaImage::from_raw(rw, rh, rgba) + .expect("dimensions du readback") + .save(&path) + .unwrap_or_else(|e| panic!("écriture {path} : {e}")); + wrote.push(path); + } + for p in &wrote { + println!("wrote {p}"); + } + assert!( + comp.webcam_mask.borrow().is_some(), + "aucun masque n'a été produit : les trois modes d'effet sont sans objet" + ); + } + + /// Caméra plein cadre (`camera-fullscreen`… sans région : on force le rect via + /// `webcamRect`), pour que le masque occupe toute l'image et se juge à taille réelle. + fn compose_visual( + comp: &super::Compositor, + screen: &FakeFrame, + webcam: &FakeFrame, + effect: &str, + ) -> Vec { + let json = format!( + r##"{{"clips":[], + "layout":{{"preset":"picture-in-picture","webcamSize":1,"webcamShape":"rectangle", + "webcamMirror":false,"webcamPosition":null,"webcamReactiveZoom":false, + "webcamRect":{{"x":0.06,"y":0.10,"width":0.55,"height":0.72}}}}, + "effects":{{"padding":0.10,"blur":false,"shadow":1,"roundnessFrac":0.02,"motionBlur":0}}, + "background":{{"kind":"gradient","angleDeg":45,"stops":["#1b2a4a","#0b0f1a"]}}, + "zoomRegions":[],"annotations":[], + "cursor":{{"show":false,"size":1,"smoothing":0,"motionBlur":0,"clickBounce":0, + "clipToBounds":false,"theme":"default"}}, + "cropByClip":[], + "webcamEffect":{effect}, + "output":{{"width":1280,"height":720,"fps":30}}}}"## + ); + let scene = crate::scene::Scene::from_json(&json).expect("scene json"); + comp.set_live_params(live_params_from_scene(&scene)); + comp.set_has_webcam(true); + comp.set_scene(Some(scene)); + let mut cfg = crate::config::Cfg::c8(); + cfg.zoom = false; + cfg.layout_anim = false; + cfg.cursor = false; + cfg.mblur_n = 1; + unsafe { + comp.compose_frame(screen.as_ptr(), webcam.as_ptr(), 0.0, &cfg) + .expect("compose_frame"); + let (_, _, rgba) = comp.readback_direct().expect("readback_direct"); + rgba + } + } /// Le pendant macOS de `compositor_windows`'s `every_shader_entry_point_compiles`. /// diff --git a/crates/compositor/src/compositor_windows.rs b/crates/compositor/src/compositor_windows.rs index 51c3a3a28..af5b65d1b 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,26 @@ 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>, + /// Segmenteur tenu SUR LE THREAD DE RENDU, utilisé à la place du worker en mode + /// déterministe. Voir `set_segmentation_deterministic`. + seg_sync: RefCell>, + /// Export : cadence par frame et inférence synchrone, au lieu de l'horloge et du worker. + seg_deterministic: std::cell::Cell, + /// 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 +602,18 @@ 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_sync: RefCell::new(None), + seg_deterministic: std::cell::Cell::new(false), + 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), }) } @@ -768,6 +823,20 @@ impl Compositor { /// uploadé une fois (cache), puis échantillonné en mode 6. Err → l'appelant retombe sur une /// couleur plate. Le rect uv `src` recouvre toute la sortie en rognant le débordement. unsafe fn draw_image_bg(&self, path: &str, output_aspect: f32) -> Result<()> { + self.draw_image_in(path, [0.0, 0.0, 1.0, 1.0], [0.0, 0.0], 0.0, output_aspect) + } + + /// `draw_image_bg` pour un rect quelconque — la bulle webcam s'en sert avec ses coins + /// arrondis. `output_aspect` est le ratio du RECT visé, pas celui de la sortie : le crop + /// « cover » se calcule contre la zone qu'on remplit. + unsafe fn draw_image_in( + &self, + path: &str, + dst: [f32; 4], + quad_px: [f32; 2], + radius_px: f32, + output_aspect: f32, + ) -> Result<()> { // NB : la recherche est isolée dans un `let` pour que l'emprunt immuable soit relâché // AVANT le `borrow_mut()` (sinon double-emprunt RefCell → panic sur la 1re frame image). let cached = self.img_cache.borrow().get(path).cloned(); @@ -795,8 +864,10 @@ impl Compositor { (0.0, (1.0 - vis) * 0.5, 1.0, 1.0 - (1.0 - vis) * 0.5) }; self.upload_cb(&LayerCB { - dst: [0.0, 0.0, 1.0, 1.0], + dst, src: [u0, v0, u1, v1], + quad_px, + radius_px, mode: 6.0, ..Default::default() }); @@ -805,6 +876,70 @@ impl Compositor { Ok(()) } + /// Peint le fond du mode « personnalisé » DANS la bulle webcam, avant que la caméra n'y soit + /// découpée par-dessus. + /// + /// Le shader ne sait peindre qu'une couleur plate sous le masque, donc un dégradé ou une image + /// y tombaient sur du noir — et le défaut EST une image (`DEFAULT_WALLPAPER`), si bien que le + /// mode ne rendait jamais ce que le sélecteur montrait. Peindre le fond puis composer la + /// caméra en détourage donne exactement le même résultat (`lerp(fond, caméra, personne)`, ici + /// par le mélange alpha) pour les trois sortes de fond, en réutilisant les chemins déjà + /// éprouvés du fond d'écran, et sans rien ajouter aux trois shaders. + /// + /// `quad_px` / `radius_px` sont ceux de la bulle : le fond doit épouser ses coins arrondis, + /// sinon un rectangle déborde derrière la caméra. + unsafe fn draw_webcam_bg( + &self, + bg: Option<&SceneBackground>, + dst: [f32; 4], + quad_px: [f32; 2], + radius_px: f32, + ) { + const BLACK: [f32; 4] = [0.0, 0.0, 0.0, 1.0]; + let solid = |color: [f32; 4]| LayerCB { + dst, + quad_px, + radius_px, + mode: 1.0, + color, + ..Default::default() + }; + match bg { + Some(SceneBackground::Color { color }) => { + self.draw_solid(&solid(parse_hex(color).unwrap_or(BLACK))); + } + Some(SceneBackground::Gradient { angle_deg, stops }) => { + let c0 = stops.first().and_then(|s| parse_hex(s)).unwrap_or(BLACK); + let c1 = stops.last().and_then(|s| parse_hex(s)).unwrap_or(c0); + // angle CSS → direction unitaire, même convention que le fond d'écran. + let a = angle_deg.to_radians(); + let dir = [a.sin(), -a.cos()]; + self.draw_solid(&LayerCB { + dst, + quad_px, + radius_px, + src: [c1[0], c1[1], c1[2], c1[3]], + mode: 5.0, + color: c0, + fx: [dir[0], dir[1], 0.0, 0.0], + ..Default::default() + }); + } + Some(SceneBackground::Image { path }) => { + // Même contrat que le fond d'écran : un chemin cassé est loggé puis remplacé par + // du noir. Un fallback silencieux redonnerait le bug qu'on corrige. + let aspect = if quad_px[1] > 0.0 { quad_px[0] / quad_px[1] } else { 1.0 }; + if let Err(e) = self.draw_image_in(path, dst, quad_px, radius_px, aspect) { + eprintln!("[compositor] fond webcam \"{}\" : {:#}", path, e); + self.draw_solid(&solid(BLACK)); + } + } + // Personnalisé sans fond : noir, comme avant — mais c'est désormais le seul chemin + // qui y mène, au lieu de l'être pour toute image et tout dégradé. + None => self.draw_solid(&solid(BLACK)), + } + } + /// Décode un fichier image (jpg/png) → texture RGBA immuable + SRV. unsafe fn load_image_srv(&self, path: &str) -> Result<(ID3D11ShaderResourceView, u32, u32)> { // Les annotations image stockent une data URL (cf. `types.ts` : « Separate storage for @@ -846,6 +981,341 @@ 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() && self.seg_sync.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(()); + } + // En preview on rend cette frame sans masque : le worker vient de démarrer et + // l'effet apparaîtra dans quelques millisecondes, ce que personne ne voit. À + // l'export cette frame part dans le fichier — on enchaîne donc sur la capture et + // l'inférence plutôt que de la laisser sortir non détourée. + if !self.seg_deterministic.get() { + 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, + )?; + } + + // La cadence horloge est le bon réglage en preview et le mauvais à l'export, où les + // frames défilent aussi vite que la machine décode : le nombre de frames couvertes par + // un masque dépendrait alors de la charge. En déterministe, une inférence par frame. + if !self.seg_deterministic.get() + && !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 self.seg_deterministic.get() { + // Synchrone : le masque doit exister avant que cette frame ne soit composée, sinon + // on retombe sur le défaut qu'on corrige. Une inférence ratée laisse le masque + // précédent, comme le fait le worker. + let mut sync = self.seg_sync.borrow_mut(); + if let Some(seg) = sync.as_mut() { + match seg.run(&scratch) { + Ok(mask) => { + let mask = mask.to_vec(); + drop(sync); + self.set_webcam_mask( + &mask, + crate::segmentation::MODEL_WIDTH, + crate::segmentation::MODEL_HEIGHT, + )?; + } + Err(e) => eprintln!("[segmentation] frame ignorée : {e}"), + } + } + } else 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() || self.seg_sync.borrow().is_some() { + return Ok(()); + } + let segmenter = crate::segmentation::Segmenter::load(model_path)?; + // En déterministe, le segmenteur reste ici : l'inférence tourne sur le thread de rendu, + // donc le masque de la frame N est prêt AVANT qu'elle ne soit composée. Le worker est un + // choix de preview — ne jamais bloquer l'affichage — et c'est exactement ce qui rend + // l'export irreproductible, le masque arrivant quelques frames plus tard selon la charge. + if self.seg_deterministic.get() { + *self.seg_sync.borrow_mut() = Some(segmenter); + return Ok(()); + } + 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(()) + } + + /// Bascule la segmentation en mode reproductible, pour l'export. + /// + /// En preview, la cadence suit l'horloge (30 Hz réels) et l'inférence tourne sur un worker : + /// c'est le bon choix, l'affichage ne doit jamais attendre. À l'export les frames sont rendues + /// aussi vite que la machine décode, sans rapport avec le temps réel — et ces deux choix + /// deviennent alors des bugs. La cadence horloge fait dépendre le nombre de frames couvertes + /// par un masque de la vitesse de la machine, et le worker asynchrone rend les premières + /// frames AVANT que le premier masque n'existe : elles partent dans le fichier avec le vrai + /// arrière-plan de la webcam. Deux exports du même projet ne donnent donc pas les mêmes + /// pixels, ce qui casse l'invariant « l'export est identique à la preview ». + /// + /// En déterministe : une inférence PAR FRAME, synchrone. Plus coûteux (~3 ms/frame), mais + /// l'export est hors ligne et chaque frame porte le masque calculé depuis SA propre image. + /// + /// À appeler avant la première frame — c'est ce qui décide comment `enable_segmentation` + /// s'installe. + pub fn set_segmentation_deterministic(&self, on: bool) { + if self.seg_deterministic.get() == on { + return; + } + self.seg_deterministic.set(on); + // Changer de mode change le MOTEUR, et `enable_segmentation` est idempotent sur la + // PRÉSENCE d'un moteur : sans démonter celui qui ne correspond plus, le drapeau mentirait. + // Un compositeur qui a déjà servi en preview garderait son worker, `seg_sync` resterait + // vide, et l'export entier ne ferait AUCUNE inférence. Le démarrage paresseux de + // `pump_segmentation` réinstalle le bon moteur à la frame suivante. + *self.seg_worker.borrow_mut() = None; + *self.seg_sync.borrow_mut() = None; + // Et le masque que le worker démonté avait peut-être déjà déposé : il vient de l'autre + // mode, il n'a rien à faire sur la première frame de celui-ci. + *self.seg_inbox.lock().unwrap() = None; + } + + /// É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 +1567,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 +2014,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 +2031,38 @@ 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); + + // Fond personnalisé : on PEINT le fond dans la bulle, puis on y découpe la caméra + // par-dessus — le mélange alpha donne `lerp(fond, caméra, personne)`, soit exactement + // 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 { + 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) + } + Some((code, e)) => (code, e.blur_intensity.clamp(0.0, 1.0)), + None => (0.0, 0.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 +2070,10 @@ 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], + 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 +2082,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/mac_frames.rs b/crates/compositor/src/mac_frames.rs index ec2432637..0d04b3da5 100644 --- a/crates/compositor/src/mac_frames.rs +++ b/crates/compositor/src/mac_frames.rs @@ -375,3 +375,51 @@ impl Drop for CpuFrames { } } } + +/// Fabrique un `CVPixelBufferRef` NV12 IOSurface-backed et y écrit les deux plans donnés. +/// +/// Réservé aux tests, mais posé ICI plutôt que dans le module de test : `CVPixelBufferCreate`, +/// le dictionnaire d'attributs IOSurface/Metal et le verrou d'accès CPU sont déjà écrits +/// au-dessus, et les redéclarer ailleurs ferait vivre deux copies de la même FFI. +/// +/// `y` fait `w * h` octets, `uv` fait `w * (h / 2)` (Cb, Cr entrelacés, demi-résolution). +/// C'est exactement ce que `CVMetalTextureCache` sait présenter en `R8Unorm` + `RG8Unorm`, +/// donc ce que `Compositor::nv12_srvs` attend — la même route qu'une frame VideoToolbox. +#[cfg(test)] +pub(crate) fn nv12_pixel_buffer_from_planes( + w: u32, + h: u32, + y: &[u8], + uv: &[u8], +) -> Result { + let (w, h) = (w as usize, h as usize); + if y.len() < w * h || uv.len() < w * (h / 2) { + bail!( + "plans trop courts pour {w}x{h} : Y={} octets, UV={} octets", + y.len(), + uv.len() + ); + } + unsafe { + let pb = create_nv12_pixel_buffer(w, h)?; + if CVPixelBufferLockBaseAddress(pb.as_ptr(), 0) != 0 { + bail!("CVPixelBufferLockBaseAddress (fixture NV12)"); + } + let base_y = CVPixelBufferGetBaseAddressOfPlane(pb.as_ptr(), 0); + let pitch_y = CVPixelBufferGetBytesPerRowOfPlane(pb.as_ptr(), 0); + let base_uv = CVPixelBufferGetBaseAddressOfPlane(pb.as_ptr(), 1); + let pitch_uv = CVPixelBufferGetBytesPerRowOfPlane(pb.as_ptr(), 1); + if base_y.is_null() || base_uv.is_null() { + CVPixelBufferUnlockBaseAddress(pb.as_ptr(), 0); + bail!("plans nuls (fixture NV12)"); + } + for row in 0..h { + ptr::copy_nonoverlapping(y.as_ptr().add(row * w), base_y.add(row * pitch_y), w); + } + for row in 0..h / 2 { + ptr::copy_nonoverlapping(uv.as_ptr().add(row * w), base_uv.add(row * pitch_uv), w); + } + CVPixelBufferUnlockBaseAddress(pb.as_ptr(), 0); + Ok(pb) + } +} 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..ddc4dd381 --- /dev/null +++ b/crates/compositor/src/segmentation.rs @@ -0,0 +1,424 @@ +//! 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; + +/// La bibliothèque ONNX Runtime est-elle chargeable ? +/// +/// `ort` est lié en `load-dynamic` et **panique** quand la bibliothèque manque — +/// `load_dynamic::init(&path).expect("Failed to load ONNX Runtime dylib")`, ort/src/lib.rs. Ce +/// n'est pas une erreur qu'on peut propager : sans ce garde, un build où le staging de la lib +/// n'a pas eu lieu ferait tomber le compositeur à la première frame avec un effet, au lieu de +/// dessiner la webcam telle quelle. +#[cfg(feature = "segmentation")] +pub fn runtime_available() -> bool { + // `ORT_DYLIB_PATH` est ce que l'app pose (`ensureOnnxRuntimeOnPath`) ; sans lui, ort ira + // chercher un nom nu dans les chemins système, ce qui est le cas « pas installé ». + match std::env::var_os("ORT_DYLIB_PATH") { + Some(p) if Path::new(&p).is_file() => true, + _ => false, + } +} + +#[cfg(not(feature = "segmentation"))] +pub fn runtime_available() -> bool { + false +} + +/// 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()); + } + if !runtime_available() { + bail!( + "bibliothèque ONNX Runtime introuvable (ORT_DYLIB_PATH={:?}) — l'effet reste éteint", + std::env::var_os("ORT_DYLIB_PATH") + ); + } + // `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. + // Deuxième garde, pour le cas « le fichier est là mais ne se charge pas » (mauvaise + // architecture, dépendance manquante) : ort panique là aussi, et une panique qui + // traverse le thread de rendu tue la preview. + let session = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { + (|| -> 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(|_| anyhow::anyhow!("ONNX Runtime a paniqué au chargement — effet désactivé"))? + .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()); + } + + + #[cfg(feature = "segmentation")] + #[test] + fn a_missing_model_is_refused_before_the_runtime_is_even_touched() { + // Ce test-ci tourne PARTOUT : le chemin est vérifié avant tout appel à ort, ce qui + // est précisément la garantie qu'on veut (pas de panique sur une machine sans lib). + 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_missing_runtime_is_an_error_not_a_panic() { + if runtime_available() { + eprintln!("ONNX Runtime présent — le cas « absent » n'est pas exerçable ici"); + return; + } + // Le modèle EXISTE, donc on va bien jusqu'au garde du runtime. Sans lui, ort + // paniquerait et emporterait le thread de rendu. + match Segmenter::load(&vendored_model()) { + Ok(_) => panic!("chargement réussi sans bibliothèque ?"), + Err(e) => assert!( + e.to_string().contains("ONNX Runtime"), + "l'erreur doit nommer la bibliothèque manquante : {e}" + ), + } + } + + /// Les tests qui font tourner une vraie inférence n'ont de sens que là où la bibliothèque + /// est installée. La CI macOS et Linux ne la stage pas encore, et un test rouge pour ça + /// dirait quelque chose de faux sur le code. + #[cfg(feature = "segmentation")] + fn skip_without_runtime() -> bool { + if runtime_available() { + return false; + } + eprintln!("ONNX Runtime absent (ORT_DYLIB_PATH non posé) — test sauté"); + true + } + + #[cfg(feature = "segmentation")] + #[test] + fn a_frame_of_the_wrong_size_is_refused_rather_than_read_out_of_bounds() { + if skip_without_runtime() { + return; + } + 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() { + if skip_without_runtime() { + return; + } + 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() { + if skip_without_runtime() { + return; + } + 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..57c59fabb 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. @@ -78,6 +82,22 @@ float sd_round_rect(float2 p, float2 halfsz, float r) return length(max(q, 0.0)) + min(max(q.x, q.y), 0.0) - r; } +// Couverture du quad avec coins arrondis, pour les modes qui retournent AVANT la queue de +// `ps_main` (5 gradient, 6 image). Ils s'en passaient tant qu'ils ne servaient qu'au fond plein +// cadre, qui n'a pas de rayon ; depuis que la bulle webcam peut porter un dégradé ou une image, +// sans ça le fond déborde en carré opaque sur les coins arrondis de la bulle et mange l'ombre. +// Renvoie 1.0 quand aucun rayon n'est demandé — le fond plein cadre est donc inchangé. +float quad_round_alpha(float2 local, float2 quad_px, float radius_px) +{ + if (radius_px <= 0.0 || quad_px.x <= 0.0 || quad_px.y <= 0.0) + { + return 1.0; + } + float2 halfsz = quad_px * 0.5; + float d = sd_round_rect(local - halfsz, halfsz, radius_px); + return 1.0 - smoothstep(0.0, 1.5, d); // même feather ~1.5px que la queue +} + // Intersection de deux droites données par (normale, offset) : n·x = d. Cramer. float2 line_cross(float2 n1, float d1, float2 n2, float d2) { @@ -154,6 +174,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 @@ -352,7 +392,8 @@ float4 ps_main(VSOut i) : SV_Target // recouvrement), i.uv l'interpole. Opaque. if (mode > 5.5) { - return float4(texImg.Sample(samp, i.uv).rgb, 1.0); + float a = quad_round_alpha(i.local, quad_px, radius_px); + return float4(texImg.Sample(samp, i.uv).rgb * a, a); // prémultiplié } // mode 5 : gradient linéaire 2 stops (parité web wallpaper dégradé). color = stop0, @@ -362,9 +403,15 @@ float4 ps_main(VSOut i) : SV_Target { float2 dir = fx.xy; float denom = max(abs(dir.x) + abs(dir.y), 1e-4); - float t = saturate(0.5 + dot(i.pout - 0.5, dir) / denom); + // Paramétré sur le QUAD dès qu'il en a un (la bulle webcam), sinon sur la sortie. Pour le + // fond plein cadre les deux coïncident ; pour une bulle dans un coin, `pout` ne montrerait + // que la tranche du dégradé plein cadre qui passe dessous, jamais la rampe complète que + // le sélecteur affiche. + float2 gp = (quad_px.x > 0.0 && quad_px.y > 0.0) ? (i.local / quad_px) : i.pout; + float t = saturate(0.5 + dot(gp - 0.5, dir) / denom); float3 g = lerp(color.rgb, src.xyz, t); - return float4(g, 1.0); // opaque, prémultiplié (a=1) + float a = quad_round_alpha(i.local, quad_px, radius_px); + return float4(g * a, a); // prémultiplié } // mode 4 : curseur custom (dot + ring, dessiné depuis les maths). color = teinte. @@ -410,6 +457,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 +484,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..21d1e973a 100644 --- a/crates/compositor/src/shaders.metal +++ b/crates/compositor/src/shaders.metal @@ -151,6 +151,22 @@ inline float sd_round_rect(float2 p, float2 halfsz, float r) return length(max(q, 0.0)) + min(max(q.x, q.y), 0.0) - r; } +// Couverture du quad avec coins arrondis, pour les modes qui retournent AVANT la queue de +// `ps_main` (5 gradient, 6 image). Ils s'en passaient tant qu'ils ne servaient qu'au fond plein +// cadre, qui n'a pas de rayon ; depuis que la bulle webcam peut porter un dégradé ou une image, +// sans ça le fond déborde en carré opaque sur les coins arrondis de la bulle et mange l'ombre. +// Renvoie 1.0 quand aucun rayon n'est demandé — le fond plein cadre est donc inchangé. +inline float quad_round_alpha(float2 local, float2 quad_px, float radius_px) +{ + if (radius_px <= 0.0 || quad_px.x <= 0.0 || quad_px.y <= 0.0) + { + return 1.0; + } + float2 halfsz = quad_px * 0.5; + float d = sd_round_rect(local - halfsz, halfsz, radius_px); + return 1.0 - smoothstep(0.0, 1.5, d); // même feather ~1.5px que la queue +} + // Intersection de deux droites données par (normale, offset) : n·x = d. Cramer. inline float2 line_cross(float2 n1, float d1, float2 n2, float d2) { @@ -219,11 +235,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) @@ -344,7 +385,8 @@ fragment float4 ps_main(VSOut i [[stage_in]], // « le wallpaper est dessiné avec alpha 0 ». if (layer.mode > 5.5 && layer.mode < 6.5) { - return float4(texImg.sample(samp, i.uv).rgb, 1.0); + float a = quad_round_alpha(i.local, layer.quad_px, layer.radius_px); + return float4(texImg.sample(samp, i.uv).rgb * a, a); // prémultiplié } // mode 5 : gradient linéaire 2 stops (parité web wallpaper dégradé). color = stop0, @@ -357,9 +399,17 @@ fragment float4 ps_main(VSOut i [[stage_in]], { float2 dir = layer.fx.xy; float denom = max(abs(dir.x) + abs(dir.y), 1e-4); - float t = clamp(0.5 + dot(i.pout - 0.5, dir) / denom, 0.0, 1.0); + // Paramétré sur le QUAD dès qu'il en a un (la bulle webcam), sinon sur la sortie. Pour le + // fond plein cadre les deux coïncident ; pour une bulle dans un coin, `pout` ne montrerait + // que la tranche du dégradé plein cadre qui passe dessous, jamais la rampe complète que + // le sélecteur affiche. + float2 gp = (layer.quad_px.x > 0.0 && layer.quad_px.y > 0.0) + ? (i.local / layer.quad_px) + : i.pout; + float t = clamp(0.5 + dot(gp - 0.5, dir) / denom, 0.0, 1.0); float3 g = mix(layer.color.rgb, layer.src.xyz, t); - return float4(g, 1.0); // opaque, prémultiplié (a=1) + float a = quad_round_alpha(i.local, layer.quad_px, layer.radius_px); + return float4(g * a, a); // prémultiplié } // mode 4 : curseur dessiné (dot + ring SDF). @@ -467,6 +517,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 +542,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/timeline_walk.rs b/crates/compositor/src/timeline_walk.rs index 721fe7902..5d95a81db 100644 --- a/crates/compositor/src/timeline_walk.rs +++ b/crates/compositor/src/timeline_walk.rs @@ -164,6 +164,14 @@ pub(crate) unsafe fn walk_composited_timeline( let mut frames: u64 = 0; + // L'export doit être reproductible : deux rendus du même projet, les mêmes pixels. Cette + // boucle avance aussi vite que la machine décode, sans rapport avec le temps réel, alors que + // la segmentation est cadencée à l'horloge et calculée sur un worker — deux choix faits pour + // la preview, et qui deviennent ici des bugs : le nombre de frames couvertes par un masque + // suivrait la charge machine, et les premières frames sortiraient AVANT le premier masque, + // donc avec le vrai arrière-plan de la webcam gravé dans le fichier. + comp.set_segmentation_deterministic(true); + for (clip_index, clip) in clips.iter().enumerate() { // Le preset de layout est GLOBAL (un seul panneau pour toute la timeline) mais la // caméra est PAR CLIP : un projet mélange sans problème un enregistrement avec webcam @@ -329,6 +337,8 @@ pub(crate) unsafe fn walk_composited_timeline( comp.set_cursor_time(None); comp.set_timeline_time(None); + // Le compositeur est réutilisé par la preview après un export : lui rendre sa cadence. + comp.set_segmentation_deterministic(false); Ok(frames) } diff --git a/crates/compositor/src/vk_shaders/layer.wgsl b/crates/compositor/src/vk_shaders/layer.wgsl index 6fb2a73ed..972933814 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, @@ -79,6 +82,20 @@ fn sd_round_rect(p: vec2, halfsz: vec2, r: f32) -> f32 { return length(max(q, vec2(0.0))) + min(max(q.x, q.y), 0.0) - r; } +// Couverture du quad avec coins arrondis, pour le mode 6 qui retourne AVANT la queue de +// `fs_main`. Il s'en passait tant qu'il ne servait qu'au fond plein cadre, qui n'a pas de +// rayon ; depuis que la bulle webcam peut porter une image, sans ca le fond deborde en carre +// opaque sur les coins arrondis de la bulle et mange l'ombre. Renvoie 1.0 quand aucun rayon +// n'est demande -- le fond plein cadre est donc inchange. +fn quad_round_alpha(local: vec2, quad_px: vec2, radius_px: f32) -> f32 { + if radius_px <= 0.0 || quad_px.x <= 0.0 || quad_px.y <= 0.0 { + return 1.0; + } + let halfsz = quad_px * 0.5; + let d = sd_round_rect(local - halfsz, halfsz, radius_px); + return 1.0 - smoothstep(0.0, 1.5, d); // meme feather ~1.5px que la queue +} + // ---- Primitives du tilt 3D (modes 8 et 12), portees de `shaders.metal` ---- // SDF segment a bouts ronds. @@ -197,10 +214,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,13 +270,39 @@ 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; } else if layer.mode > 4.5 && layer.mode < 5.5 { // Mode 5 -- gradient lineaire : color (c0) -> src.rgb (c1) le long de // la direction fx.xy (sin, -cos de l'angle). Parite avec le HLSL/MSL. - let t = clamp(dot(i.pout - vec2(0.5), layer.fx.xy) + 0.5, 0.0, 1.0); + // `denom` : HLSL et MSL normalisent coin-a-coin (|dx|+|dy|) pour couvrir toute la + // diagonale. Il manquait ici, donc le meme degrade ne rendait pas pareil sur Linux. + let denom = max(abs(layer.fx.x) + abs(layer.fx.y), 1e-4); + // Parametre sur le QUAD des qu'il en a un (la bulle webcam), sinon sur la sortie. Pour + // le fond plein cadre les deux coincident ; pour une bulle dans un coin, `pout` ne + // montrerait que la tranche du degrade plein cadre qui passe dessous. + var gp = i.pout; + if layer.quad_px.x > 0.0 && layer.quad_px.y > 0.0 { + gp = i.local / layer.quad_px; + } + let t = clamp(0.5 + dot(gp - vec2(0.5), layer.fx.xy) / denom, 0.0, 1.0); rgb = mix(layer.color.rgb, layer.src.rgb, t); } else if layer.mode > 10.5 && layer.mode < 11.5 { // Mode 11 : texte. texY est l'atlas R8 (couverture alpha au canal .r, @@ -339,7 +401,8 @@ fn fs_main(i: VsOut) -> @location(0) vec4 { // Mode 6 -- fond image (wallpaper RGBA) cover-fit, echantillonne sur // texY. `src` porte le rect UV cover-fit (calcule cote Rust). Opaque : // le fond couvre tout le cadre. - return vec4(textureSample(texY, samp, i.uv).rgb, 1.0); + let bg_a = quad_round_alpha(i.local, layer.quad_px, layer.radius_px); + return vec4(textureSample(texY, samp, i.uv).rgb * bg_a, bg_a); // premultiplie } else if layer.mode > 7.5 && layer.mode < 8.5 { // Mode 8 -- ecran tilte (rotation 3D des zoom regions). Le quad projete est // dessine dans sa BBOX (le VS ne sait tracer qu'un rect) et chaque fragment @@ -427,7 +490,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-builder.json5 b/electron-builder.json5 index 66ee93af4..58f3e0742 100644 --- a/electron-builder.json5 +++ b/electron-builder.json5 @@ -97,6 +97,15 @@ { "from": "public/cursors", "to": "cursors" + }, + // The segmentation model, out of the asar for the same reason as the two above: the main + // process resolves it with `original-fs` (see realExistsSync in compositorViewService.ts), + // which cannot see inside app.asar, so a copy that ships only in `dist` resolves to null and + // the compositor draws the webcam unsegmented — the whole camera-background feature, silently + // off in every installer. 457 KB. + { + "from": "public/mediapipe", + "to": "mediapipe" } ], diff --git a/electron/native-bridge/services/compositorViewService.test.ts b/electron/native-bridge/services/compositorViewService.test.ts index 1569816af..29f492b10 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" } }); @@ -307,6 +338,57 @@ describe("resolveSceneAssetPaths", () => { expect(fs.existsSync(out.background.path)).toBe(true); }); + // The camera's own background under the "custom" mode. It was NOT resolved, and the failure + // was silent and total: the compositor got "/wallpapers/wallpaper1.jpg", could not open it, + // and painted the bubble black — behind every one of the 18 bundled wallpapers, including + // the default. The screen's background had the fix; this one was simply missed. + it("resolves the camera's custom background, not just the screen's", () => { + const out = resolved({ + webcamEffect: { + mode: "custom", + background: { kind: "image", path: "/wallpapers/wallpaper1.jpg" }, + }, + }); + + expect(out.webcamEffect.background.path).toBe( + path.join(resources, "wallpapers", "wallpaper1.jpg"), + ); + expect(out.webcamEffect.background.path).not.toContain("app.asar"); + expect(fs.existsSync(out.webcamEffect.background.path)).toBe(true); + }); + + // The two are independent: a scene can put a wallpaper behind the screen and a different one + // behind the camera, and resolving one must not depend on the other being present. + it("resolves both backgrounds in the same scene", () => { + const out = resolved({ + background: { kind: "image", path: "/wallpapers/wallpaper1.jpg" }, + webcamEffect: { + mode: "custom", + background: { kind: "image", path: "/wallpapers/wallpaper1.jpg" }, + }, + }); + + expect(out.background.path).toBe(path.join(resources, "wallpapers", "wallpaper1.jpg")); + expect(out.webcamEffect.background.path).toBe(out.background.path); + }); + + // A colour or a gradient carries no path, and the compositor renders both itself. Rewriting + // them would be a bug, not a no-op. + it("leaves a colour or gradient camera background untouched", () => { + const colour = resolved({ + webcamEffect: { mode: "custom", background: { kind: "color", color: "#ff0080" } }, + }); + expect(colour.webcamEffect.background).toEqual({ kind: "color", color: "#ff0080" }); + + const gradient = resolved({ + webcamEffect: { + mode: "custom", + background: { kind: "gradient", angleDeg: 90, stops: ["#000", "#fff"] }, + }, + }); + expect(gradient.webcamEffect.background.stops).toEqual(["#000", "#fff"]); + }); + it("resolves a cursor theme's arrow sprite to a path that exists on disk", () => { if (!themed) return; // no bundled theme ships an arrow override const arrow = resolved({ cursor: { theme: themed.id } }).cursor.cursorSprites.arrow; diff --git a/electron/native-bridge/services/compositorViewService.ts b/electron/native-bridge/services/compositorViewService.ts index 9efc302ec..1a61bd696 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,21 +127,50 @@ export function resolveSceneAssetPaths(sceneJson: string): string { theme?: string; cursorSprites?: Record; }; + webcamEffect?: { + mode?: string; + modelPath?: string; + background?: { kind?: string; path?: string }; + }; }; let changed = false; - const bg = scene.background; - if (bg?.kind === "image" && typeof bg.path === "string" && bg.path.startsWith("/")) { + // Both backgrounds go through this: the screen's, and the camera's under the "custom" + // mode. The camera one was missed, and the failure is silent — the compositor gets + // "/wallpapers/wallpaper1.jpg", `image::open` cannot find it, and the PiP falls back to + // a flat colour with only a line on stderr to say so. + const resolveBackgroundImage = (target?: { kind?: string; path?: string }): boolean => { + if ( + target?.kind !== "image" || + typeof target.path !== "string" || + !target.path.startsWith("/") + ) { + return false; + } // strip the leading slash so path.join keeps it under the base dir - const resolved = resolveSceneAssetPath(bg.path.replace(/^\/+/, "")); - if (resolved) { - bg.path = resolved; - changed = true; + const resolved = resolveSceneAssetPath(target.path.replace(/^\/+/, "")); + if (!resolved) { + return false; } - } + target.path = resolved; + return true; + }; + changed = resolveBackgroundImage(scene.background) || changed; + changed = resolveBackgroundImage(scene.webcamEffect?.background) || changed; if (scene.cursor && typeof scene.cursor.theme === "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 +369,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 +447,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 a8c2c6c3f..b046e5422 100644 --- a/nix/package.nix +++ b/nix/package.nix @@ -51,7 +51,7 @@ buildNpmPackage { ); }; - npmDepsHash = "sha256-sp1UlXIUZ4z03LRV36+yN0Op8pu/qPROU7sI15LgyTg="; + npmDepsHash = "sha256-mnI1d8HyZdaCiYolAd8VvBSlSdEsZT89Rf0ADVm9Bf8="; env.ELECTRON_SKIP_BINARY_DOWNLOAD = "1"; diff --git a/package-lock.json b/package-lock.json index 745a74e57..089d62674 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/package.json b/package.json index 056e4aecb..24891039d 100644 --- a/package.json +++ b/package.json @@ -42,16 +42,16 @@ "assets:appx": "node scripts/generate-appx-assets.mjs", "preview": "vite preview", "build:native:mac": "node scripts/build-macos-screencapturekit-helper.mjs", - "build:mac": "npm run build:native:mac && npm run fetch:ffmpeg:mac && npm run build:native:compositor:mac && tsc && vite build && electron-builder --mac", + "build:mac": "npm run build:native:mac && npm run fetch:ffmpeg:mac && npm run fetch:onnxruntime && npm run build:native:compositor:mac && tsc && vite build && electron-builder --mac", "build:native:win": "node scripts/build-windows-wgc-helper.mjs", "stage:vcomp": "node scripts/stage-vcomp-runtime.mjs", "build:native:compositor": "node scripts/build-windows-compositor-addon.mjs", "build:native:compositor:mac": "node scripts/build-macos-compositor-addon.mjs", "build:native:compositor:linux": "node scripts/build-linux-compositor-addon.mjs", "build:native:linux": "node scripts/build-linux-pipewire-helper.mjs", - "build:win": "npm run build:native:win && npm run fetch:ffmpeg && npm run stage:vcomp && npm run build:native:compositor && tsc && vite build && electron-builder --win --config.npmRebuild=false", - "build:win:store": "npm run build:native:win && npm run fetch:ffmpeg && npm run stage:vcomp && npm run build:native:compositor && tsc && vite build && electron-builder --win appx --config.npmRebuild=false", - "build:linux": "npm run fetch:ffmpeg:sdk && npm run build:native:linux && npm run build:native:compositor:linux && tsc && vite build && electron-builder --linux AppImage deb pacman rpm --config.npmRebuild=false", + "build:win": "npm run build:native:win && npm run fetch:ffmpeg && npm run fetch:onnxruntime && npm run stage:vcomp && npm run build:native:compositor && tsc && vite build && electron-builder --win --config.npmRebuild=false", + "build:win:store": "npm run build:native:win && npm run fetch:ffmpeg && npm run fetch:onnxruntime && npm run stage:vcomp && npm run build:native:compositor && tsc && vite build && electron-builder --win appx --config.npmRebuild=false", + "build:linux": "npm run fetch:ffmpeg:sdk && npm run build:native:linux && npm run fetch:onnxruntime && npm run build:native:compositor:linux && tsc && vite build && electron-builder --linux AppImage deb pacman rpm --config.npmRebuild=false", "build:whisper-binaries": "bash scripts/build-whisper-stt.sh", "test:whisper-stt": "node scripts/test-whisper-stt.mjs", "test": "vitest --run", @@ -92,7 +92,8 @@ "prepare": "husky", "fetch:ffmpeg": "node scripts/fetch-ffmpeg.mjs", "fetch:ffmpeg:mac": "node scripts/fetch-ffmpeg-macos.mjs", - "fetch:ffmpeg:sdk": "node scripts/fetch-ffmpeg.mjs --sdk-only" + "fetch:ffmpeg:sdk": "node scripts/fetch-ffmpeg.mjs --sdk-only", + "fetch:onnxruntime": "node scripts/fetch-onnxruntime.mjs" }, "dependencies": { "@fix-webm-duration/fix": "^1.0.1", 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/scripts/fetch-onnxruntime.mjs b/scripts/fetch-onnxruntime.mjs new file mode 100644 index 000000000..791e38656 --- /dev/null +++ b/scripts/fetch-onnxruntime.mjs @@ -0,0 +1,307 @@ +// Provisions the ONNX Runtime shared library into +// electron/native/bin/-/, next to the compositor addon and the +// ffmpeg libraries. That directory is gitignored and shipped by electron-builder's +// extraResources, so this runs at build time rather than committing a 15-38 MB binary. +// +// WHY IT SHIPS: the native compositor segments the webcam subject on the CPU +// execution provider (crates/compositor/src/segmentation.rs), and `ort` is linked +// with `load-dynamic` — nothing is needed to BUILD, but at runtime +// `ensureOnnxRuntimeOnPath` (electron/native-bridge/services/compositorViewService.ts) +// walks this exact directory looking for the library and sets ORT_DYLIB_PATH to it. +// Without it `Segmenter::load` fails, the compositor logs one line and draws the +// webcam unsegmented — so the AI background cutout/blur/custom modes are simply off. +// Everything degrades; nothing breaks. That is why this script never fails a build. +// +// VERSION IS NOT FREE TO MOVE. crates/Cargo.toml pins `ort` with feature `api-NN`, +// which is the MINIMUM ONNX Runtime minor version the crate will accept — a lower +// one makes `GetApi` return null and `ort` panics rather than erroring. The pin here +// must satisfy that, and scripts/fetch-onnxruntime.test.mjs cross-checks the two so +// a bump on either side cannot land alone. +// +// SUPPLY CHAIN. This binary is signed and shipped to every user, so nothing floats: +// - Pinned to an immutable release tag, never `latest`. +// - SHA-256 verified before the archive is opened. The digests below are the ones +// GitHub publishes per asset (`digest` in the releases API), independently +// re-verified by downloading and hashing. +// - Only the plain CPU assets. The `gpu_cuda*` variants are 200-320 MB and pull +// NVIDIA runtime dependencies we neither need nor may redistribute; the measured +// decision to use the CPU EP is in +// technical-documentation/engineering/webcam-segmentation.md. +// +// LICENSING: ONNX Runtime is MIT, which is compatible with this MIT app — but the +// archive is checked rather than trusted, the same way fetch-ffmpeg.mjs verifies +// ffmpeg's LGPL-ness instead of believing the asset name. Attribution ships in +// THIRD-PARTY-NOTICES.md. + +import { spawnSync } from "node:child_process"; +import crypto from "node:crypto"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const ROOT = path.join(__dirname, ".."); + +/** + * The pinned release. Asset names are DERIVED from it rather than written out per + * entry, which is deliberate: fetch-ffmpeg.mjs keeps full asset strings and grew a + * test because a re-pin moved some and not others. Templating removes that failure + * mode by construction. The digests still have to move by hand — but a stale one + * fails loudly on the SHA-256 check before anything is extracted, which is the safe + * direction to fail in. + */ +const VERSION = "1.27.1"; +const BASE = `https://github.com/microsoft/onnxruntime/releases/download/v${VERSION}`; + +/** + * Per-target: the upstream artifact slug, its digest, and the library to lift out. + * + * `out` is not cosmetic — it is the exact name `ortLibName()` looks for in + * compositorViewService.ts. `member` is the file inside the archive, which on macOS + * and Linux is the VERSIONED real file rather than the unversioned symlink beside + * it: tar restores that symlink as a symlink, and a dangling one in the packaged app + * would resolve to nothing. + * + * darwin-x64 is absent and cannot be added: Microsoft publishes no `osx-x86_64` + * (or universal) asset for any release from 1.27 on — arm64 is the only macOS + * target. Building it from source is the only way to change that, and it is not + * worth an ffmpeg-macos-sized build script for a shrinking platform when the + * fallback is "the effect is off". See the darwin-x64 branch in main(). + */ +const PINNED = { + "win32-x64": { + slug: "win-x64", + ext: "zip", + sha256: "2e00414a63fdef0914cd5a5ede6c707844878e0c08e1b6693842f0451b2df2a1", + member: "onnxruntime.dll", + out: "onnxruntime.dll", + }, + "win32-arm64": { + slug: "win-arm64", + ext: "zip", + sha256: "6e22c2061ba6400b42a59663d700c8694e4e8fe654cf452c4700c24237407ae1", + member: "onnxruntime.dll", + out: "onnxruntime.dll", + }, + "darwin-arm64": { + slug: "osx-arm64", + ext: "tgz", + sha256: "e42b77a7281cc6e55141bf44fcfbac2c782b823a491bbb6ac33c781dd991f8a6", + member: `libonnxruntime.${VERSION}.dylib`, + out: "libonnxruntime.dylib", + }, + // Linux is wired into `build:linux` since its back-end gained the capture half + // (`capture_webcam_rgb` + `set_webcam_mask` in `compositor_linux.rs`). Until + // then this entry existed but was deliberately unused: the back-end carried the + // segmentation SHADER only, so `fx.z` never left 0 and the library would have + // been 23 MB of installer for a code path that could not run. + // See technical-documentation/engineering/webcam-segmentation-backend-port.md. + "linux-x64": { + slug: "linux-x64", + ext: "tgz", + sha256: "25b1ef1fea1acd210d63f8f24dc870ad6e077795ce1f54876252c6d3803c15af", + member: `libonnxruntime.so.${VERSION}`, + out: "libonnxruntime.so", + }, + "linux-arm64": { + slug: "linux-aarch64", + ext: "tgz", + sha256: "33c67e33d1e25b816878366ea276589a024f71f000e7ff955c4b33224d639edd", + member: `libonnxruntime.so.${VERSION}`, + out: "libonnxruntime.so", + }, +}; + +const assetName = (spec) => `onnxruntime-${spec.slug}-${VERSION}.${spec.ext}`; + +/** Magic bytes the vendored library must start with, per target platform. */ +const MAGIC = { + win32: { bytes: [0x4d, 0x5a], name: "PE (MZ)" }, // .dll + darwin: { bytes: [0xcf, 0xfa, 0xed, 0xfe], name: "Mach-O 64" }, // .dylib + linux: { bytes: [0x7f, 0x45, 0x4c, 0x46], name: "ELF" }, // .so +}; + +function run(cmd, args, opts = {}) { + return spawnSync(cmd, args, { stdio: "inherit", ...opts }); +} + +function tarBin() { + if (process.platform !== "win32") return "tar"; + const sys32 = path.join(process.env.SystemRoot ?? "C:\\Windows", "System32", "tar.exe"); + return fs.existsSync(sys32) ? sys32 : "tar"; +} + +function extract(archive, destDir) { + fs.mkdirSync(destDir, { recursive: true }); + // Run from destDir with a bare filename: given an absolute Windows path, tar + // reads "C:\..." as host:path and tries to resolve a host called C. + const r = run(tarBin(), [archive.endsWith(".zip") ? "-xf" : "-xzf", path.basename(archive)], { + cwd: destDir, + }); + if (r.status !== 0) throw new Error(`tar failed to extract ${path.basename(archive)}`); +} + +/** Depth-first search for a file by exact basename. */ +function find(dir, name) { + for (const entry of fs.readdirSync(dir, { withFileTypes: true })) { + const p = path.join(dir, entry.name); + if (entry.isDirectory()) { + const hit = find(p, name); + if (hit) return hit; + } else if (entry.name === name) { + return p; + } + } + return null; +} + +/** + * Refuses anything that is not the MIT ONNX Runtime we pinned. + * + * Three independent checks, because the digest alone only proves we got the archive + * we asked for — it says nothing about having lifted the RIGHT FILE out of it, which + * is where a re-pin actually goes wrong (a renamed member silently vendors a 20 KB + * provider stub, and the failure surfaces as "the effect does nothing" months later). + * + * 1. the archive's LICENSE really is MIT — asset names are not evidence; + * 2. the library is a binary of the expected format for the target platform; + * 3. it carries the pinned version string, which is what `GetVersionString()` + * returns and what `ort` compares against its `api-NN` floor. + */ +function verify(libPath, licensePath, targetPlatform) { + const license = fs.readFileSync(licensePath, "utf8"); + if (!/^MIT License/m.test(license)) { + throw new Error( + `${path.basename(licensePath)} does not begin with "MIT License".\n` + + "Refusing to vendor: ONNX Runtime is MIT and this app is MIT — a relicensed\n" + + "upstream is a decision for a human, not a build script.", + ); + } + + const buf = fs.readFileSync(libPath); + const magic = MAGIC[targetPlatform]; + if (!magic.bytes.every((b, i) => buf[i] === b)) { + const got = [...buf.subarray(0, 4)].map((b) => b.toString(16).padStart(2, "0")).join(" "); + throw new Error( + `${path.basename(libPath)} is not a ${magic.name} binary (starts with ${got}).\n` + + "The archive layout probably changed under the pin — check `member`.", + ); + } + + // The version lives in the binary as a plain NUL-terminated string. + if (!buf.includes(Buffer.from(`\0${VERSION}\0`, "latin1"))) { + throw new Error( + `${path.basename(libPath)} does not carry the version string ${VERSION}.\n` + + "Either the pin and the digest disagree, or the wrong member was extracted.", + ); + } + + return `MIT ONNX Runtime ${VERSION}, ${magic.name}, ${(buf.length / 1048576).toFixed(1)} MB`; +} + +async function download(spec) { + const asset = assetName(spec); + const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "openscreen-ort-")); + console.log(`Downloading ${asset}\n from v${VERSION}`); + const res = await fetch(`${BASE}/${asset}`); + if (!res.ok) throw new Error(`Download failed: ${res.status} ${res.statusText}`); + const bytes = Buffer.from(await res.arrayBuffer()); + + // Before opening it: is this the exact artifact we pinned? + const got = crypto.createHash("sha256").update(bytes).digest("hex"); + if (got !== spec.sha256) { + fs.rmSync(tmp, { recursive: true, force: true }); + throw new Error( + `SHA-256 mismatch for ${asset}\n expected ${spec.sha256}\n got ${got}\n` + + "Refusing to extract. Either the pin is stale or the artifact changed under it.", + ); + } + console.log(` sha256 ok (${(bytes.length / 1048576).toFixed(0)} MB)`); + + const archive = path.join(tmp, asset); + fs.writeFileSync(archive, bytes); + extract(archive, tmp); + return tmp; +} + +async function main() { + // `--target` exists for CI, which provisions for the runner it is on; without it + // the host is the target, which is what every local build wants. + const targetArg = process.argv.find((a) => a.startsWith("--target=")); + const tag = targetArg + ? targetArg.slice("--target=".length) + : `${process.platform}-${process.arch}`; + const [targetPlatform] = tag.split("-"); + + // Not an error, and deliberately exit 0: `build:mac` runs on an Intel runner for + // the x64 DMG, and there is no upstream library to give it. Failing here would + // break a release build over a feature that is designed to be absent gracefully. + if (tag === "darwin-x64") { + console.log( + "ONNX Runtime is not provisioned for darwin-x64: Microsoft publishes no\n" + + "osx-x86_64 (or universal) asset for 1.27 or later — arm64 is the only macOS\n" + + "target. The webcam background effects are therefore OFF on Intel Macs; the\n" + + "compositor logs one line and draws the camera unsegmented. Nothing else changes.", + ); + return; + } + + const spec = PINNED[tag]; + if (!spec) { + console.log( + `No pinned ONNX Runtime for ${tag} — skipping. Have: ${Object.keys(PINNED).join(", ")}`, + ); + return; + } + if (!MAGIC[targetPlatform]) { + throw new Error(`Unknown target platform in --target=${tag}`); + } + + const binDir = path.join(ROOT, "electron", "native", "bin", tag); + const dest = path.join(binDir, spec.out); + + if (fs.existsSync(dest) && !process.argv.includes("--force")) { + // Re-verify rather than trusting the filename: this directory is gitignored + // scratch space that a half-finished run or a hand copy can leave anything in. + const buf = fs.readFileSync(dest); + const magic = MAGIC[targetPlatform]; + const looksRight = + magic.bytes.every((b, i) => buf[i] === b) && + buf.includes(Buffer.from(`\0${VERSION}\0`, "latin1")); + if (looksRight) { + console.log(`Already present: ${dest}`); + console.log(` ONNX Runtime ${VERSION}, ${(buf.length / 1048576).toFixed(1)} MB`); + console.log("Use --force to re-download."); + return; + } + console.log(`Present but not ONNX Runtime ${VERSION} — re-fetching: ${dest}`); + } + + const tmp = await download(spec); + try { + const lib = find(tmp, spec.member); + if (!lib) throw new Error(`${spec.member} not found inside ${assetName(spec)}`); + const license = find(tmp, "LICENSE"); + if (!license) throw new Error(`LICENSE not found inside ${assetName(spec)}`); + + // Verify BEFORE vendoring: nothing unchecked reaches electron/native/bin, + // where the packager would happily ship it. + console.log("Verifying..."); + const banner = verify(lib, license, targetPlatform); + + fs.mkdirSync(binDir, { recursive: true }); + fs.copyFileSync(lib, dest); + if (targetPlatform !== "win32") fs.chmodSync(dest, 0o755); + + console.log(` ${banner}`); + console.log(`\nVendored -> ${dest}`); + } finally { + fs.rmSync(tmp, { recursive: true, force: true }); + } +} + +main().catch((err) => { + console.error(`\n${err.message}`); + process.exit(1); +}); diff --git a/scripts/fetch-onnxruntime.test.mjs b/scripts/fetch-onnxruntime.test.mjs new file mode 100644 index 000000000..6424e4c7f --- /dev/null +++ b/scripts/fetch-onnxruntime.test.mjs @@ -0,0 +1,116 @@ +// The ONNX Runtime pin is coupled to a pin in a DIFFERENT LANGUAGE, and nothing +// else notices when they drift apart. +// +// `crates/Cargo.toml` gives `ort` the feature `api-NN`. That NN is the minimum ONNX +// Runtime minor version the crate accepts: `ort_sys` computes `ORT_API_VERSION` from +// it and asks the library for that API, and a library older than NN returns null — +// at which point `ort` PANICS rather than erroring (it is documented doing so in +// segmentation.rs, and it took down a render thread once already). So bumping `ort` +// without re-pinning this script ships a build where the effect is not merely off +// but actively fatal on the first frame that asks for it. +// +// The reverse drift is quieter and worse: pinning a NEWER runtime than the crate was +// built for makes `ort` log a compatibility warning to stderr and carry on, which in +// a packaged Electron app nobody reads. +// +// Neither direction is visible in review — the two lines are in different files, in +// different languages, edited by different tasks. This test is the thing that sees it. +// +// Read as source text rather than imported: fetch-onnxruntime.mjs calls main() at +// import and would start downloading. The property under test is a property of the +// literal table anyway. + +import fs from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import { describe, expect, it } from "vitest"; + +const HERE = path.dirname(fileURLToPath(import.meta.url)); +const source = fs.readFileSync(path.join(HERE, "fetch-onnxruntime.mjs"), "utf8"); +const cargoToml = fs.readFileSync(path.join(HERE, "..", "crates", "Cargo.toml"), "utf8"); + +const version = source.match(/^const VERSION = "([^"]+)";/m)?.[1]; +// Anchored at the property so the file's (extensive) prose cannot match. +const digests = [...source.matchAll(/^\s*sha256:\s*"([^"]+)"/gm)].map((m) => m[1]); +const slugs = [...source.matchAll(/^\s*slug:\s*"([^"]+)"/gm)].map((m) => m[1]); +const tags = [...source.matchAll(/^\t"([a-z0-9]+-[a-z0-9]+)":\s*\{$/gm)].map((m) => m[1]); + +/** The `api-NN` feature `crates/Cargo.toml` gives `ort`, as a number. */ +const ortApiFloor = () => { + const block = cargoToml.match(/^ort = \{[\s\S]*?^\]\s*\}/m)?.[0] ?? ""; + const found = [...block.matchAll(/"api-(\d+)"/g)].map((m) => Number(m[1])); + return found.length ? Math.max(...found) : null; +}; + +describe("fetch-onnxruntime pins", () => { + // Without this, a reformat that breaks the regexes above would leave every other + // assertion iterating an empty array and passing vacuously. + it("still finds the pin table", () => { + expect(version, "VERSION not found in fetch-onnxruntime.mjs").toMatch(/^\d+\.\d+\.\d+$/); + expect(slugs.length).toBeGreaterThanOrEqual(4); + expect(digests).toHaveLength(slugs.length); + expect(tags).toHaveLength(slugs.length); + }); + + // THE point of this file. + it("pins a runtime that satisfies the `api-NN` floor in crates/Cargo.toml", () => { + const floor = ortApiFloor(); + expect(floor, "no api-NN feature found on `ort` in crates/Cargo.toml").toBeGreaterThan(0); + const minor = Number(version.split(".")[1]); + expect( + minor, + `crates/Cargo.toml asks ort for api-${floor}, so ONNX Runtime must be >= 1.${floor}.x, ` + + `but fetch-onnxruntime.mjs pins ${version}. A runtime below the floor makes GetApi ` + + "return null and ort PANICS. Re-pin VERSION and every sha256 together.", + ).toBeGreaterThanOrEqual(floor); + }); + + // Above the floor is not free either: ort warns at load and carries on, which in a + // packaged app goes to a stderr nobody reads. Exact match is the intended state. + it("pins the runtime the crate was actually built for, not merely a compatible one", () => { + const floor = ortApiFloor(); + expect( + Number(version.split(".")[1]), + `ONNX Runtime ${version} does not match the api-${floor} ort was built against. ` + + "Below it, ort panics; above it, ort logs a compatibility warning at every " + + "startup, into a stderr no packaged app shows. If the mismatch is deliberate, " + + "move ort to the matching api-NN feature in the same change.", + ).toBe(floor); + }); + + // The gpu_cuda variants are 200-320 MB and carry NVIDIA runtime redistribution + // terms. Nothing in this app uses a GPU execution provider — the CPU EP was the + // measured choice (webcam-segmentation.md), and it is what makes ONNX Runtime + // shippable at all. + it("pins only the plain CPU assets", () => { + for (const slug of slugs) { + expect(slug, `${slug} is not a plain CPU asset`).not.toMatch( + /gpu|cuda|tensorrt|qnn|training/, + ); + } + }); + + it("pins a full sha-256 for every asset", () => { + for (const digest of digests) { + expect(digest).toMatch(/^[0-9a-f]{64}$/); + } + }); + + // The keys are matched against `${process.platform}-${process.arch}`, so a + // plausible-looking typo (`darwin-aarch64`, `win-x64`) silently provisions + // nothing and the effect is off with no error anywhere. + it("keys the table by real process.platform-process.arch tags", () => { + for (const tag of tags) { + expect(tag).toMatch(/^(win32|darwin|linux)-(x64|arm64)$/); + } + expect(new Set(tags).size, "duplicate tag in the pin table").toBe(tags.length); + }); + + // Microsoft publishes no osx-x86_64 asset from 1.27 on. main() has a branch that + // explains that and exits 0 so the Intel release build still succeeds; if someone + // adds a darwin-x64 entry, that branch makes it dead and the effect stays off. + it("does not pin darwin-x64, which upstream does not publish", () => { + expect(tags).not.toContain("darwin-x64"); + expect(source).toMatch(/tag === "darwin-x64"/); + }); +}); 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 67ed67aa6..880d94b8c 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 @@ -1836,9 +1894,60 @@ const CAMERA_SHAPES: Array<{ }, ]; +// The camera-background control used to be gated on the platform: the mask is produced by the +// native compositor, and Linux carried the shader branch with nothing feeding it, so `fx.z` +// never left 0 there and the setting would have changed nothing. The Linux back-end now +// captures the frame and uploads the mask like the other two, so the gate had become a lie +// and is gone — all three platforms segment. +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 @@ -2039,6 +2148,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