diff --git a/crates/compositor-view-napi/src/lib.rs b/crates/compositor-view-napi/src/lib.rs index 62c164e0c..69476ffc2 100644 --- a/crates/compositor-view-napi/src/lib.rs +++ b/crates/compositor-view-napi/src/lib.rs @@ -70,6 +70,22 @@ pub fn probe_backend() -> String { .to_string() } +/// Si cette machine peut produire un masque de segmentation, c'est-à-dire si la bibliothèque +/// ONNX Runtime est là où l'app l'a posée. +/// +/// Sert à NE PAS MENTIR : le contrôle « fond de caméra » est le seul de l'éditeur dont l'effet +/// dépend d'un binaire optionnel. Sans lui, `Segmenter::load` refuse, le compositeur dessine la +/// webcam telle quelle, et l'utilisateur clique sur un réglage qui ne fait rien — exactement ce +/// qu'un contrôle ne doit jamais faire. +/// +/// Une question posée au système plutôt que devinée depuis la plateforme : `darwin` ne suffit +/// pas à répondre, puisque l'amont ne publie aucun binaire ONNX pour les Macs Intel, et une +/// build de dev ou un `--dir` n'en ont pas davantage. Seul l'état réel de la machine le sait. +#[napi] +pub fn segmentation_runtime_available() -> bool { + openscreen_compositor::segmentation::runtime_available() +} + #[napi] pub fn create_view( rect: CompositorViewRect, diff --git a/electron/ipc/nativeBridge.ts b/electron/ipc/nativeBridge.ts index 47d66e270..5d4992c10 100644 --- a/electron/ipc/nativeBridge.ts +++ b/electron/ipc/nativeBridge.ts @@ -353,6 +353,12 @@ export function registerNativeBridgeHandlers(context: NativeBridgeContext) { return createSuccessResponse(requestId, { backend: compositorViewService.probeBackend(), }); + case "probeSegmentation": + // No view needed either: the layout panel decides whether to offer the + // camera-background control before any preview exists. + return createSuccessResponse(requestId, { + support: compositorViewService.probeSegmentation(), + }); case "setRect": compositorViewService.setRect(request.payload.id, request.payload.rect); return createSuccessResponse(requestId, { ok: true }); diff --git a/electron/native-bridge/services/compositorViewService.ts b/electron/native-bridge/services/compositorViewService.ts index 1a61bd696..59d0ee547 100644 --- a/electron/native-bridge/services/compositorViewService.ts +++ b/electron/native-bridge/services/compositorViewService.ts @@ -16,6 +16,7 @@ import type { GifParamsInput, NativeFramePacket, RemuxStats, + SegmentationSupport, } from "../../native/compositor-view/addon"; /** @@ -485,6 +486,36 @@ export class CompositorViewService { } } + /** Whether this machine can actually segment the camera, and if not, what is missing. + * + * Three things have to line up, and each of them has been silently absent at some point: + * the addon, the ONNX Runtime library, and the model. The renderer used to guess from + * `process.platform`, which was wrong in both directions — it hid the control on Linux + * builds that could segment, and shows it on Intel Macs, for which upstream publishes no + * ONNX binary at all. A dev checkout and a `--dir` build have none staged either. + * + * Same shape as `probeBackend`: asked without allocating a view, because the panel needs + * the answer before any preview exists. */ + probeSegmentation(): SegmentationSupport { + const addon = this.ensureAddon(); + if (!addon) { + return "none"; + } + try { + if (!addon.segmentationRuntimeAvailable()) { + return "no-runtime"; + } + } catch (err) { + // An older `.node` predates this probe. Treat as unsupported rather than crashing + // the bridge — same contract as `probeBackend`. + console.warn("[compositor-view] segmentationRuntimeAvailable unavailable:", err); + return "none"; + } + // The model is resolved by this process, not the addon, so it is checked here — and it + // is the same lookup `resolveSceneAssetPaths` performs, so the two cannot disagree. + return resolveSceneAssetPath(SEGMENTATION_MODEL_ASSET) ? "ready" : "no-model"; + } + /** Allocates an offscreen compositor view sized to `rect.width`x`rect.height`. * `rect.x` / `rect.y` are vestigial (ignored native-side) — the renderer * keeps them on the wire so the existing `CompositorViewRect` shape stays diff --git a/electron/native/compositor-view/addon.d.ts b/electron/native/compositor-view/addon.d.ts index dc5fe599a..4c63065d8 100644 --- a/electron/native/compositor-view/addon.d.ts +++ b/electron/native/compositor-view/addon.d.ts @@ -104,11 +104,22 @@ export interface ClipInput { * all, so the view will fail with its own, more specific message. */ export type CompositorBackend = "hardware" | "cpu" | "none"; +/** Whether this machine can segment the camera, and if not, what is missing. Mirrored in + * `src/native/contracts.ts` for the renderer, the same way `CompositorBackend` is — the addon + * boundary and the IPC boundary each own their shape. */ +export type SegmentationSupport = "ready" | "no-runtime" | "no-model" | "none"; + export interface CompositorViewAddon { /** What this machine offers, asked without allocating a view — the export dialog * needs the answer before any preview exists. Cached native-side. */ probeBackend(): CompositorBackend; + /** Whether the ONNX Runtime library is where the app staged it, and therefore whether a + * segmentation mask can be produced at all. Asked of the machine rather than guessed from + * the platform: upstream publishes no ONNX build for Intel Macs, and a dev checkout or a + * `--dir` build has none either. */ + segmentationRuntimeAvailable(): boolean; + /** Allocates an offscreen compositor view sized to `rect.width`x`rect.height` (the * target preview resolution; `rect.x` / `rect.y` are vestigial and ignored native-side). * No HWND/native-window-handle is passed: there's no OS window to parent to. The diff --git a/scripts/before-pack.cjs b/scripts/before-pack.cjs index d9701c654..4b97a35a3 100644 --- a/scripts/before-pack.cjs +++ b/scripts/before-pack.cjs @@ -68,6 +68,26 @@ const HELPER_SOURCE_PATHS = [ * `mac.extraResources` ships this directory wholesale (`filter: ["darwin-*​/*"]`), so * "present here" is the same thing as "present in the installed app". */ +/** + * L'exigence ONNX Runtime de macOS, séparée parce qu'elle ne vaut QUE sur arm64. + * + * L'amont ne publie aucun binaire ONNX pour les Macs Intel : `fetch-onnxruntime.mjs` le constate + * et sort en 0 sans rien poser. Un paquet x64 sans la bibliothèque est donc CORRECT, et l'exiger + * là ferait échouer à l'empaquetage une build parfaitement saine — la garde se retournerait + * contre ce qu'elle protège. + * + * Sur arm64 en revanche son absence ne casse rien de visible : `Segmenter::load` refuse, le + * compositeur dessine la webcam telle quelle, et le contrôle disparaît de l'éditeur. Le paquet + * est silencieusement amputé, ce qui est exactement la panne que cette garde existe pour + * attraper et qu'aucun test ne peut voir puisque tout se dégrade proprement. + */ +const MAC_ONNX_REQUIRED = { + match: (name) => name === "libonnxruntime.dylib", + what: "the ONNX Runtime library the camera-background segmentation loads", + breaks: "the camera-background control vanishes from the editor and every effect is a no-op", + fix: "Stage it with:\n\n npm run fetch:onnxruntime", +}; + const MAC_REQUIRED = [ { match: (name) => name === "compositor_view.node", @@ -119,6 +139,17 @@ const MAC_REQUIRED = [ * `helper-ffmpeg/` subdirectory holds. */ const LINUX_REQUIRED = [ + // L'effet de fond de caméra est le seul dont la présence dépend d'un binaire optionnel, et + // son absence ne casse RIEN de visible : `Segmenter::load` refuse, le compositeur dessine la + // webcam telle quelle, et le contrôle disparaît de l'éditeur. Un paquet livré sans elle est + // donc silencieusement amputé — la panne exacte que cette garde existe pour attraper, et + // celle qu'aucun test ne peut voir puisque tout se dégrade proprement. + { + match: (name) => name === "libonnxruntime.so", + what: "the ONNX Runtime library the camera-background segmentation loads", + breaks: "the camera-background control vanishes from the editor and every effect is a no-op", + fix: "Stage it with:\n\n npm run fetch:onnxruntime", + }, { match: (name) => name === "compositor_view.node", what: "the wgpu/Vulkan compositor addon", @@ -223,6 +254,17 @@ function checkNativePayload({ dir, required, osLabel, bundleNoun, emptyDirFix }) * "together here" is the same thing as "together in the installed app". */ const WIN_REQUIRED = [ + // L'effet de fond de caméra est le seul dont la présence dépend d'un binaire optionnel, et + // son absence ne casse RIEN de visible : `Segmenter::load` refuse, le compositeur dessine la + // webcam telle quelle, et le contrôle disparaît de l'éditeur. Un paquet livré sans elle est + // donc silencieusement amputé — la panne exacte que cette garde existe pour attraper, et + // celle qu'aucun test ne peut voir puisque tout se dégrade proprement. + { + match: (name) => name === "onnxruntime.dll", + what: "the ONNX Runtime library the camera-background segmentation loads", + breaks: "the camera-background control vanishes from the editor and every effect is a no-op", + fix: "Stage it with:\n\n npm run fetch:onnxruntime", + }, { match: (name) => name === "compositor_view.node", what: "the D3D11 compositor addon", @@ -419,10 +461,13 @@ function checkWinNativePayload() { } function checkMacNativePayload(context) { - const dir = path.join(ROOT, "electron", "native", "bin", `darwin-${archTagFor(context)}`); + const arch = archTagFor(context); + const dir = path.join(ROOT, "electron", "native", "bin", `darwin-${arch}`); checkNativePayload({ dir, - required: MAC_REQUIRED, + // Voir `MAC_ONNX_REQUIRED` : exiger la bibliothèque sur Intel ferait échouer une build + // que l'amont rend impossible à satisfaire. + required: arch === "arm64" ? [...MAC_REQUIRED, MAC_ONNX_REQUIRED] : MAC_REQUIRED, osLabel: "macOS", bundleNoun: "the .app", emptyDirFix: `${FIX_MAC}\n\nThe STT helper and the capture helper are separate builds — see\ntechnical-documentation/engineering/build-and-packaging.md.`, diff --git a/src/components/ai-edition/RightPanes.tsx b/src/components/ai-edition/RightPanes.tsx index 880d94b8c..2e15f7b12 100644 --- a/src/components/ai-edition/RightPanes.tsx +++ b/src/components/ai-edition/RightPanes.tsx @@ -16,6 +16,7 @@ import { Sliders, Trash2, } from "lucide-react"; + import { type ChangeEvent, type CSSProperties, @@ -80,6 +81,7 @@ import { type AspectRatio, getAspectRatioLabel, } from "@/utils/aspectRatioUtils"; +import { useCanSegmentCamera } from "../../native/hooks/useSegmentationSupport"; import styles from "./NewEditorShell.module.css"; interface PaneProps { @@ -1943,6 +1945,7 @@ const CAMERA_BACKGROUND_MODES: Array<{ ]; export function LayoutPane() { + const canSegmentCamera = useCanSegmentCamera(); const ts = useScopedT("settings"); const { settings, set, setLive, commit, hasDocument } = useEditorSettings(); const { pick: handlePickWebcamWallpaper, input: webcamWallpaperInput } = useWallpaperFileInput( @@ -2148,77 +2151,87 @@ 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} -
+ {/* Le seul contrôle de l'éditeur dont l'effet dépend d'un binaire optionnel : sans la + bibliothèque ONNX Runtime, le compositeur dessine la webcam telle quelle et le réglage + ne fait rien. On demande donc à la machine plutôt que de deviner depuis la plateforme — + `process.platform` se trompait dans les deux sens : il cachait le contrôle sur des + builds Linux capables de segmenter, et le montrait sur les Macs Intel, pour lesquels + l'amont ne publie aucun binaire ONNX. */} + {canSegmentCamera ? ( + <> +
{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} + ) : null}
{ts("layout.webcamFraming")}
diff --git a/src/native/compositorViewClient.ts b/src/native/compositorViewClient.ts index 68f062e62..987d506d2 100644 --- a/src/native/compositorViewClient.ts +++ b/src/native/compositorViewClient.ts @@ -20,6 +20,8 @@ import type { CompositorParamValue, CompositorViewRect, CompositorViewResult, + SegmentationSupport, + SegmentationSupportResult, } from "./contracts"; /** Which backend the native compositor will use here. @@ -39,6 +41,23 @@ export async function probeCompositorBackend(): Promise { } } +/** Whether this machine can segment the camera. `"none"` outside Electron or without the addon. + * + * Fails closed: any error means the control should not be offered. Showing a setting that + * cannot do anything is the failure this exists to prevent, so a broken probe must not be + * read as capability. */ +export async function probeSegmentationSupport(): Promise { + try { + const result = await requireNativeBridgeData({ + domain: "compositor", + action: "probeSegmentation", + }); + return result.support; + } catch { + return "none"; + } +} + export function createCompositorView( rect: CompositorViewRect, sources?: { screenPath?: string; webcamPath?: string; cursorPath?: string }, diff --git a/src/native/contracts.ts b/src/native/contracts.ts index ede052f32..7e7fc11c9 100644 --- a/src/native/contracts.ts +++ b/src/native/contracts.ts @@ -128,6 +128,23 @@ export interface CompositorBackendResult { backend: CompositorBackend; } +/** Whether this machine can segment the camera, and if not, what is missing. + * + * `"ready"` — runtime and model both present. + * `"no-runtime"` — no ONNX Runtime library. Upstream publishes none for Intel Macs, and a dev + * checkout or a `--dir` build has none staged either. + * `"no-model"` — the runtime is there but the `.onnx` does not resolve. + * `"none"` — no usable native addon: none loaded at all (the pure-web/dev case), or one + * too old to answer the probe. Not a degraded machine, and not a verdict on the runtime. + * + * Asked rather than guessed. Gating on `process.platform` was wrong in both directions: it hid + * the control on a Linux box that could segment, and showed it on an Intel Mac that never can. */ +export type SegmentationSupport = "ready" | "no-runtime" | "no-model" | "none"; + +export interface SegmentationSupportResult { + support: SegmentationSupport; +} + /** A self-describing preview frame returned by `readFrame` (native → renderer): pixels * (`data`, RGBA8, `width * height * 4` bytes) plus their dimensions and a monotonic * generation. The hook keeps `gen` and passes it back as `sinceGen`; an unchanged frame @@ -675,6 +692,11 @@ export type NativeBridgeRequest = action: "probeBackend"; requestId?: string; } + | { + domain: "compositor"; + action: "probeSegmentation"; + requestId?: string; + } | { domain: "compositor"; action: "setRect"; diff --git a/src/native/hooks/useSegmentationSupport.test.ts b/src/native/hooks/useSegmentationSupport.test.ts new file mode 100644 index 000000000..bc71cc658 --- /dev/null +++ b/src/native/hooks/useSegmentationSupport.test.ts @@ -0,0 +1,79 @@ +// @vitest-environment jsdom +/** + * The camera-background control must appear only where a mask can actually reach the shader. + * + * The failure worth guarding is the false POSITIVE, and it is the mirror of the CPU-notice + * one next door: here, offering the control is the damage. Every non-`"ready"` answer means + * the user would click a setting, watch it persist and highlight, and see nothing change — + * in the preview and in the exported file alike. + * + * This replaced a `process.platform` guess that was wrong in both directions: it hid the + * control on Linux builds that could segment, and showed it on Intel Macs, for which + * upstream publishes no ONNX Runtime binary at all. + */ + +import { renderHook, waitFor } from "@testing-library/react"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const mocks = vi.hoisted(() => ({ probeSegmentationSupport: vi.fn() })); + +vi.mock("../compositorViewClient", () => ({ + probeSegmentationSupport: mocks.probeSegmentationSupport, +})); + +import { + resetSegmentationSupportProbeForTests, + useCanSegmentCamera, + useSegmentationSupport, +} from "./useSegmentationSupport"; + +describe("useSegmentationSupport", () => { + beforeEach(() => { + vi.clearAllMocks(); + resetSegmentationSupportProbeForTests(); + }); + + it("offers the control when the machine can segment", async () => { + mocks.probeSegmentationSupport.mockResolvedValue("ready"); + const { result } = renderHook(() => useCanSegmentCamera()); + await waitFor(() => expect(result.current).toBe(true)); + }); + + // The three ways it silently cannot, each of which has actually happened: no ONNX Runtime + // (Intel Mac, dev checkout, `--dir` build), no model (not lifted out of app.asar), no addon + // (pure-web dev). None of them fails loudly, which is exactly why the probe exists. + it.each([ + "no-runtime", + "no-model", + "none", + ] as const)("hides the control when the answer is %s", async (answer) => { + mocks.probeSegmentationSupport.mockResolvedValue(answer); + const { result } = renderHook(() => useCanSegmentCamera()); + await waitFor(() => expect(mocks.probeSegmentationSupport).toHaveBeenCalled()); + expect(result.current).toBe(false); + }); + + it("reports the reason, not just the verdict", async () => { + mocks.probeSegmentationSupport.mockResolvedValue("no-runtime"); + const { result } = renderHook(() => useSegmentationSupport()); + // Which one it is decides whether staging is missing or the model is — the difference + // between a build bug and a packaging bug. + await waitFor(() => expect(result.current).toBe("no-runtime")); + }); + + it("hides the control until the probe answers", () => { + mocks.probeSegmentationSupport.mockReturnValue(new Promise(() => {})); + const { result } = renderHook(() => useCanSegmentCamera()); + // Fails closed: no flash of a control that is about to disappear. + expect(result.current).toBe(false); + }); + + it("probes once for the whole session, however many consumers ask", async () => { + mocks.probeSegmentationSupport.mockResolvedValue("ready"); + const a = renderHook(() => useCanSegmentCamera()); + const b = renderHook(() => useCanSegmentCamera()); + await waitFor(() => expect(a.result.current).toBe(true)); + await waitFor(() => expect(b.result.current).toBe(true)); + expect(mocks.probeSegmentationSupport).toHaveBeenCalledTimes(1); + }); +}); diff --git a/src/native/hooks/useSegmentationSupport.ts b/src/native/hooks/useSegmentationSupport.ts new file mode 100644 index 000000000..3a6873892 --- /dev/null +++ b/src/native/hooks/useSegmentationSupport.ts @@ -0,0 +1,60 @@ +/** + * Whether this machine can segment the camera, probed once per session. + * + * A property of the installation, not of a view: the same three things have to line up + * whatever is on screen — the native addon, the ONNX Runtime library, and the model. So it + * is memoised in a module-level promise, exactly as `useCompositorBackend` is. + * + * Returns `null` until the probe resolves, so callers render nothing rather than flashing a + * control that may be about to disappear. + */ + +import { useEffect, useState } from "react"; +import { probeSegmentationSupport } from "../compositorViewClient"; +import type { SegmentationSupport } from "../contracts"; + +let cached: Promise | null = null; + +function probeOnce(): Promise { + if (!cached) { + cached = probeSegmentationSupport(); + } + return cached; +} + +/** Test seam: drops the memoised probe so each test observes its own mock. */ +export function resetSegmentationSupportProbeForTests(): void { + cached = null; +} + +export function useSegmentationSupport(): SegmentationSupport | null { + const [support, setSupport] = useState(null); + + useEffect(() => { + let disposed = false; + probeOnce().then((value) => { + if (!disposed) { + setSupport(value); + } + }); + return () => { + disposed = true; + }; + }, []); + + return support; +} + +/** + * True only once the machine has answered that it CAN segment. + * + * Fails closed on purpose. `null` (still probing) is not capability, and neither is + * `"no-runtime"` / `"no-model"` / `"none"` — the whole point is that a control which cannot + * do anything must not be offered. Showing it and having it do nothing is the failure this + * replaces: the previous gate read `process.platform`, which hid the control on Linux builds + * that could segment and showed it on Intel Macs, for which upstream publishes no ONNX + * binary at all. + */ +export function useCanSegmentCamera(): boolean { + return useSegmentationSupport() === "ready"; +}