Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -276,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"
Expand Down
1,332 changes: 1,326 additions & 6 deletions crates/compositor/src/compositor_linux.rs

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,7 @@
"build:native:linux": "node scripts/build-linux-pipewire-helper.mjs",
"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 build:native:compositor:linux && tsc && vite build && electron-builder --linux AppImage deb pacman rpm --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",
Expand Down
10 changes: 5 additions & 5 deletions scripts/fetch-onnxruntime.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -91,11 +91,11 @@ const PINNED = {
member: `libonnxruntime.${VERSION}.dylib`,
out: "libonnxruntime.dylib",
},
// Linux is pinned but not yet wired into `build:linux`: the Linux compositor
// back-end carries the segmentation SHADER only — nothing captures the webcam
// frame or uploads a mask there, so `fx.z` never leaves 0 and the library would
// be 23 MB of installer for a code path that cannot run. The entry is here so
// that landing the Linux capture half is a one-line change to package.json.
// 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",
Expand Down
165 changes: 76 additions & 89 deletions src/components/ai-edition/RightPanes.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -1894,20 +1894,11 @@ const CAMERA_SHAPES: Array<{
},
];

/**
* Whether this build can actually segment the camera.
*
* The mask is produced by the native compositor, so this tracks which back-ends capture the
* webcam frame and upload the mask: Windows and macOS do, Linux carries the shader branch but
* nothing feeds it, so `fx.z` never leaves 0 there. Showing the control anyway would put a
* setting in the panel that changes nothing, which is the one thing a control must never do.
* It comes back for a platform the moment that platform's capture lands.
*/
function supportsWebcamSegmentation(): boolean {
const platform = window.electronAPI?.getPlatform?.();
return platform === "win32" || platform === "darwin";
}

// 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;
Expand Down Expand Up @@ -2157,81 +2148,77 @@ export function LayoutPane() {
</div>
</div>
) : null}
{supportsWebcamSegmentation() ? (
<>
<div className={styles.sectionLabel}>{ts("layout.webcamBackground")}</div>
<div
style={{
display: "grid",
gridTemplateColumns: "repeat(4, minmax(0, 1fr))",
gap: 8,
padding: "0 var(--sp-4) 12px",
}}
>
{CAMERA_BACKGROUND_MODES.map((mode) => {
const isActive = settings.webcamBackgroundMode === mode.value;
return (
<button
type="button"
key={mode.value}
className={`${styles.cursorCell} ${isActive ? styles.isActive : ""}`}
style={{
flexDirection: "column",
gap: 4,
padding: 8,
display: "flex",
alignItems: "center",
minWidth: 0,
}}
disabled={layoutControlsDisabled}
onClick={() => {
void set({ webcamBackgroundMode: mode.value });
}}
>
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
width={22}
height={22}
>
{mode.icon}
</svg>
<span style={{ font: "500 11px/1 var(--font-body)" }}>{ts(mode.labelKey)}</span>
</button>
);
})}
</div>
{settings.webcamBackgroundMode === "blur" ? (
<div className={styles.sliderGrid}>
<SliderCell
label={ts("layout.webcamBlurIntensity")}
value={Math.round(settings.webcamBlurIntensity * 100)}
min={0}
max={100}
suffix="%"
disabled={layoutControlsDisabled}
onChange={(next) => setLive({ webcamBlurIntensity: next / 100 })}
onCommit={() => void commit()}
/>
</div>
) : null}
{settings.webcamBackgroundMode === "custom" ? (
<div style={{ padding: "0 var(--sp-4) 12px" }}>
<WallpaperPicker
value={settings.webcamWallpaper}
hasDocument={hasDocument && !layoutControlsDisabled}
onChange={(url) => void set({ webcamWallpaper: url })}
onLiveChange={(url) => setLive({ webcamWallpaper: url })}
onCommit={commit}
updateNativeBackground={false}
onPickFile={handlePickWebcamWallpaper}
/>
{webcamWallpaperInput}
</div>
) : null}
</>
<div className={styles.sectionLabel}>{ts("layout.webcamBackground")}</div>
<div
style={{
display: "grid",
gridTemplateColumns: "repeat(4, minmax(0, 1fr))",
gap: 8,
padding: "0 var(--sp-4) 12px",
}}
>
{CAMERA_BACKGROUND_MODES.map((mode) => {
const isActive = settings.webcamBackgroundMode === mode.value;
return (
<button
type="button"
key={mode.value}
className={`${styles.cursorCell} ${isActive ? styles.isActive : ""}`}
style={{
flexDirection: "column",
gap: 4,
padding: 8,
display: "flex",
alignItems: "center",
minWidth: 0,
}}
disabled={layoutControlsDisabled}
onClick={() => {
void set({ webcamBackgroundMode: mode.value });
}}
>
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
width={22}
height={22}
>
{mode.icon}
</svg>
<span style={{ font: "500 11px/1 var(--font-body)" }}>{ts(mode.labelKey)}</span>
</button>
);
})}
</div>
{settings.webcamBackgroundMode === "blur" ? (
<div className={styles.sliderGrid}>
<SliderCell
label={ts("layout.webcamBlurIntensity")}
value={Math.round(settings.webcamBlurIntensity * 100)}
min={0}
max={100}
suffix="%"
disabled={layoutControlsDisabled}
onChange={(next) => setLive({ webcamBlurIntensity: next / 100 })}
onCommit={() => void commit()}
/>
</div>
) : null}
{settings.webcamBackgroundMode === "custom" ? (
<div style={{ padding: "0 var(--sp-4) 12px" }}>
<WallpaperPicker
value={settings.webcamWallpaper}
hasDocument={hasDocument && !layoutControlsDisabled}
onChange={(url) => void set({ webcamWallpaper: url })}
onLiveChange={(url) => setLive({ webcamWallpaper: url })}
onCommit={commit}
updateNativeBackground={false}
onPickFile={handlePickWebcamWallpaper}
/>
{webcamWallpaperInput}
</div>
) : null}
<div className={styles.sectionLabel}>{ts("layout.webcamFraming")}</div>
<div className={styles.sliderGrid}>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,26 +6,26 @@ Context and the measurements behind the design: [webcam-segmentation.md](webcam-

## Where it stands

The feature is complete on Windows and macOS, and inert on Linux. The split is deliberate:
The feature is complete on all three back-ends.

| piece | Windows | macOS | Linux |
|---|---|---|---|
| shader branch on `fx.z` | done | done | **done** |
| shader branch on `fx.z` | done | done | done |
| mask texture binding | `t3` | `texture(3)` | `@binding(4)` |
| `capture_webcam_rgb` | done | done | **missing** |
| `set_webcam_mask` | done | done | **missing** |
| `pump_segmentation` call | done | done | **missing** |
| `fx` on the webcam layer | done | done | **missing** |
| `capture_webcam_rgb` | done | done | done |
| `set_webcam_mask` | done | done | done |
| `pump_segmentation` call | done | done | done |
| `fx` on the webcam layer | done | done | done |
| inference (`segmentation.rs`) | shared | shared | shared |

The shader half landed on all three at once on purpose: it is the part that must not drift
between back-ends, it is mechanical, and the Metal and WGSL versions are checked by CI (see
Verification). Everything below is the per-back-end half.
Verification). Everything below is the per-back-end half, and it is what each port added.

`RightPanes.tsx`'s `supportsWebcamSegmentation()` now admits `win32` and `darwin`, and still hides
the control on Linux. **Delete that gate for your platform in the same PR that lands the port** —
it exists so users are not offered a setting that does nothing, and it becomes a lie the moment
the port works.
`RightPanes.tsx`'s `supportsWebcamSegmentation()` is **gone**: it existed so users were not
offered a setting that did nothing, and once the last back-end captured a frame it admitted every
platform and had become a lie. A fourth back-end would need it back — and would need to land the
gate and the port in the same PR, as the first three did.

## What you are adding

Expand Down Expand Up @@ -115,6 +115,24 @@ Plus `seg_visual_renders_the_four_modes_from_a_real_photo`, opt-in behind
assertions above can only say the mask *composites*; a mask that is *correct* on real hair
against a real background is a judgement, and this is what you look at to make it.

`compositor_linux::tests` is the same set, one test wider. It adds
`a_capture_whose_rows_need_padding_is_depadded_correctly`, because the padding trap below is the
one thing the shipped resolution can never exercise: 256 px of RGBA is 1024 bytes, already
aligned, so at the size that actually runs the depad branch is dead code. The test captures at
100 px — 400 bytes of payload in a 512-byte stride — and samples several rows, since a wrong
depad does not produce noise but a shear of 28 px per row.

Two more things differ from macOS, both forced by the host:

- **`Gpu::create_auto`, not `Gpu::create`.** `create` is hardware-strict and the Linux CI runner
has no GPU, so the strict constructor would make every one of these tests skip silently on the
only machine that runs them automatically. `create_auto` falls back to lavapipe, which is what
the job installs `mesa-vulkan-drivers` for.
- **The frames are built by hand.** There is no `CVPixelBuffer` equivalent to lean on: the tests
allocate the NV12-split pair themselves and pack a `linux_frames::VkFrameTex` carrier into
`AVFrame::data[0]`, exactly as `CpuFrames::attach_carrier` does. They therefore still go through
the real `nv12_srvs`, so no shortcut is taken on the frame seam.

## macOS specifics

- **Device and queue.** `d3d_macos.rs:66-72` — `Gpu { device: metal::Device, context:
Expand Down Expand Up @@ -150,6 +168,25 @@ against a real background is a judgement, and this is what you look at to make i

## Linux specifics

What the port settled, beyond the notes below:

- **`make_bind` resolves the mask itself**, rather than every call site passing it. It already
builds one bind group per draw and the layout requires binding 4, so the mask is bound on every
draw and `dummy_view()` is the fallback in the one place that decides. Nothing else changed at
the ~15 call sites.
- **The capture readback is its own thing, not a fourth entry on `ReadbackRing`.** The ring exists
to *avoid* waiting; the capture must wait, because the frame it reads is the worker's input for
this tick. What it borrows from the ring is the lesson, not the code:
`WaitForSubmissionIndex`, never `Maintain::Wait`.
- **`pump_segmentation` is called behind a scene check in `compose_frame`, not only inside
itself.** It re-checks anyway — the body is the Windows one verbatim — but on this back-end
`nv12_srvs` *allocates* two `TextureView`s per call, so reaching the function at all would cost
two allocations a frame on every project that has no effect. The feature has to cost zero when
it is off, and here that is a call-site property.
- **`Queue::write_texture` for the mask, and it is not subject to the 256-byte rule.** That rule
is `copy_texture_to_buffer`'s. `linux_frames::upload` already writes NV12 planes with swscale's
SIMD strides through the same call.

- **Webcam textures.** `nv12_srvs` (`compositor_linux.rs:779-791`) delegates to
`linux_frames::nv12_planes`, which builds **fresh `TextureView`s on every call** — there is no
cache (`clear_srv_cache` is a documented no-op at `:765-767`). The planes are **CPU-uploaded**
Expand Down
Loading