feat(webcam): port segmentation to the macOS Metal back-end - #529
Merged
EtienneLescot merged 2 commits intoAug 29, 2026
Merged
Conversation
The shader half already shipped on all three back-ends — `ps_main` takes `texMask [[texture(3)]]` and branches on `fx.z`. What macOS was missing was everything that feeds it: nothing captured the camera, nothing uploaded a mask, so `fx.z` never left 0 and the effect was inert. Four pieces, mirroring `compositor_windows.rs`: - `capture_webcam_rgb` renders the camera NV12 into a 256x144 `Private` target and blits to a `Shared` mirror for `get_bytes` — the readback shape the module header already prescribed and that `render_nv12` already uses. It runs on its OWN command buffer with `wait_until_completed`, never `submit`/`sync`, because `sync` waits on `last_cmd` and `read_nv12_scaled` needs `last_cmd` to stay the `render_nv12` buffer. It passes the WHOLE valid frame, not the sub-rect drawn: a tight user crop would amputate the subject at the model's input. - `set_webcam_mask` uploads R8 through `replace_region`, reallocating only when the model resolution changes. - `pump_segmentation` at the top of `compose_frame`, before the compose command buffer exists. Worker, inbox, 30 Hz limiter and lazy start from `scene.webcam_effect.model_path` are copied from Windows unchanged — that half was already platform-independent. - `fx` and `color` on the webcam `LayerCB`, the mask bound at index 3, and the PiP drop shadow suppressed in cutout mode. `effect_code` stays 0 until a mask has actually been uploaded, so cutout cannot render an invisible camera on the frames before the first inference lands. `synchronizeResource` was checked rather than assumed: `Shared` is not `Managed` and the readback is correct without it. The mask needs no double buffer either — all three macOS frame paths drain the queue before returning, so nothing is in flight when `pump_segmentation` rewrites the texture. Eleven tests in `compositor_macos::tests`, all rendering real pixels on the system device. Seven post the mask by hand and need no ONNX Runtime, which is what CI has; the last one drives capture -> inference -> upload through `compose_frame` alone and skips cleanly without the library. `supportsWebcamSegmentation` now admits darwin. The gate exists so users are not offered a setting that does nothing; on macOS it is no longer true.
The unit tests post the mask by hand, so they can only say that it composites. Whether the mask is CORRECT on real hair against a real background is a judgement, and there is no bench off Windows to make it with — `poc-d3d` is `cfg(windows)`. `seg_visual_renders_the_four_modes_from_a_real_photo` fills that gap: give it a photograph, it renders none/cutout/blur/custom and writes PNGs. Opt-in behind `OPENSCREEN_SEG_VISUAL` + `OPENSCREEN_SEG_CAM`, the same shape of gate as `tests/compose_linux.rs` and for the same reason — it renders on the GPU and reads a file the repo does not carry. Its RGB->NV12 is the exact inverse of `yuv709_limited`, so a colour shift in the output is the compositor's and not the harness's. Also records the measured cost, and is careful about what it does not measure: cutout and blur do identical capture work, so the 0.90 ms between those arms is shader work and the capture cost stays inside both, unattributed. The net figure is the one claimed.
Contributor
|
Important Review skippedAuto reviews are disabled on base/target branches other than the default branch. Please check the settings in the CodeRabbit UI or the ⚙️ Run configurationConfiguration used: defaults Review profile: CHILL Plan: Pro Plus Run ID: You can disable this status message by setting the Use the checkbox below for a quick retry:
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
EtienneLescot
pushed a commit
that referenced
this pull request
Aug 29, 2026
…ually run The compositor has segmented the webcam since #493, and on macOS since #529 — but `ort` is linked `load-dynamic`, nothing ever put a library where `ensureOnnxRuntimeOnPath` looks, and so the feature has been dark on every platform since it landed. This provisions it. `scripts/fetch-onnxruntime.mjs`, wired into `build:mac`, `build:win` and `build:win:store`. Modelled on `fetch-ffmpeg.mjs`: pinned to an immutable release, SHA-256 verified BEFORE the archive is opened, and the extracted library checked three ways before it is allowed near `electron/native/bin/` — the archive's own LICENSE really is MIT, the binary really is a PE/Mach-O/ELF for the target, and it really carries the pinned version string. The digest alone proves only that 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. `scripts/fetch-onnxruntime.test.mjs` guards a coupling that crosses a language boundary: `crates/Cargo.toml` gives `ort` the feature `api-27`, which is the MINIMUM runtime minor version it accepts — below it `GetApi` returns null and `ort` panics rather than erroring, on the render thread, on the first frame with an effect. Nothing else would notice those two pins drifting apart; they are in different files, different languages, and touched by different tasks. CI now stages the library for the macOS compositor job and points ORT_DYLIB_PATH at it. Without that, `runtime_available()` is false and every segmentation test returns early — the suite went green having exercised no inference at all, which is exactly how the ort-panics-when-absent bug got in. Two findings, both now in the docs rather than the estimate they replace: - macOS costs 38.5 MB, not the "roughly 15 MB per platform" this was scoped as — 2.5x Windows' 15.4 MB. `strip -x` would halve it, but stripping means shipping something other than the artifact the digest vouches for. - Intel Macs cannot have it: upstream publishes no osx-x86_64 or universal asset from 1.27 on. The x64 DMG ships without it and the effect is absent there, degrading as designed. The script says so and exits 0 rather than failing that build. Linux is pinned but not wired in: its back-end has the shader half only, so the library would be 23 MB of installer for a code path that cannot run. Also adds the MediaPipe model to THIRD-PARTY-NOTICES.md. It is Apache-2.0 and ships inside app.asar, but its provenance README is stripped by electron-builder's `"!*.md"` filter — so the attribution reached no user at all.
EtienneLescot
pushed a commit
that referenced
this pull request
Aug 30, 2026
…ually run The compositor has segmented the webcam since #493, and on macOS since #529 — but `ort` is linked `load-dynamic`, nothing ever put a library where `ensureOnnxRuntimeOnPath` looks, and so the feature has been dark on every platform since it landed. This provisions it. `scripts/fetch-onnxruntime.mjs`, wired into `build:mac`, `build:win` and `build:win:store`. Modelled on `fetch-ffmpeg.mjs`: pinned to an immutable release, SHA-256 verified BEFORE the archive is opened, and the extracted library checked three ways before it is allowed near `electron/native/bin/` — the archive's own LICENSE really is MIT, the binary really is a PE/Mach-O/ELF for the target, and it really carries the pinned version string. The digest alone proves only that 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. `scripts/fetch-onnxruntime.test.mjs` guards a coupling that crosses a language boundary: `crates/Cargo.toml` gives `ort` the feature `api-27`, which is the MINIMUM runtime minor version it accepts — below it `GetApi` returns null and `ort` panics rather than erroring, on the render thread, on the first frame with an effect. Nothing else would notice those two pins drifting apart; they are in different files, different languages, and touched by different tasks. CI now stages the library for the macOS compositor job and points ORT_DYLIB_PATH at it. Without that, `runtime_available()` is false and every segmentation test returns early — the suite went green having exercised no inference at all, which is exactly how the ort-panics-when-absent bug got in. Two findings, both now in the docs rather than the estimate they replace: - macOS costs 38.5 MB, not the "roughly 15 MB per platform" this was scoped as — 2.5x Windows' 15.4 MB. `strip -x` would halve it, but stripping means shipping something other than the artifact the digest vouches for. - Intel Macs cannot have it: upstream publishes no osx-x86_64 or universal asset from 1.27 on. The x64 DMG ships without it and the effect is absent there, degrading as designed. The script says so and exits 0 rather than failing that build. Linux is pinned but not wired in: its back-end has the shader half only, so the library would be 23 MB of installer for a code path that cannot run. Also adds the MediaPipe model to THIRD-PARTY-NOTICES.md. It is Apache-2.0 and ships inside app.asar, but its provenance README is stripped by electron-builder's `"!*.md"` filter — so the attribution reached no user at all.
EtienneLescot
pushed a commit
that referenced
this pull request
Aug 30, 2026
…ually run The compositor has segmented the webcam since #493, and on macOS since #529 — but `ort` is linked `load-dynamic`, nothing ever put a library where `ensureOnnxRuntimeOnPath` looks, and so the feature has been dark on every platform since it landed. This provisions it. `scripts/fetch-onnxruntime.mjs`, wired into `build:mac`, `build:win` and `build:win:store`. Modelled on `fetch-ffmpeg.mjs`: pinned to an immutable release, SHA-256 verified BEFORE the archive is opened, and the extracted library checked three ways before it is allowed near `electron/native/bin/` — the archive's own LICENSE really is MIT, the binary really is a PE/Mach-O/ELF for the target, and it really carries the pinned version string. The digest alone proves only that 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. `scripts/fetch-onnxruntime.test.mjs` guards a coupling that crosses a language boundary: `crates/Cargo.toml` gives `ort` the feature `api-27`, which is the MINIMUM runtime minor version it accepts — below it `GetApi` returns null and `ort` panics rather than erroring, on the render thread, on the first frame with an effect. Nothing else would notice those two pins drifting apart; they are in different files, different languages, and touched by different tasks. CI now stages the library for the macOS compositor job and points ORT_DYLIB_PATH at it. Without that, `runtime_available()` is false and every segmentation test returns early — the suite went green having exercised no inference at all, which is exactly how the ort-panics-when-absent bug got in. Two findings, both now in the docs rather than the estimate they replace: - macOS costs 38.5 MB, not the "roughly 15 MB per platform" this was scoped as — 2.5x Windows' 15.4 MB. `strip -x` would halve it, but stripping means shipping something other than the artifact the digest vouches for. - Intel Macs cannot have it: upstream publishes no osx-x86_64 or universal asset from 1.27 on. The x64 DMG ships without it and the effect is absent there, degrading as designed. The script says so and exits 0 rather than failing that build. Linux is pinned but not wired in: its back-end has the shader half only, so the library would be 23 MB of installer for a code path that cannot run. Also adds the MediaPipe model to THIRD-PARTY-NOTICES.md. It is Apache-2.0 and ships inside app.asar, but its provenance README is stripped by electron-builder's `"!*.md"` filter — so the attribution reached no user at all.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Stacked on #493. Ports the capture/upload half of webcam segmentation to the Metal back-end, which had the shader half only.
The brief is
webcam-segmentation-backend-port.md; this follows it, and updates it pluswebcam-segmentation.mdto match what is now true.What was missing
ps_mainalready tooktexMask [[texture(3)]]and branched onfx.z. Nothing on macOS ever raisedfx.z, because nothing captured the camera or uploaded a mask. The effect was inert and the control was hidden.What this adds
capture_webcam_rgb— renders the camera NV12 into a 256x144Privatetarget, blits to aSharedmirror,get_bytes, compacts RGBA→RGB in place. It passes the whole valid frame ([0, 0, wcw/wtw, wch/wth]), not the sub-rect drawn, so a tight user crop cannot amputate the subject at the model's input.set_webcam_mask—MTLPixelFormat::R8Unorm+replace_region, reallocated only when the model resolution changes.pump_segmentationat the top ofcompose_frame, before the compose command buffer exists. The worker, inbox, 30 Hz limiter, lazy start fromscene.webcam_effect.model_pathand theseg_failedlatch are copied from Windows unchanged — that half was already platform-independent.fxandcoloron the webcamLayerCB, the mask bound at index 3, and the PiP drop shadow suppressed in cutout mode.supportsWebcamSegmentationnow admitsdarwin. Linux stays gated.Untouched, as the brief requires: the shader contract, the 25-tap blur, and
segmentation.rs.The traps, and what happened at each
submit/syncfor the capture pass. Avoided.sync()waits onlast_cmd, andread_nv12_scaledneedslast_cmdto stay therender_nv12buffer — taking it would make the encode readback wait on the capture and read plans nothing had written. The capture commits and waits on its own buffer.effect_codemust stay 0 until a mask exists. It does, anda_mode_without_a_mask_composites_exactly_like_no_effect_at_allasserts it byte for byte rather than approximately.mode < 0.5andfx.z > 0.5, and only the webcam layer raisesfx.z. It shares an encoder with the PiP shadow, which is mode 2. No unbind afterwards — unlike Windows, that state dies with the encoder and the annotations open their own.synchronizeResourceon a discrete GPU. Checked, not assumed: not needed.Sharedis notManaged, and this is the samePrivate→blit→Sharedshapert_readandnv12_read_yhave used in production all along, so it carries no support risk the existing readbacks do not.ortpanics when its library is missing.runtime_available()andcatch_unwindboth left alone.One thing the brief did not list, worth flagging:
set_webcam_maskrewrites a texture the GPU could in principle still be sampling. It cannot here, because all three macOS frame paths drain the queue before returning (readback_directandrgb_to_nv12dosubmit+sync,read_nv12_scaleddoessync), so nothing is in flight whencompose_framenext pumps. That is why there is no double buffer, and it is written down on the function — if that invariant changes, this is the code that breaks.What I actually saw on screen
poc-d3discfg(windows), so the--cfg C8 --sceneroute does not exist here. I rendered a real 1280x720 camera frame — a person against dense foliage, deliberately a hard case for hair — through the realcompose_frameon this M1, with real ONNX Runtime inference, and looked at all four modes:#ff2d95behind the subject, PiP frame and shadow kept.Honestly: there is a faint dark fringe along the hairline and shoulders, one to two pixels of the original dark background surviving the matte. It is most visible in custom mode against saturated pink. That is the 256x144 mask upscaled ~4.5x by a linear sampler, so it is a property of the shipped mask resolution and the shared shader, not of this port — Windows renders the same artefact from the same inputs. Calling it out because "the mask composites" and "the mask is correct" are different claims and the brief says so.
The harness that produced those images is committed as
seg_visual_renders_the_four_modes_from_a_real_photo, opt-in behindOPENSCREEN_SEG_VISUAL+OPENSCREEN_SEG_CAM(same gate shape astests/compose_linux.rs).Cost
Indicative only — not a §C.2 run (no fixture, no
--repeat 3, its own harness). M1, 8 GPU cores, macOS 26.5, 1280x720 preview including the synchronousreadback_directthe preview already pays. A/B/A/B interleaved, 300 frames per arm, warm-up discarded; spread between repeats of the same arm ≤ 1.2 %.Cutout is cheaper than no effect at all — it drops the PiP shadow, one fewer full-quad SDF draw, which more than pays for everything the feature adds.
What these arms do not isolate is the capture. Cutout and blur do identical capture, inference and upload work; they differ only by the shader branch and the shadow, so the 0.90 ms between them is shader work and the capture cost sits inside both arms unattributed. Separating it would need a fourth arm — effect requested, capture running,
fx.zpinned at 0 — which is not a state the compositor can be asked for. So the claim is the net one, and it is a this machine claim: M1 unified memory makes the 147 KBPrivate→Sharedblit nearly free, and on an Intel Mac with a discrete GPU it is a real bus transfer. Unmeasured there.Tests
cargo test -p openscreen-compositor --lib --tests— 156 passed (was 146), green on a real Metal device both with and without ONNX Runtime onORT_DYLIB_PATH.cargo build -p compositor-view-napiclean. Ten new tests, all rendering real pixels; seven post the mask by hand and need no ONNX Runtime, which is what CI has, and the full-loop one skips cleanly without it.the_webcam_capture_comes_back_as_interleaved_rgb_at_model_resolutionthe_mask_actually_cuts_the_camera_outthe_custom_background_colour_replaces_the_masked_out_pixelscolornot carried onto the webcamLayerCBa_mode_without_a_mask_composites_exactly_like_no_effect_at_alleffect_codeleaving 0 too early — the invisible-webcam trapcompose_frame_cuts_the_camera_out_once_a_mask_existsfxnot carried, by counting camera pixels rather than pinning PiP geometrythe_pip_shadow_is_suppressed_in_cutout_modethe_whole_loop_produces_a_mask_from_compose_frame_alonecompose_framealoneThe end-to-end ones go through real
CVPixelBufferRef-backedAVFrames and therefore the samenv12_srvsthe decoder uses; the NV12 fixture helper is#[cfg(test)]inmac_frames.rs, next to the CoreVideo FFI it reuses, so the test does not grow a second copy of it.Note on ONNX Runtime
Still not staged for any platform, so the effect remains off at runtime until a CI job puts the library in
electron/native/bin/<tag>/. That is unchanged by this PR and is tracked inwebcam-segmentation.mdunder Not done. Removing the macOS gate reflects back-end capability, matching what Windows already does.I ran locally against ONNX Runtime 1.27.1 osx-arm64 from the official microsoft/onnxruntime release, which matches the pinned
api-27exactly.ci.ymltriggers onpull_request: branches: [main, feat/ai-edition, "release/**"]. This PR targetsfeat/webcam-effects, which matches none of them, so every job is skipped — includingRust test (macOS compositor), the one that would exercise this change. The only checks you see are CodeRabbit (disabled for this base) and the Discord sync.That is the same gap the workflow's own header comment is about — it cost #167–#169 on a release branch. Flagging rather than fixing it: adding
feat/webcam-effectsto that list is a repo-wide change andfeat/**is a short-lived branch pattern, so it is a call for whoever owns the branch strategy. #493 targets main and will run everything when it lands.In its place I ran the CI jobs locally, on an M1 with a real Metal device:
cd crates && cargo test -p openscreen-compositor --lib --testsORT_DYLIB_PATHcargo build -p compositor-view-napi --releasebiome checkon the changed filenpx tsc --noEmitnpm run testnode scripts/check-docs.mjsRust check (Windows compositor)I cannot run here. This PR touches no Windows file —compositor_macos.rsandmac_frames.rsare bothcfg(target_os = "macos").