Skip to content

FUG-120: native iOS exposure bridge (getUserMedia can't set exposure on WebKit) - #91

Open
issuefleet[bot] wants to merge 1 commit into
mainfrom
agent/fug-120-ios-exposure-control-doesn-t-wor
Open

FUG-120: native iOS exposure bridge (getUserMedia can't set exposure on WebKit)#91
issuefleet[bot] wants to merge 1 commit into
mainfrom
agent/fug-120-ios-exposure-control-doesn-t-wor

Conversation

@issuefleet

@issuefleet issuefleet Bot commented Aug 14, 2026

Copy link
Copy Markdown
Contributor

Problem

The exposure slider (Advanced ▸ Manual override, while mapping) does nothing on iOS.

The capture path is getUserMedia + MediaStreamTrack.applyConstraints (web/src/xr/mediaStreamCapture.ts), and exposureControl.ts deliberately locks camera exposure down so auto-exposure stops blowing the LEDs out in the dark. But WebKit implements none of the MediaStream Image-Capture exposure extensions (exposureMode / exposureTime / exposureCompensation / iso): on iOS track.getCapabilities() reports no exposure controls, so planExposure() finds nothing to set and both the manual slider and the auto servo are silent no-ops. This is exactly the "the webview may not expose this" case the ticket anticipated.

Fix — a native AVFoundation bridge

Exposure is a property of the physical AVCaptureDevice, not of a capture session. WebKit's getUserMedia opens the back wide-angle camera (the only device type its AVVideoCaptureSource uses) — the same shared device singleton. So a new first-party Capacitor plugin @splanc/exposure-bridge (same SPM shape as the wss/speech bridges) configures that device with setExposureModeCustom(duration:iso:), and the lock applies to the frames the WKWebView is already rendering. No second capture session, no ownership of WebKit's.

Semantics mirror the existing planExposure exactly, so behaviour is uniform across platforms:

  • target in [0,1] maps 0 = shortest exposure (darkest, least LED bloom) → 1 = longest.
  • ISO is pinned to the sensor minimum to hold gain down.
  • maxExposureMs (the Nyquist cap for the servo, the user's ceiling for manual override) caps the longest exposure — computed against the device's real activeFormat duration range.

Changes

  • web/native-plugins/exposure-bridge/ExposureBridgePlugin.swift (capabilities / setExposure / clearExposure) + Package.swift / package.json / BUILD.bazel / .gitignore.
  • web/src/xr/nativeExposure.ts — binds the plugin via the injected Capacitor global (registerNativePlugin, no @capacitor/core import), gated on isIosNative().
  • web/src/xr/mediaStreamCapture.tssetExposure() routes to the native bridge on iOS, falling back to the web applyConstraints path everywhere else; stop() restores continuous auto-exposure.
  • Link dep in web/package.json + pnpm-lock.yaml, doc §4.6 in docs/design/ios-support.md, comment touch-ups in web/BUILD.bazel and the macOS CI workflow.

No UI change — the existing manual-override slider and exposure servo just start working on iOS. No new Info.plist string — camera access is already granted via getUserMedia, and reading/writing device exposure needs no extra entitlement. Non-iOS (browser PWA, Android) behaviour is unchanged: nativeExposureAvailable() is false and the old path runs verbatim.

Testing

  • bazel build //web:web_tests_js (full app + test tsc), //web:dist (Vite/Rolldown PWA bundle), and //web:exposureControl_test / //web:exposure_test all pass. Pre-commit hooks (prettier, buildifier, …) clean.
  • Not yet verified on a physical iPhone — the container has no iOS device, and the Simulator has no real camera exposure to observe. On-device check for a reviewer: enter mapping, open Advanced ▸ Manual override, and drag Camera exposure — the preview should visibly darken toward 0 and brighten toward 1 (and the HUD line should read native: manual exposure …ms, iso …). The ?exposure=servo auto path should likewise track scene brightness.

🤖 Generated with Claude Code

Closes-Linear: FUG-120 (https://linear.app/fughilli/issue/FUG-120/ios-exposure-control-doesnt-work)

…on WebKit)

The capture path is getUserMedia + MediaStreamTrack.applyConstraints, and
exposureControl.ts locks exposure DOWN so auto-exposure stops blowing the LEDs
out. But WebKit implements none of the MediaStream Image-Capture exposure
extensions (exposureMode/exposureTime/exposureCompensation/iso): on iOS
getCapabilities() returns nothing, planExposure() finds no control, and the
manual slider + auto servo are silent no-ops — the reported bug.

Fix: a first-party Capacitor plugin @splanc/exposure-bridge (AVFoundation),
same SPM shape as the wss/speech bridges. Exposure is a property of the physical
AVCaptureDevice, not of a capture session, and WebKit's getUserMedia opens the
back wide-angle camera — the same device singleton — so configuring it with
setExposureModeCustom(duration:iso:) applies to the frames the WKWebView is
already rendering (no second capture session).

- ExposureBridgePlugin.swift: capabilities/setExposure/clearExposure. Mirrors
  planExposure semantics — target 0..1 maps shortest→longest, ISO pinned to the
  sensor minimum, maxExposureMs (Nyquist / manual ceiling) caps the longest
  exposure using the device's real activeFormat range.
- xr/nativeExposure.ts binds it via the injected Capacitor global
  (registerNativePlugin, no @capacitor/core import), gated on isIosNative().
- MediaStreamCaptureSource.setExposure routes to the native bridge on iOS,
  falling back to the web applyConstraints path elsewhere; stop() restores
  continuous auto-exposure. No UI change — the existing slider/servo just start
  working on iOS.
- Scaffolding parity: Package.swift/package.json/BUILD.bazel/.gitignore, link
  dep in web/package.json + pnpm-lock, doc §4.6, CI/BUILD comments. No new
  Info.plist string (camera is already granted via getUserMedia).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@github-actions

Copy link
Copy Markdown
PR Preview Action v1.8.1

QR code for preview link

🚀 View preview at
https://fughilli.github.io/splanc/pr-preview/pr-91/

Built to branch gh-pages at 2026-08-14 22:32 UTC.
Preview will be ready when the GitHub Pages deployment is complete.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

0 participants