From 121ee5943497e93117cc6cc3f36b61de1b435fac Mon Sep 17 00:00:00 2001 From: Dario Lencina Date: Fri, 21 Aug 2026 09:25:32 -0700 Subject: [PATCH 01/14] Manual exposure: engine, wire protocol, capability gate (#206 PR 1) Adds ExposurePolicy (pure clamp/recording-cap/unsupported decisions), SetExposure=33 + ExposureState on the wire, supports_manual_exposure capability, one-owner apply in CaptureEngine re-applied on device swap and format change, coordinator gate pinned by loopback tests, and a hardware probe in CaptureIntegrationTests. No UI yet. Design: Docs/pro-controls.md. Co-Authored-By: Claude Fable 5 --- Docs/pro-controls.md | 387 ++++++++++++++++++ RemoteCam/CameraControlling.swift | 3 + RemoteCam/CameraRig.swift | 7 + RemoteCam/CaptureEngine.swift | 116 +++++- RemoteCam/ExposurePolicy.swift | 107 +++++ RemoteCam/FlatBufferSchemas.fbs | 39 +- RemoteCam/FlatBufferSchemas_generated.swift | 138 ++++++- RemoteCam/MonitorPresenter.swift | 7 + RemoteCam/MonitorViewModel.swift | 6 + RemoteCam/RemoteCmdFlatBuffers.swift | 96 ++++- RemoteCam/RemoteCmds.swift | 38 ++ RemoteCam/SessionCoordinator.swift | 53 +++ RemoteCam/UICmds.swift | 10 + RemoteCamTests/CaptureIntegrationTests.swift | 50 +++ RemoteCamTests/ExposurePolicyTests.swift | 79 ++++ RemoteCamTests/LoopbackSessionTests.swift | 55 +++ RemoteCamTests/MonitorPresenterTests.swift | 31 ++ .../RemoteCmdSerializationTests.swift | 53 +++ RemoteCamTests/SessionTestSupport.swift | 16 + RemoteShutter.xcodeproj/project.pbxproj | 8 + 20 files changed, 1288 insertions(+), 11 deletions(-) create mode 100644 Docs/pro-controls.md create mode 100644 RemoteCam/ExposurePolicy.swift create mode 100644 RemoteCamTests/ExposurePolicyTests.swift diff --git a/Docs/pro-controls.md b/Docs/pro-controls.md new file mode 100644 index 00000000..56d7b1be --- /dev/null +++ b/Docs/pro-controls.md @@ -0,0 +1,387 @@ +# Pro controls — manual exposure & Cinematic video + +Issue [#206](https://github.com/security-union/remote-shutter/issues/206) asks for +"advanced settings": shutter speed for long exposures, ISO, and aperture. This +document covers all three as two remote-driven controls: + +- **Manual exposure** — shutter speed + ISO, Auto/Manual, photo and video. +- **Cinematic video** — iOS 26 Cinematic mode with a simulated-aperture dial + (f/1.4 … f/16 depending on device). This *is* iPhone's "aperture": the + physical iris is fixed, so Apple exposes depth-of-field as a video effect. + +Both follow the shape of tap-to-focus (`FocusAtPoint`, action 22): a +capability-gated wire command, one `CameraControlling` method, one +`sessionQueue`-confined mutation in `CaptureEngine`, and a monitor control +usable by a person standing across the room from the phone. + +> Each control is gated on its own capability flag +> (`supports_manual_exposure`, `supports_cinematic_video`), so a 10.0.x camera +> pairs exactly as before and **a button only appears when the connected camera +> offers that feature**. The UI ships behind +> `FeatureFlags.ENABLE_PRO_CONTROLS`; the user-facing unlock mirrors +> tap-to-focus (`StoreManager.hasProControlsFeature()`). + +## What Apple actually lets us do + +Facts below are from the Xcode 26.6 SDK headers (`AVCaptureDevice.h`, +`AVCaptureInput.h`, `AVCaptureMetadataOutput.h`), not memory. + +| Control | API | Availability | Notes | +|---|---|---|---| +| Shutter (exposure duration) | `setExposureModeCustom(duration:iso:)` | iOS 8+, Catalyst 14+ | Range `activeFormat.minExposureDuration…maxExposureDuration` (≈1/10 000 s … ⅓–1 s by device/format). | +| ISO | same call | same | Range `activeFormat.minISO…maxISO`. `AVCaptureDevice.currentISO` / `.currentExposureDuration` change only one. | +| Simulated aperture | `AVCaptureDeviceInput.simulatedAperture` | **iOS 26+, Catalyst 26+** | Only while `isCinematicVideoCaptureEnabled`; range `activeFormat.min/maxSimulatedAperture` (0 = not adjustable); **throws if set during a recording**. | +| Cinematic video | `AVCaptureDeviceInput.isCinematicVideoCaptureEnabled` | iOS 26+ | Requires `activeFormat.isCinematicVideoCaptureSupported`. Effect is rendered into **video data output, movie output, and preview** alike. | +| Exposure bias (EV) | `setExposureTargetBias(_:)` | everywhere | Deferred (see end). | + +Constraints that drive the design: + +1. **Virtual devices refuse custom exposure.** The header states + `builtInDualCamera` (and by extension Dual-Wide / Triple) "does not support + `AVCaptureExposureModeCustom`". `CaptureEngine.preferredCamera(for:)` + deliberately picks the Triple/Dual-Wide/Dual virtual device for the back + position so zoom auto-switches lenses. Manual exposure therefore needs the + **physical constituent** lens. +2. **Shutter and frame rate are coupled.** A duration longer than + `activeVideoMaxFrameDuration` silently lengthens it (preview fps drops); a + later frame-rate change shortens the exposure. The engine owns the order + of operations and never rebuilds frame durations as `CMTimeMake(1, fps)` + (existing Catalyst invariant). +3. **Cinematic is a session-level reconfiguration, not a device property.** + Enabling it is "lengthy" and must happen inside + `beginConfiguration`/`commitConfiguration`; it pins `focusMode` to + continuous AF (changing it throws); it narrows zoom to + `videoMin/MaxZoomFactorForCinematicVideo` and frame rate to + `videoFrameRateRangeForCinematicVideo`; it is incompatible with + `AVCaptureDepthDataOutput`; and support flips to `false` (auto-disabling + itself) whenever the camera or format changes. +4. **Ranges are per device *and* per format.** Every lens switch, camera + toggle, or video-quality change can invalidate the monitor's dials. The + camera is the source of truth and re-reports ranges + current values after + any change, exactly as zoom does. + +## Components & connections + +```mermaid +flowchart LR + classDef actor fill:#7c3aed,color:#fff,stroke:#4c1d95,stroke-width:3px + classDef swiftui fill:#0ea5e9,color:#fff,stroke:#075985 + classDef viewmodel fill:#a5f3fc,color:#0e7490,stroke:#0e7490 + classDef worker fill:#fbbf24,color:#78350f,stroke:#b45309 + classDef plain fill:#e5e7eb,color:#111827,stroke:#6b7280 + classDef pure fill:#bbf7d0,color:#14532d,stroke:#166534 + + subgraph Remote["📱 REMOTE"] + PANEL["ProControlsPanel
Exposure · Cinematic"]:::swiftui + MVM["MonitorViewModel
exposure / cinematic snapshots"]:::viewmodel + SC1{{"SessionCoordinator
peerSupports… gates"}}:::actor + PANEL -- "UICmd.SetExposure / SetCinematic
(20 Hz throttle, trailing flush)" --> SC1 + SC1 -- "updateExposure / updateCinematic" --> MVM + MVM -- "@Published" --> PANEL + end + + subgraph Camera["📱 CAMERA"] + SC2{{"SessionCoordinator"}}:::actor + RIG["CameraRig"]:::plain + POL["ExposurePolicy · CinematicPolicy
pure functions, unit-tested"]:::pure + ENG["CaptureEngine · sessionQueue
exposureIntent · cinematicIntent"]:::worker + CVM["CameraViewModel
proReadout"]:::viewmodel + SC2 -- "await ctrl.setExposure / setCinematic" --> RIG --> ENG + ENG -- "resolve(intent, facts)" --> POL + ENG -- "readout" --> CVM + end + + SC1 == "RemoteCmd.SetExposure (33) · SetCinematic (34)" ==> SC2 + SC2 == "…Resp · ExposureState / CinematicState" ==> SC1 +``` + +## Wire protocol (`FlatBufferSchemas.fbs`, append-only) + +``` +enum ExposureMode : byte { Unknown = 0, Auto = 1, Manual = 2 } + +// CommandAction +SetExposure = 33 +SetCinematic = 34 + +// CommandParameters — appended +exposure_mode: ExposureMode; +exposure_duration_seconds: double; // 0 = keep current +exposure_iso: float; // 0 = keep current +cinematic_enabled: bool; +simulated_aperture: float; // 0 = keep current + +table ExposureState { + mode: ExposureMode; + duration_seconds: double; // currently applied + iso: float; + min_duration_seconds: double; // activeFormat range + max_duration_seconds: double; + min_iso: float; + max_iso: float; +} + +table CinematicState { + enabled: bool; + simulated_aperture: float; // currently applied + min_simulated_aperture: float; // 0 = fixed aperture, hide the dial + max_simulated_aperture: float; + default_simulated_aperture: float; + aperture_locked: bool; // true while recording — dial disabled + not_enough_light: bool; // from cinematicVideoCaptureSceneMonitoringStatuses +} + +// CameraCapabilities — appended +supports_manual_exposure: bool; // active device supports .custom +exposure: ExposureState; // so the panel opens populated +supports_cinematic_video: bool; // iOS 26+ and active format supports it +cinematic: CinematicState; + +// CameraStateResponse — appended +exposure: ExposureState; // echoed on SetExposureResp +cinematic: CinematicState; // echoed on SetCinematicResp +``` + +Durations travel as seconds (`double`) and are clamped back into the device's +own `CMTime` range on the camera — the wire never carries a timescale. + +`RemoteCmd.SetExposure/Resp`, `RemoteCmd.SetCinematic/Resp` in +`RemoteCmds.swift`; encode/decode in `RemoteCmdFlatBuffers.swift` next to +`SetZoom`. `CameraCapabilitiesResp` gains the two flags and two state tables. +`not_enough_light` changes are pushed by the camera on the existing +`CameraStateReport` channel (action 31) so the monitor hint is live without +polling. + +## Monitor → camera path (both controls) + +1. **Panel** (`ProControlsPanel` inside `MonitorChrome`). Dragging a dial emits + `UICmd.SetExposure` / `UICmd.SetCinematic` through the same 20 Hz + trailing-edge throttle `handleZoomChange` uses. Locked users are routed to + the paywall before anything is sent (mirrors `handleFocusTap`). +2. **Coordinator send gate** in `.monitor` (photo and video-mode handlers): + `guard peerSupportsManualExposure` / `guard peerSupportsCinematicVideo` + else drop → `sendMessage(...)`. No new `SessionState`: like zoom, the + monitor stays in `.monitor` and absorbs the `Resp` when it arrives, so a + slow or lost response can never wedge the screen. +3. **Camera handler** (root camera state and video-mode state, next to + `SetZoom`): `let state = try await ctrl.setExposure(intent)` → + `sendOrGoToScanning(RemoteCmd.SetExposureResp(state))`; same for + cinematic. +4. **Rig** forwards to the engine and updates `cameraViewModel.proReadout`. +5. **Engine** (`sessionQueue`, `lockForConfiguration`) — below. + +## Engine: intent → policy → apply + +`CaptureEngine` stores two values — `exposureIntent` (`.auto` | +`.manual(duration: CMTime, iso: Float)`) and `cinematicIntent` (`.off` | +`.on(aperture: Float?)`) — and exactly one function applies each. The policies +are pure, `Sendable`, table-tested value types; no AVFoundation objects cross +their boundary (they take a `DeviceFacts` struct of ranges and booleans). + +### Exposure + +``` +applyExposureIntentLocked() + plan = ExposurePolicy.resolve(intent, facts, recording: isRecording) + .auto: exposureMode = .continuousAutoExposure; restore frame durations from the last setVideoQuality + .manual(d, iso): setExposureModeCustom(duration: d, iso: iso) + .unsupported: intent = .auto → same as .auto; Resp says mode = Auto + return ExposureState(device + activeFormat ranges) +``` + +- clamps duration/ISO into the format's range; +- **while recording** caps duration at the active max frame duration so the + clip's frame rate never changes mid-take; in photo mode a long shutter may + slow the preview; +- `isExposureModeSupported(.custom) == false` → `.unsupported`. + +**Virtual device → physical lens.** Entering Manual on a virtual device swaps +the input to the physical lens currently in use +(`device.activePrimaryConstituent`, iOS 15+), carrying zoom over by the ratio +of the two zoom spaces; returning to Auto swaps back to +`preferredCamera(for:)`. While Manual is on, zoom is the physical lens's own +range (no auto lens switching); the existing `SetZoomResp` range echo already +informs the monitor's zoom slider. + +### Cinematic + +``` +applyCinematicIntentLocked() + plan = CinematicPolicy.resolve(intent, facts, recording: isRecording, mode: currentCameraMode) + .enable(format, aperture): + session.beginConfiguration() + activeFormat = format // first format with isCinematicVideoCaptureSupported matching the chosen resolution + input.isCinematicVideoCaptureEnabled = true // pins focusMode to continuous AF + metadataOutput.metadataObjectTypes = metadataOutput.requiredMetadataObjectTypesForCinematicVideoCapture + input.simulatedAperture = aperture // only when min > 0 and not recording + clamp videoZoomFactor into videoMin/MaxZoomFactorForCinematicVideo + frame durations from videoFrameRateRangeForCinematicVideo (its own CMTimes) + session.commitConfiguration() + .apertureOnly(a): input.simulatedAperture = a // no session reconfig + .disable: beginConfiguration; enabled = false; restore the format/fps chosen by setVideoQuality; commitConfiguration + .rejected(reason): .recording (aperture change mid-take) / .photoMode / .unsupported → state unchanged, Resp carries current truth + return CinematicState(input + activeFormat + sceneMonitoringStatuses) +``` + +- Cinematic is **video-mode only**; switching the camera to photo mode + disables it and the `Resp`/capability refresh tells the monitor. +- An `AVCaptureMetadataOutput` is added to the session only while Cinematic + is on (the header requires its `metadataObjectTypes` be set to the Cinematic + set); nothing else in the app consumes it. +- KVO on `cinematicVideoCaptureSceneMonitoringStatuses` (main-hopped via the + rig) drives `not_enough_light` and the on-camera/monitor hint. +- Tap-to-focus while Cinematic is on routes to + `setCinematicVideoTrackingFocus(at: poi, focusMode: .strong)` instead of + touching `focusMode` (which would throw). The same `FocusPointMapping` + produces the device-space point. + +### One owner, three re-entry points + +Both intents are re-applied — never touched ad hoc — from: + +| Trigger | What happens | +|---|---| +| `setExposure` / `setCinematic` from the wire | store intent, apply, return state | +| `swapToDeviceLocked` / lens switch / camera toggle | re-apply both intents to the new device (clamped to its ranges; each falls back to its off/auto state if unsupported). State rides on the capabilities refresh the monitor already requests after a toggle. | +| `setVideoQuality` (format / fps change) | re-apply after the format change; ranges, the recording cap and Cinematic format support all changed | + +Existing focus code changes: `setFocusExposurePointLocked` must not reset +`exposureMode` while a manual intent is active, and must use the Cinematic +focus API while Cinematic is on; `resetFocusExposureToAutoLocked` likewise. +Exiting the camera screen and a session disconnect reset both intents (the +next session starts clean, like zoom and torch). + +### Hardware probe first + +A code read cannot settle four things; per house rule, step 1 of +implementation is a probe on a real iPhone (debug log + a +`CaptureIntegrationTests` case), and the dependent pieces are built only as +the probe dictates: + +1. Does current iOS reject `.custom` on Triple/Dual-Wide (→ is the lens swap + needed)? +2. Can custom exposure and Cinematic be active together? If not, + `CinematicPolicy` makes them mutually exclusive and the panel shows that. +3. Do our `AVCaptureVideoDataOutput` frames carry the Cinematic effect with + the pixel format `FrameStreamingCoordinator`/`RecordingPipeline` request? +4. How long is the preview interruption on enable/disable? + +## UI + +Follows the HIG for camera controls: values a photographer recognizes, direct +and reversible adjustments, current state always visible on both devices. + +**Remote (monitor)** +- Two chrome buttons, each shown only when its capability is advertised: + **Exposure** (`dial.medium`) and **Cinematic** (`camera.aperture`, video + mode only). Each opens the same bottom panel used for quality settings. +- **Exposure**: segmented `Auto | Manual`. Auto shows a live readout of what + the camera is choosing (`1/120 · ISO 64`). Manual shows two **detented + horizontal dials** (`ProDial`): shutter at standard stops (1/8000 … ¼, ⅓, + ½, 1 s, filtered to the device range) and ISO in ⅓-stops; bold value label; + Reset-to-Auto. +- **Cinematic**: an on/off toggle and, when `min_simulated_aperture > 0`, an + aperture dial in ⅓-stops (f/1.4, 1.6, 1.8, 2, 2.2 … 16) filtered to the + range, defaulting to `default_simulated_aperture`. While recording the dial + is disabled with the caption "Aperture is set before recording". A "Scene + too dark for Cinematic" hint appears when the camera reports it. +- Dials give haptic ticks on detents (`.sensoryFeedback` on iOS 17+, + `UISelectionFeedbackGenerator` below) and display the **echoed** value from + the camera, never the dragged one — the remote never claims a state the + camera did not confirm. +- Accessibility: dials are `.adjustable` elements reading "1/125 second", + "ISO 400", "f/2.8"; Dynamic Type labels; 44 pt targets; same dismissal + gesture as the quality panel. +- Mac Catalyst: identical SwiftUI; dials take scroll-wheel/trackpad through + the existing gesture layer. Mac cameras generally advertise neither flag, so + neither button appears. + +**Camera phone** +- A readout chip on the preview, top edge, while a pro control is active: + `M 1/125 ISO 400`, `CINEMATIC f/2.8`, or both. Mirrors `RemoteFocusIndicator` + in `CameraViewModel` but persists until the control is off. The "too dark" + hint also shows here, next to the chip. + +**Watch** — untouched. **Multicam director** — untouched; cameras simply +advertise the flags and the 1:1 path works. Broadcasting to N cameras is a +follow-up. + +## Monetization + +One unlock for both controls — "Pro Controls" — mirroring tap-to-focus: +product ID `"10"`, `PurchaseKey.proControls`, +`StoreManager.hasProControlsFeature()` (`hasFullAccess() || purchased`), +`.proControlsAcquired` notification, a `PurchaseItem` in `SettingsViewModel` +and `WelcomeViewModel`, localized IAP strings in +`fastlane/iap_localizations.json` (name ≤ 30, description ≤ 45 chars, 15 +locales). The ASC product is created by hand. + +The wire-level gate and the purchase gate stay separate and additive: the +capability gate protects old peers and hides buttons the camera cannot honor; +the purchase gate protects revenue. + +## Design rules + +1. **One owner per device setting.** Only `applyExposureIntentLocked()` + touches exposure mode/duration/ISO; only `applyCinematicIntentLocked()` + touches `isCinematicVideoCaptureEnabled`/`simulatedAperture`. Every other + path (focus, device swap, quality change) re-applies the intent. +2. **Camera is the source of truth.** The monitor renders only echoed state; + ranges always come from the camera's active format. +3. **Decisions are pure.** Clamping, the recording caps, format selection, + mutual exclusion and unsupported fallbacks live in `ExposurePolicy` / + `CinematicPolicy` with table-driven tests. +4. **No new transient state.** These are settings, like zoom, not requests + that can wedge the monitor. +5. **Legacy peers never see actions 33/34** — pinned by loopback tests, like + `testFocusAtPointIsNeverSentToLegacyPeer`. +6. **Buttons exist only when the camera advertises the capability.** No + "unsupported" alerts; unavailable controls are absent, not disabled. +7. **Frame durations are clamped into the `AVFrameRateRange`'s own + `CMTime`s** (existing invariant); neither control rebuilds them. +8. **Cinematic session reconfiguration happens only inside + `begin/commitConfiguration` on `sessionQueue`** and never while + `isRecording`. + +## Tests + +- `ExposurePolicyTests` / `CinematicPolicyTests` — clamping, recording caps, + format selection, photo-mode rejection, unsupported fallback, dial-stop + generation from a range (pure). +- `RemoteCmdFlatBuffersTests` — both commands, responses and capabilities + round-trip; legacy buffers decode with `mode = Unknown` / `enabled = false`. +- `LoopbackSessionTests` — happy path across the wire for each; never sent to + a peer that did not advertise the flag; re-sync after camera toggle; + Cinematic dropped when the camera is in photo mode. +- Snapshot tests — monitor panel (Auto, Manual, Cinematic on, dial locked + while recording), camera readout chip. +- `CaptureIntegrationTests` (real hardware, skipped on CI) — the four probe + questions above; applied duration/ISO/aperture read back within tolerance; + frame rate preserved while recording. +- Full suite under Thread Sanitizer. + +## Deferred (tracked, not in v1) + +- **Exposure bias (EV ±)** — `setExposureTargetBias`, works on virtual + devices and in Auto; a cheap follow-up slice. +- **Cinematic focus transitions from the remote** (tap a subject → strong + tracking focus, rack focus between two subjects) — the API exists + (`setCinematicVideoTrackingFocus(detectedObjectID:)`) but needs detected + objects streamed to the monitor. +- **Editable Cinematic files.** Our `AVAssetWriter` path bakes the effect + into the clip; re-editing focus/aperture in Photos needs + `AVCaptureMovieFileOutput`'s Cinematic metadata tracks. +- **Portrait-style photos** (depth + `CIContext.depthBlurEffectFilter`) — + the photo counterpart of aperture; separate capture path. +- **Long exposures beyond the sensor max (~1 s)** — frame stacking. +- **White balance**, **Watch / multicam** exposure control. + +## Known debts + +- The virtual-device swap (if the probe confirms it) and the Cinematic + reconfiguration both interrupt the preview momentarily; acceptable for a + deliberate mode switch, but measured and shown as a brief "Switching…" + state on the monitor rather than a frozen frame. +- `ExposureState`/`CinematicState` inside `CameraCapabilities` duplicate the + echoes in `CameraStateResponse`; kept so the panel is populated on open + without a round-trip. diff --git a/RemoteCam/CameraControlling.swift b/RemoteCam/CameraControlling.swift index f017a89b..656dd73f 100644 --- a/RemoteCam/CameraControlling.swift +++ b/RemoteCam/CameraControlling.swift @@ -46,6 +46,9 @@ protocol CameraControlling: AnyObject, Sendable { /// normalized (0..1) in the upright display image, origin top-left. /// Fire-and-forget: a no-op if the active device has no point of interest. func focusAtPoint(x: Float, y: Float) async throws + /// Auto or manual (shutter + ISO) exposure. The device clamps into its + /// active format's range; the returned state is the truth to echo. + func setExposure(_ intent: ExposureIntent) async throws -> ExposureState func switchLens(to lensType: CameraLensType) async throws -> (CameraLensType, [CameraLensType], CGFloat, RemoteCmd.ZoomRange) func toggleFlash() async throws -> AVCaptureDevice.FlashMode func toggleTorch() async throws -> AVCaptureDevice.TorchMode diff --git a/RemoteCam/CameraRig.swift b/RemoteCam/CameraRig.swift index be884219..70fae1eb 100644 --- a/RemoteCam/CameraRig.swift +++ b/RemoteCam/CameraRig.swift @@ -145,6 +145,9 @@ final class CameraRig: @unchecked Sendable { engine.onStatusChanged = { [weak self] in self?.updateCameraStatus() } + // The exposure policy caps a long shutter at the frame duration while + // a clip is rolling; recording truth lives in the pipeline. + engine.isRecordingProvider = { [pipeline] in pipeline.isRecording } // Captures the session ref (not self) so recording acks/responses still // reach the actor if the rig deallocates mid-recording. pipeline.sendMessage = { [session] msg in @@ -516,6 +519,10 @@ extension CameraRig: CameraControlling { try await engine.setZoom(zoomFactor: zoomFactor) } + func setExposure(_ intent: ExposureIntent) async throws -> ExposureState { + try await engine.setExposure(intent) + } + func focusAtPoint(x: Float, y: Float) async throws { // Show the same reticle the monitor draws, so the person holding the // camera sees the tap land — on every command, even where the device diff --git a/RemoteCam/CaptureEngine.swift b/RemoteCam/CaptureEngine.swift index 920adcd8..ab1a3e5a 100644 --- a/RemoteCam/CaptureEngine.swift +++ b/RemoteCam/CaptureEngine.swift @@ -120,6 +120,15 @@ final class CaptureEngine: NSObject, AVCapturePhotoCaptureDelegate { // MARK: - Aspect Ratio var currentAspectRatio: AspectRatio = .sixteenNine + // MARK: - Manual Exposure + /// What the monitor asked for. The device is made to match it by exactly + /// one function, `applyExposureIntentLocked()`, which every path that + /// disturbs the device (swap, format change) calls again. sessionQueue-confined. + private var exposureIntent: ExposureIntent = .auto + /// Recording truth lives in the rig's pipeline; the policy needs it to cap + /// a long shutter at the frame duration while a clip is rolling. + var isRecordingProvider: () -> Bool = { false } + // MARK: - Callbacks to the view controller /// Forwards a finished photo capture. `(data, nil)` on success, `(nil, error)` /// on failure. The VC relays this to the session actor exactly as before. @@ -264,6 +273,10 @@ final class CaptureEngine: NSObject, AVCapturePhotoCaptureDelegate { func stopSession() { sessionQueue.async { self.isExpectedToRun = false + // The AVCaptureDevice outlives this session: leave it in auto so + // the next session (or the system Camera app) starts clean. + self.exposureIntent = .auto + _ = self.applyExposureIntentLocked() if self.captureSession.isRunning { self.captureSession.stopRunning() } @@ -368,6 +381,7 @@ final class CaptureEngine: NSObject, AVCapturePhotoCaptureDelegate { captureSession.commitConfiguration() applyDesiredTorchLocked() // restore torch onto the new camera (no-op if it has none) resetFocusExposureToAutoLocked() // a stale focus point must not carry across a device change + _ = applyExposureIntentLocked() // the new device must match the monitor's exposure intent // Swapping away from a dead device must also revive a session that a // runtime error stopped — otherwise the new camera never delivers. if isExpectedToRun && !captureSession.isRunning { @@ -923,6 +937,12 @@ final class CaptureEngine: NSObject, AVCapturePhotoCaptureDelegate { debugLog("🔍 DEBUG: - Found \(videoDevices.count) devices for \(positionName) position") for device in videoDevices { debugLog("🔍 DEBUG: - \(device.localizedName) (\(device.deviceType.rawValue))") + // Pro-controls hardware probe (Docs/pro-controls.md): which devices + // can do custom exposure, and what the format allows. + let format = device.activeFormat + debugLog("🌗 EXPOSURE PROBE: \(device.localizedName) custom=\(device.isExposureModeSupported(.custom)) " + + "shutter \(CMTimeGetSeconds(format.minExposureDuration))–\(CMTimeGetSeconds(format.maxExposureDuration))s " + + "ISO \(format.minISO)–\(format.maxISO)") } guard !videoDevices.isEmpty else { @@ -1041,6 +1061,11 @@ final class CaptureEngine: NSObject, AVCapturePhotoCaptureDelegate { // release the director UI ships. supportsMulticam: FeatureFlags.ENABLE_MULTICAM, previewMode: CameraPreviewModeStore().load(), + // A property of the ACTIVE device (virtual multi-lens devices and + // most Mac cameras refuse .custom), so it is re-advertised on every + // capabilities refresh after a swap. + supportsManualExposure: currentDevice.isExposureModeSupported(.custom), + exposure: exposureStateLocked(currentDevice), error: nil ) @@ -1126,7 +1151,9 @@ final class CaptureEngine: NSObject, AVCapturePhotoCaptureDelegate { device.focusMode = .autoFocus } } - if device.isExposurePointOfInterestSupported { + // In manual exposure a tap moves only focus: re-enabling auto exposure + // here would silently throw away the monitor's shutter/ISO. + if device.isExposurePointOfInterestSupported && exposureIntent == .auto { device.exposurePointOfInterest = poi if device.isExposureModeSupported(.continuousAutoExposure) { device.exposureMode = .continuousAutoExposure @@ -1150,12 +1177,96 @@ final class CaptureEngine: NSObject, AVCapturePhotoCaptureDelegate { device.focusPointOfInterest = center if device.isFocusModeSupported(.continuousAutoFocus) { device.focusMode = .continuousAutoFocus } } - if device.isExposurePointOfInterestSupported { + if device.isExposurePointOfInterestSupported && exposureIntent == .auto { device.exposurePointOfInterest = center if device.isExposureModeSupported(.continuousAutoExposure) { device.exposureMode = .continuousAutoExposure } } } + // MARK: - Manual Exposure + + /// Stores the monitor's intent and makes the device match it. Returns the + /// device's exposure truth afterwards (the response payload). + func setExposure(_ intent: ExposureIntent) async throws -> ExposureState { + try await onSessionQueueThrowing { + self.exposureIntent = intent + guard let state = self.applyExposureIntentLocked() else { + throw NSError(domain: "No camera device available", code: 0, userInfo: nil) + } + return state + } + } + + /// The ranges and booleans the policy decides on, read from the active device. + private func exposureFactsLocked(_ device: AVCaptureDevice) -> ExposureFacts { + let format = device.activeFormat + return ExposureFacts( + supportsCustom: device.isExposureModeSupported(.custom), + minDurationSeconds: CMTimeGetSeconds(format.minExposureDuration), + maxDurationSeconds: CMTimeGetSeconds(format.maxExposureDuration), + minISO: format.minISO, + maxISO: format.maxISO, + maxFrameDurationSeconds: CMTimeGetSeconds(device.activeVideoMaxFrameDuration), + currentDurationSeconds: CMTimeGetSeconds(device.exposureDuration), + currentISO: device.iso) + } + + private func exposureStateLocked(_ device: AVCaptureDevice) -> ExposureState { + let facts = exposureFactsLocked(device) + return ExposureState( + mode: device.exposureMode == .custom ? .manual : .auto, + durationSeconds: facts.currentDurationSeconds, + iso: facts.currentISO, + minDurationSeconds: facts.minDurationSeconds, + maxDurationSeconds: facts.maxDurationSeconds, + minISO: facts.minISO, + maxISO: facts.maxISO) + } + + /// The ONE place that sets the device's exposure mode / duration / ISO. + /// Called with a fresh intent from the wire, and again after every device + /// swap and format change so the hardware always reflects `exposureIntent`. + /// Returns nil only when there is no device. + @discardableResult + private func applyExposureIntentLocked() -> ExposureState? { + dispatchPrecondition(condition: .onQueue(sessionQueue)) + guard let device = videoDeviceInput?.device else { return nil } + let facts = exposureFactsLocked(device) + let plan = ExposurePolicy.resolve(exposureIntent, facts: facts, isRecording: isRecordingProvider()) + + switch plan { + case .unsupported: + debugLog("🌗 EXPOSURE: \(device.localizedName) cannot do custom exposure — staying auto") + exposureIntent = .auto + fallthrough + case .auto: + if device.exposureMode != .continuousAutoExposure, + device.isExposureModeSupported(.continuousAutoExposure), + (try? device.lockForConfiguration()) != nil { + device.exposureMode = .continuousAutoExposure + device.unlockForConfiguration() + // A long manual shutter may have stretched the frame duration; + // auto restores the frame rate the quality setting chose. + try? setFrameRate(framerate: fpsSetting.value, videoDevice: device) + } + case let .manual(durationSeconds, iso): + // Clamp into the format's OWN CMTimes (never rebuild from integers) + // and re-clamp ISO: out-of-range values raise an NSRangeException + // that Swift cannot catch. + let format = device.activeFormat + var duration = CMTimeMakeWithSeconds(durationSeconds, preferredTimescale: 1_000_000_000) + if CMTimeCompare(duration, format.minExposureDuration) < 0 { duration = format.minExposureDuration } + if CMTimeCompare(duration, format.maxExposureDuration) > 0 { duration = format.maxExposureDuration } + let safeISO = min(max(iso, format.minISO), format.maxISO) + if (try? device.lockForConfiguration()) != nil { + device.setExposureModeCustom(duration: duration, iso: safeISO, completionHandler: nil) + device.unlockForConfiguration() + } + debugLog("🌗 EXPOSURE: manual \(CMTimeGetSeconds(duration))s ISO \(safeISO) on \(device.localizedName)") + } + return exposureStateLocked(device) + } + // MARK: - Enhanced Zoom Control Methods func setZoom(zoomFactor: CGFloat) async throws -> (CGFloat, CameraLensType, RemoteCmd.ZoomRange) { try await onSessionQueueThrowing { try self.setZoomLocked(zoomFactor: zoomFactor) } @@ -1472,6 +1583,7 @@ final class CaptureEngine: NSObject, AVCapturePhotoCaptureDelegate { } applyDesiredTorchLocked() // changing activeFormat/preset also resets the torch + _ = applyExposureIntentLocked() // ranges and the frame-rate cap changed with the format currentVideoResolution = resolution currentVideoFrameRate = appliedFrameRate diff --git a/RemoteCam/ExposurePolicy.swift b/RemoteCam/ExposurePolicy.swift new file mode 100644 index 00000000..750f6221 --- /dev/null +++ b/RemoteCam/ExposurePolicy.swift @@ -0,0 +1,107 @@ +// +// ExposurePolicy.swift +// RemoteShutter +// +// Manual exposure (shutter speed + ISO) as pure values and one decision +// function. The engine turns an `ExposureIntent` into device calls; the policy +// decides what the device is allowed to receive. No AVFoundation types cross +// this boundary, so every rule here is table-testable. See +// Docs/pro-controls.md. +// + +import Foundation + +/// Auto vs. manual exposure, as reported by the camera and chosen by the monitor. +public enum ExposureMode: Equatable, Sendable { + case auto + case manual +} + +/// What the monitor asked for. Seconds rather than `CMTime` because this value +/// rides the wire; the engine clamps it back into the device's own `CMTime`s. +/// A duration or ISO of `0` (or less) means "keep the device's current value" +/// — the same convention as `AVCaptureDevice.currentExposureDuration`. +public enum ExposureIntent: Equatable, Sendable { + case auto + case manual(durationSeconds: Double, iso: Float) +} + +/// The ranges and booleans the policy needs from the active device + format. +struct ExposureFacts: Equatable, Sendable { + var supportsCustom: Bool + var minDurationSeconds: Double + var maxDurationSeconds: Double + var minISO: Float + var maxISO: Float + /// The active max frame duration (1 / fps). A manual shutter longer than + /// this lengthens it — which changes the recorded frame rate mid-clip. + var maxFrameDurationSeconds: Double + var currentDurationSeconds: Double + var currentISO: Float +} + +/// What the engine should do to the device. +enum ExposurePlan: Equatable, Sendable { + case auto + case manual(durationSeconds: Double, iso: Float) + /// The active device cannot do custom exposure (virtual multi-lens + /// devices, most Mac cameras): the engine falls back to auto and the + /// response tells the monitor so. + case unsupported +} + +/// Snapshot of the device's exposure truth, echoed to the monitor after every +/// command and carried in capabilities so the panel opens populated. +public struct ExposureState: Equatable, Sendable { + public var mode: ExposureMode + public var durationSeconds: Double + public var iso: Float + public var minDurationSeconds: Double + public var maxDurationSeconds: Double + public var minISO: Float + public var maxISO: Float + + public init(mode: ExposureMode, durationSeconds: Double, iso: Float, + minDurationSeconds: Double, maxDurationSeconds: Double, minISO: Float, maxISO: Float) { + self.mode = mode + self.durationSeconds = durationSeconds + self.iso = iso + self.minDurationSeconds = minDurationSeconds + self.maxDurationSeconds = maxDurationSeconds + self.minISO = minISO + self.maxISO = maxISO + } +} + +enum ExposurePolicy { + + /// The one decision: clamp the intent into what the device + format allow. + /// + /// - While recording the shutter is additionally capped at the frame + /// duration so the clip's frame rate never changes mid-take. In photo + /// mode a long shutter may legitimately slow the preview. + /// - Zero/negative components keep the device's current value. + static func resolve(_ intent: ExposureIntent, + facts: ExposureFacts, + isRecording: Bool) -> ExposurePlan { + switch intent { + case .auto: + return .auto + case let .manual(requestedDuration, requestedISO): + guard facts.supportsCustom else { return .unsupported } + + var durationCeiling = facts.maxDurationSeconds + if isRecording, facts.maxFrameDurationSeconds > 0 { + durationCeiling = min(durationCeiling, facts.maxFrameDurationSeconds) + } + durationCeiling = max(durationCeiling, facts.minDurationSeconds) + + let wantedDuration = requestedDuration > 0 ? requestedDuration : facts.currentDurationSeconds + let wantedISO = requestedISO > 0 ? requestedISO : facts.currentISO + + let duration = min(max(wantedDuration, facts.minDurationSeconds), durationCeiling) + let iso = min(max(wantedISO, facts.minISO), facts.maxISO) + return .manual(durationSeconds: duration, iso: iso) + } + } +} diff --git a/RemoteCam/FlatBufferSchemas.fbs b/RemoteCam/FlatBufferSchemas.fbs index 45e6a51d..2e676cf2 100644 --- a/RemoteCam/FlatBufferSchemas.fbs +++ b/RemoteCam/FlatBufferSchemas.fbs @@ -66,10 +66,23 @@ enum CommandAction : byte { // RequestCameraStateReport. Payload rides in // CommandParameters (state_report_seq, // state_recording_phase, elapsed ticks). - RequestCameraStateReport = 32 // monitor/director -> camera: re-push the + RequestCameraStateReport = 32, // monitor/director -> camera: re-push the // current CameraStateReport (e.g. on connection). + SetExposure = 33 // monitor -> camera: auto / manual (shutter + ISO). + // Payload in CommandParameters (exposure_*); the + // camera answers with a CameraStateResponse carrying + // ExposureState. Only sent to peers advertising + // supports_manual_exposure. } +// Auto vs. manual exposure. Unknown = legacy peer / field absent. +enum ExposureMode : byte { + Unknown = 0, + Auto = 1, + Manual = 2 +} + + // Whether the camera device drives its own on-screen live preview. On is the // shipping default and preserves existing behavior; Standby stops LOCAL preview // compositing only — the capture session and the frames streamed to the monitor @@ -221,6 +234,11 @@ table CommandParameters { state_report_seq: uint64; state_recording_phase: RecordingPhase; state_recording_elapsed_ms: uint64; + // SetExposure payload. Seconds, not a CMTime: the camera clamps into its + // own format's CMTime range. 0 = keep the device's current value. + exposure_mode: ExposureMode; + exposure_duration_seconds: double; + exposure_iso: float; } // MARK: - Command Structure @@ -232,6 +250,19 @@ table CameraCommand { // MARK: - State Structures +// The camera's exposure truth: what is applied now plus the active format's +// range, so the monitor's dials always reflect this device. Echoed on every +// SetExposure response and carried in capabilities. +table ExposureState { + mode: ExposureMode; + duration_seconds: double; + iso: float; + min_duration_seconds: double; + max_duration_seconds: double; + min_iso: float; + max_iso: float; +} + table ZoomRange { min_zoom: double; max_zoom: double; @@ -325,6 +356,11 @@ table CameraCapabilities { // peer (they would be decoded as Unknown and dropped, silently desyncing // the rig). supports_multicam: bool; + // False/absent = the active device cannot do custom exposure (legacy peer, + // virtual multi-lens device, most Mac cameras); a monitor must not send + // SetExposure to such a peer and shows no exposure control. + supports_manual_exposure: bool; + exposure: ExposureState; } // MARK: - Response Structure @@ -344,6 +380,7 @@ table CameraStateResponse { clock_sync_echo_t0_ms: uint64; // ClockSyncPing response: echoed director t0 clock_sync_camera_clock_ms: uint64; // ClockSyncPing response: camera clock at receipt capture_id_echo: string; // ScheduledCapture ack: the accepted capture id + exposure: ExposureState; // SetExposure response: applied values + ranges } // MARK: - Frame Data diff --git a/RemoteCam/FlatBufferSchemas_generated.swift b/RemoteCam/FlatBufferSchemas_generated.swift index 27c41298..e5090ca7 100644 --- a/RemoteCam/FlatBufferSchemas_generated.swift +++ b/RemoteCam/FlatBufferSchemas_generated.swift @@ -41,12 +41,26 @@ public enum RemoteShutter_CommandAction: Int8, Enum, Verifiable { case requestvideoresend = 30 case camerastatereport = 31 case requestcamerastatereport = 32 + case setexposure = 33 - public static var max: RemoteShutter_CommandAction { return .requestcamerastatereport } + public static var max: RemoteShutter_CommandAction { return .setexposure } public static var min: RemoteShutter_CommandAction { return .unknown } } +public enum RemoteShutter_ExposureMode: Int8, Enum, Verifiable { + public typealias T = Int8 + public static var byteSize: Int { return MemoryLayout.size } + public var value: Int8 { return self.rawValue } + case unknown = 0 + case auto = 1 + case manual = 2 + + public static var max: RemoteShutter_ExposureMode { return .manual } + public static var min: RemoteShutter_ExposureMode { return .unknown } +} + + public enum RemoteShutter_CameraPreviewModeEnum: Int8, Enum, Verifiable { public typealias T = Int8 public static var byteSize: Int { return MemoryLayout.size } @@ -364,6 +378,9 @@ public struct RemoteShutter_CommandParameters: FlatBufferObject, Verifiable { case stateReportSeq = 60 case stateRecordingPhase = 62 case stateRecordingElapsedMs = 64 + case exposureMode = 66 + case exposureDurationSeconds = 68 + case exposureIso = 70 var v: Int32 { Int32(self.rawValue) } var p: VOffset { self.rawValue } } @@ -404,7 +421,10 @@ public struct RemoteShutter_CommandParameters: FlatBufferObject, Verifiable { public var stateReportSeq: UInt64 { let o = _accessor.offset(VTOFFSET.stateReportSeq.v); return o == 0 ? 0 : _accessor.readBuffer(of: UInt64.self, at: o) } public var stateRecordingPhase: RemoteShutter_RecordingPhase { let o = _accessor.offset(VTOFFSET.stateRecordingPhase.v); return o == 0 ? .unknown : RemoteShutter_RecordingPhase(rawValue: _accessor.readBuffer(of: Int8.self, at: o)) ?? .unknown } public var stateRecordingElapsedMs: UInt64 { let o = _accessor.offset(VTOFFSET.stateRecordingElapsedMs.v); return o == 0 ? 0 : _accessor.readBuffer(of: UInt64.self, at: o) } - public static func startCommandParameters(_ fbb: inout FlatBufferBuilder) -> UOffset { fbb.startTable(with: 31) } + public var exposureMode: RemoteShutter_ExposureMode { let o = _accessor.offset(VTOFFSET.exposureMode.v); return o == 0 ? .unknown : RemoteShutter_ExposureMode(rawValue: _accessor.readBuffer(of: Int8.self, at: o)) ?? .unknown } + public var exposureDurationSeconds: Double { let o = _accessor.offset(VTOFFSET.exposureDurationSeconds.v); return o == 0 ? 0.0 : _accessor.readBuffer(of: Double.self, at: o) } + public var exposureIso: Float32 { let o = _accessor.offset(VTOFFSET.exposureIso.v); return o == 0 ? 0.0 : _accessor.readBuffer(of: Float32.self, at: o) } + public static func startCommandParameters(_ fbb: inout FlatBufferBuilder) -> UOffset { fbb.startTable(with: 34) } public static func add(sendToRemote: Bool, _ fbb: inout FlatBufferBuilder) { fbb.add(element: sendToRemote, def: false, at: VTOFFSET.sendToRemote.p) } public static func add(zoomFactor: Double, _ fbb: inout FlatBufferBuilder) { fbb.add(element: zoomFactor, def: 0.0, at: VTOFFSET.zoomFactor.p) } @@ -437,6 +457,9 @@ public struct RemoteShutter_CommandParameters: FlatBufferObject, Verifiable { public static func add(stateReportSeq: UInt64, _ fbb: inout FlatBufferBuilder) { fbb.add(element: stateReportSeq, def: 0, at: VTOFFSET.stateReportSeq.p) } public static func add(stateRecordingPhase: RemoteShutter_RecordingPhase, _ fbb: inout FlatBufferBuilder) { fbb.add(element: stateRecordingPhase.rawValue, def: 0, at: VTOFFSET.stateRecordingPhase.p) } public static func add(stateRecordingElapsedMs: UInt64, _ fbb: inout FlatBufferBuilder) { fbb.add(element: stateRecordingElapsedMs, def: 0, at: VTOFFSET.stateRecordingElapsedMs.p) } + public static func add(exposureMode: RemoteShutter_ExposureMode, _ fbb: inout FlatBufferBuilder) { fbb.add(element: exposureMode.rawValue, def: 0, at: VTOFFSET.exposureMode.p) } + public static func add(exposureDurationSeconds: Double, _ fbb: inout FlatBufferBuilder) { fbb.add(element: exposureDurationSeconds, def: 0.0, at: VTOFFSET.exposureDurationSeconds.p) } + public static func add(exposureIso: Float32, _ fbb: inout FlatBufferBuilder) { fbb.add(element: exposureIso, def: 0.0, at: VTOFFSET.exposureIso.p) } public static func endCommandParameters(_ fbb: inout FlatBufferBuilder, start: UOffset) -> Offset { let end = Offset(offset: fbb.endTable(at: start)); return end } public static func createCommandParameters( _ fbb: inout FlatBufferBuilder, @@ -470,7 +493,10 @@ public struct RemoteShutter_CommandParameters: FlatBufferObject, Verifiable { streamFps: Int32 = 0, stateReportSeq: UInt64 = 0, stateRecordingPhase: RemoteShutter_RecordingPhase = .unknown, - stateRecordingElapsedMs: UInt64 = 0 + stateRecordingElapsedMs: UInt64 = 0, + exposureMode: RemoteShutter_ExposureMode = .unknown, + exposureDurationSeconds: Double = 0.0, + exposureIso: Float32 = 0.0 ) -> Offset { let __start = RemoteShutter_CommandParameters.startCommandParameters(&fbb) RemoteShutter_CommandParameters.add(sendToRemote: sendToRemote, &fbb) @@ -504,6 +530,9 @@ public struct RemoteShutter_CommandParameters: FlatBufferObject, Verifiable { RemoteShutter_CommandParameters.add(stateReportSeq: stateReportSeq, &fbb) RemoteShutter_CommandParameters.add(stateRecordingPhase: stateRecordingPhase, &fbb) RemoteShutter_CommandParameters.add(stateRecordingElapsedMs: stateRecordingElapsedMs, &fbb) + RemoteShutter_CommandParameters.add(exposureMode: exposureMode, &fbb) + RemoteShutter_CommandParameters.add(exposureDurationSeconds: exposureDurationSeconds, &fbb) + RemoteShutter_CommandParameters.add(exposureIso: exposureIso, &fbb) return RemoteShutter_CommandParameters.endCommandParameters(&fbb, start: __start) } @@ -540,6 +569,9 @@ public struct RemoteShutter_CommandParameters: FlatBufferObject, Verifiable { try _v.visit(field: VTOFFSET.stateReportSeq.p, fieldName: "stateReportSeq", required: false, type: UInt64.self) try _v.visit(field: VTOFFSET.stateRecordingPhase.p, fieldName: "stateRecordingPhase", required: false, type: RemoteShutter_RecordingPhase.self) try _v.visit(field: VTOFFSET.stateRecordingElapsedMs.p, fieldName: "stateRecordingElapsedMs", required: false, type: UInt64.self) + try _v.visit(field: VTOFFSET.exposureMode.p, fieldName: "exposureMode", required: false, type: RemoteShutter_ExposureMode.self) + try _v.visit(field: VTOFFSET.exposureDurationSeconds.p, fieldName: "exposureDurationSeconds", required: false, type: Double.self) + try _v.visit(field: VTOFFSET.exposureIso.p, fieldName: "exposureIso", required: false, type: Float32.self) _v.finish() } } @@ -587,6 +619,79 @@ public struct RemoteShutter_CameraCommand: FlatBufferObject, Verifiable { } } +public struct RemoteShutter_ExposureState: FlatBufferObject, Verifiable { + + static func validateVersion() { FlatBuffersVersion_25_2_10() } + public var __buffer: ByteBuffer! { return _accessor.bb } + private var _accessor: Table + + public static var id: String { "RCAM" } + public static func finish(_ fbb: inout FlatBufferBuilder, end: Offset, prefix: Bool = false) { fbb.finish(offset: end, fileId: RemoteShutter_ExposureState.id, addPrefix: prefix) } + private init(_ t: Table) { _accessor = t } + public init(_ bb: ByteBuffer, o: Int32) { _accessor = Table(bb: bb, position: o) } + + private enum VTOFFSET: VOffset { + case mode = 4 + case durationSeconds = 6 + case iso = 8 + case minDurationSeconds = 10 + case maxDurationSeconds = 12 + case minIso = 14 + case maxIso = 16 + var v: Int32 { Int32(self.rawValue) } + var p: VOffset { self.rawValue } + } + + public var mode: RemoteShutter_ExposureMode { let o = _accessor.offset(VTOFFSET.mode.v); return o == 0 ? .unknown : RemoteShutter_ExposureMode(rawValue: _accessor.readBuffer(of: Int8.self, at: o)) ?? .unknown } + public var durationSeconds: Double { let o = _accessor.offset(VTOFFSET.durationSeconds.v); return o == 0 ? 0.0 : _accessor.readBuffer(of: Double.self, at: o) } + public var iso: Float32 { let o = _accessor.offset(VTOFFSET.iso.v); return o == 0 ? 0.0 : _accessor.readBuffer(of: Float32.self, at: o) } + public var minDurationSeconds: Double { let o = _accessor.offset(VTOFFSET.minDurationSeconds.v); return o == 0 ? 0.0 : _accessor.readBuffer(of: Double.self, at: o) } + public var maxDurationSeconds: Double { let o = _accessor.offset(VTOFFSET.maxDurationSeconds.v); return o == 0 ? 0.0 : _accessor.readBuffer(of: Double.self, at: o) } + public var minIso: Float32 { let o = _accessor.offset(VTOFFSET.minIso.v); return o == 0 ? 0.0 : _accessor.readBuffer(of: Float32.self, at: o) } + public var maxIso: Float32 { let o = _accessor.offset(VTOFFSET.maxIso.v); return o == 0 ? 0.0 : _accessor.readBuffer(of: Float32.self, at: o) } + public static func startExposureState(_ fbb: inout FlatBufferBuilder) -> UOffset { fbb.startTable(with: 7) } + public static func add(mode: RemoteShutter_ExposureMode, _ fbb: inout FlatBufferBuilder) { fbb.add(element: mode.rawValue, def: 0, at: VTOFFSET.mode.p) } + public static func add(durationSeconds: Double, _ fbb: inout FlatBufferBuilder) { fbb.add(element: durationSeconds, def: 0.0, at: VTOFFSET.durationSeconds.p) } + public static func add(iso: Float32, _ fbb: inout FlatBufferBuilder) { fbb.add(element: iso, def: 0.0, at: VTOFFSET.iso.p) } + public static func add(minDurationSeconds: Double, _ fbb: inout FlatBufferBuilder) { fbb.add(element: minDurationSeconds, def: 0.0, at: VTOFFSET.minDurationSeconds.p) } + public static func add(maxDurationSeconds: Double, _ fbb: inout FlatBufferBuilder) { fbb.add(element: maxDurationSeconds, def: 0.0, at: VTOFFSET.maxDurationSeconds.p) } + public static func add(minIso: Float32, _ fbb: inout FlatBufferBuilder) { fbb.add(element: minIso, def: 0.0, at: VTOFFSET.minIso.p) } + public static func add(maxIso: Float32, _ fbb: inout FlatBufferBuilder) { fbb.add(element: maxIso, def: 0.0, at: VTOFFSET.maxIso.p) } + public static func endExposureState(_ fbb: inout FlatBufferBuilder, start: UOffset) -> Offset { let end = Offset(offset: fbb.endTable(at: start)); return end } + public static func createExposureState( + _ fbb: inout FlatBufferBuilder, + mode: RemoteShutter_ExposureMode = .unknown, + durationSeconds: Double = 0.0, + iso: Float32 = 0.0, + minDurationSeconds: Double = 0.0, + maxDurationSeconds: Double = 0.0, + minIso: Float32 = 0.0, + maxIso: Float32 = 0.0 + ) -> Offset { + let __start = RemoteShutter_ExposureState.startExposureState(&fbb) + RemoteShutter_ExposureState.add(mode: mode, &fbb) + RemoteShutter_ExposureState.add(durationSeconds: durationSeconds, &fbb) + RemoteShutter_ExposureState.add(iso: iso, &fbb) + RemoteShutter_ExposureState.add(minDurationSeconds: minDurationSeconds, &fbb) + RemoteShutter_ExposureState.add(maxDurationSeconds: maxDurationSeconds, &fbb) + RemoteShutter_ExposureState.add(minIso: minIso, &fbb) + RemoteShutter_ExposureState.add(maxIso: maxIso, &fbb) + return RemoteShutter_ExposureState.endExposureState(&fbb, start: __start) + } + + public static func verify(_ verifier: inout Verifier, at position: Int, of type: T.Type) throws where T: Verifiable { + var _v = try verifier.visitTable(at: position) + try _v.visit(field: VTOFFSET.mode.p, fieldName: "mode", required: false, type: RemoteShutter_ExposureMode.self) + try _v.visit(field: VTOFFSET.durationSeconds.p, fieldName: "durationSeconds", required: false, type: Double.self) + try _v.visit(field: VTOFFSET.iso.p, fieldName: "iso", required: false, type: Float32.self) + try _v.visit(field: VTOFFSET.minDurationSeconds.p, fieldName: "minDurationSeconds", required: false, type: Double.self) + try _v.visit(field: VTOFFSET.maxDurationSeconds.p, fieldName: "maxDurationSeconds", required: false, type: Double.self) + try _v.visit(field: VTOFFSET.minIso.p, fieldName: "minIso", required: false, type: Float32.self) + try _v.visit(field: VTOFFSET.maxIso.p, fieldName: "maxIso", required: false, type: Float32.self) + _v.finish() + } +} + public struct RemoteShutter_ZoomRange: FlatBufferObject, Verifiable { static func validateVersion() { FlatBuffersVersion_25_2_10() } @@ -1107,6 +1212,8 @@ public struct RemoteShutter_CameraCapabilities: FlatBufferObject, Verifiable { case supportsFocusPoint = 12 case supportsPreviewMode = 14 case supportsMulticam = 16 + case supportsManualExposure = 18 + case exposure = 20 var v: Int32 { Int32(self.rawValue) } var p: VOffset { self.rawValue } } @@ -1121,7 +1228,9 @@ public struct RemoteShutter_CameraCapabilities: FlatBufferObject, Verifiable { public var supportsFocusPoint: Bool { let o = _accessor.offset(VTOFFSET.supportsFocusPoint.v); return o == 0 ? false : _accessor.readBuffer(of: Bool.self, at: o) } public var supportsPreviewMode: Bool { let o = _accessor.offset(VTOFFSET.supportsPreviewMode.v); return o == 0 ? false : _accessor.readBuffer(of: Bool.self, at: o) } public var supportsMulticam: Bool { let o = _accessor.offset(VTOFFSET.supportsMulticam.v); return o == 0 ? false : _accessor.readBuffer(of: Bool.self, at: o) } - public static func startCameraCapabilities(_ fbb: inout FlatBufferBuilder) -> UOffset { fbb.startTable(with: 7) } + public var supportsManualExposure: Bool { let o = _accessor.offset(VTOFFSET.supportsManualExposure.v); return o == 0 ? false : _accessor.readBuffer(of: Bool.self, at: o) } + public var exposure: RemoteShutter_ExposureState? { let o = _accessor.offset(VTOFFSET.exposure.v); return o == 0 ? nil : RemoteShutter_ExposureState(_accessor.bb, o: _accessor.indirect(o + _accessor.position)) } + public static func startCameraCapabilities(_ fbb: inout FlatBufferBuilder) -> UOffset { fbb.startTable(with: 9) } public static func add(frontCamera: Offset, _ fbb: inout FlatBufferBuilder) { fbb.add(offset: frontCamera, at: VTOFFSET.frontCamera.p) } public static func add(backCamera: Offset, _ fbb: inout FlatBufferBuilder) { fbb.add(offset: backCamera, at: VTOFFSET.backCamera.p) } public static func addVectorOf(cameraDevices: Offset, _ fbb: inout FlatBufferBuilder) { fbb.add(offset: cameraDevices, at: VTOFFSET.cameraDevices.p) } @@ -1132,6 +1241,9 @@ public struct RemoteShutter_CameraCapabilities: FlatBufferObject, Verifiable { at: VTOFFSET.supportsPreviewMode.p) } public static func add(supportsMulticam: Bool, _ fbb: inout FlatBufferBuilder) { fbb.add(element: supportsMulticam, def: false, at: VTOFFSET.supportsMulticam.p) } + public static func add(supportsManualExposure: Bool, _ fbb: inout FlatBufferBuilder) { fbb.add(element: supportsManualExposure, def: false, + at: VTOFFSET.supportsManualExposure.p) } + public static func add(exposure: Offset, _ fbb: inout FlatBufferBuilder) { fbb.add(offset: exposure, at: VTOFFSET.exposure.p) } public static func endCameraCapabilities(_ fbb: inout FlatBufferBuilder, start: UOffset) -> Offset { let end = Offset(offset: fbb.endTable(at: start)); return end } public static func createCameraCapabilities( _ fbb: inout FlatBufferBuilder, @@ -1141,7 +1253,9 @@ public struct RemoteShutter_CameraCapabilities: FlatBufferObject, Verifiable { activeDeviceIdOffset activeDeviceId: Offset = Offset(), supportsFocusPoint: Bool = false, supportsPreviewMode: Bool = false, - supportsMulticam: Bool = false + supportsMulticam: Bool = false, + supportsManualExposure: Bool = false, + exposureOffset exposure: Offset = Offset() ) -> Offset { let __start = RemoteShutter_CameraCapabilities.startCameraCapabilities(&fbb) RemoteShutter_CameraCapabilities.add(frontCamera: frontCamera, &fbb) @@ -1151,6 +1265,8 @@ public struct RemoteShutter_CameraCapabilities: FlatBufferObject, Verifiable { RemoteShutter_CameraCapabilities.add(supportsFocusPoint: supportsFocusPoint, &fbb) RemoteShutter_CameraCapabilities.add(supportsPreviewMode: supportsPreviewMode, &fbb) RemoteShutter_CameraCapabilities.add(supportsMulticam: supportsMulticam, &fbb) + RemoteShutter_CameraCapabilities.add(supportsManualExposure: supportsManualExposure, &fbb) + RemoteShutter_CameraCapabilities.add(exposure: exposure, &fbb) return RemoteShutter_CameraCapabilities.endCameraCapabilities(&fbb, start: __start) } @@ -1163,6 +1279,8 @@ public struct RemoteShutter_CameraCapabilities: FlatBufferObject, Verifiable { try _v.visit(field: VTOFFSET.supportsFocusPoint.p, fieldName: "supportsFocusPoint", required: false, type: Bool.self) try _v.visit(field: VTOFFSET.supportsPreviewMode.p, fieldName: "supportsPreviewMode", required: false, type: Bool.self) try _v.visit(field: VTOFFSET.supportsMulticam.p, fieldName: "supportsMulticam", required: false, type: Bool.self) + try _v.visit(field: VTOFFSET.supportsManualExposure.p, fieldName: "supportsManualExposure", required: false, type: Bool.self) + try _v.visit(field: VTOFFSET.exposure.p, fieldName: "exposure", required: false, type: ForwardOffset.self) _v.finish() } } @@ -1192,6 +1310,7 @@ public struct RemoteShutter_CameraStateResponse: FlatBufferObject, Verifiable { case clockSyncEchoT0Ms = 24 case clockSyncCameraClockMs = 26 case captureIdEcho = 28 + case exposure = 30 var v: Int32 { Int32(self.rawValue) } var p: VOffset { self.rawValue } } @@ -1216,7 +1335,8 @@ public struct RemoteShutter_CameraStateResponse: FlatBufferObject, Verifiable { public var clockSyncCameraClockMs: UInt64 { let o = _accessor.offset(VTOFFSET.clockSyncCameraClockMs.v); return o == 0 ? 0 : _accessor.readBuffer(of: UInt64.self, at: o) } public var captureIdEcho: String? { let o = _accessor.offset(VTOFFSET.captureIdEcho.v); return o == 0 ? nil : _accessor.string(at: o) } public var captureIdEchoSegmentArray: [UInt8]? { return _accessor.getVector(at: VTOFFSET.captureIdEcho.v) } - public static func startCameraStateResponse(_ fbb: inout FlatBufferBuilder) -> UOffset { fbb.startTable(with: 13) } + public var exposure: RemoteShutter_ExposureState? { let o = _accessor.offset(VTOFFSET.exposure.v); return o == 0 ? nil : RemoteShutter_ExposureState(_accessor.bb, o: _accessor.indirect(o + _accessor.position)) } + public static func startCameraStateResponse(_ fbb: inout FlatBufferBuilder) -> UOffset { fbb.startTable(with: 14) } public static func add(action: RemoteShutter_CommandAction, _ fbb: inout FlatBufferBuilder) { fbb.add(element: action.rawValue, def: 0, at: VTOFFSET.action.p) } public static func add(success: Bool, _ fbb: inout FlatBufferBuilder) { fbb.add(element: success, def: false, at: VTOFFSET.success.p) } @@ -1231,6 +1351,7 @@ public struct RemoteShutter_CameraStateResponse: FlatBufferObject, Verifiable { public static func add(clockSyncEchoT0Ms: UInt64, _ fbb: inout FlatBufferBuilder) { fbb.add(element: clockSyncEchoT0Ms, def: 0, at: VTOFFSET.clockSyncEchoT0Ms.p) } public static func add(clockSyncCameraClockMs: UInt64, _ fbb: inout FlatBufferBuilder) { fbb.add(element: clockSyncCameraClockMs, def: 0, at: VTOFFSET.clockSyncCameraClockMs.p) } public static func add(captureIdEcho: Offset, _ fbb: inout FlatBufferBuilder) { fbb.add(offset: captureIdEcho, at: VTOFFSET.captureIdEcho.p) } + public static func add(exposure: Offset, _ fbb: inout FlatBufferBuilder) { fbb.add(offset: exposure, at: VTOFFSET.exposure.p) } public static func endCameraStateResponse(_ fbb: inout FlatBufferBuilder, start: UOffset) -> Offset { let end = Offset(offset: fbb.endTable(at: start)); return end } public static func createCameraStateResponse( _ fbb: inout FlatBufferBuilder, @@ -1246,7 +1367,8 @@ public struct RemoteShutter_CameraStateResponse: FlatBufferObject, Verifiable { currentZoom: Double = 0.0, clockSyncEchoT0Ms: UInt64 = 0, clockSyncCameraClockMs: UInt64 = 0, - captureIdEchoOffset captureIdEcho: Offset = Offset() + captureIdEchoOffset captureIdEcho: Offset = Offset(), + exposureOffset exposure: Offset = Offset() ) -> Offset { let __start = RemoteShutter_CameraStateResponse.startCameraStateResponse(&fbb) RemoteShutter_CameraStateResponse.add(action: action, &fbb) @@ -1262,6 +1384,7 @@ public struct RemoteShutter_CameraStateResponse: FlatBufferObject, Verifiable { RemoteShutter_CameraStateResponse.add(clockSyncEchoT0Ms: clockSyncEchoT0Ms, &fbb) RemoteShutter_CameraStateResponse.add(clockSyncCameraClockMs: clockSyncCameraClockMs, &fbb) RemoteShutter_CameraStateResponse.add(captureIdEcho: captureIdEcho, &fbb) + RemoteShutter_CameraStateResponse.add(exposure: exposure, &fbb) return RemoteShutter_CameraStateResponse.endCameraStateResponse(&fbb, start: __start) } @@ -1280,6 +1403,7 @@ public struct RemoteShutter_CameraStateResponse: FlatBufferObject, Verifiable { try _v.visit(field: VTOFFSET.clockSyncEchoT0Ms.p, fieldName: "clockSyncEchoT0Ms", required: false, type: UInt64.self) try _v.visit(field: VTOFFSET.clockSyncCameraClockMs.p, fieldName: "clockSyncCameraClockMs", required: false, type: UInt64.self) try _v.visit(field: VTOFFSET.captureIdEcho.p, fieldName: "captureIdEcho", required: false, type: ForwardOffset.self) + try _v.visit(field: VTOFFSET.exposure.p, fieldName: "exposure", required: false, type: ForwardOffset.self) _v.finish() } } diff --git a/RemoteCam/MonitorPresenter.swift b/RemoteCam/MonitorPresenter.swift index 9fe30629..c4cb0030 100644 --- a/RemoteCam/MonitorPresenter.swift +++ b/RemoteCam/MonitorPresenter.swift @@ -103,6 +103,11 @@ public final class MonitorPresenter { } } + func updateExposure(_ state: ExposureState?) { + guard let state else { return } + onMain { $0.viewModel.exposure = state } + } + func updateLens(_ lensType: CameraLensType?, availableLenses: [CameraLensType]?, currentZoom: CGFloat?, @@ -128,6 +133,8 @@ public final class MonitorPresenter { // property of the peer, not of whichever camera it has selected, so // a peer that reports no current camera must not lose the flag. display.viewModel.supportsCameraStandby = capabilities.supportsPreviewMode + display.viewModel.supportsManualExposure = capabilities.supportsManualExposure + display.viewModel.exposure = capabilities.exposure guard let cameraInfo = capabilities.getCurrentCameraInfo() else { return } // Update lens controls in view model diff --git a/RemoteCam/MonitorViewModel.swift b/RemoteCam/MonitorViewModel.swift index 2e13643f..4f76a615 100644 --- a/RemoteCam/MonitorViewModel.swift +++ b/RemoteCam/MonitorViewModel.swift @@ -260,6 +260,12 @@ class MonitorViewModel: ObservableObject { /// the standby tray tile — an older camera ignores the command, so offering /// a control that does nothing would be worse than hiding it. @Published var supportsCameraStandby: Bool = false + /// Whether the peer's ACTIVE camera can do manual exposure. Gates the + /// exposure control — absent, not disabled, when the camera can't. + @Published var supportsManualExposure: Bool = false + /// The camera's echoed exposure truth (mode, shutter, ISO, ranges). The + /// monitor renders only this, never the value it last dragged to. + @Published var exposure: ExposureState? // MARK: - Video Quality Update Methods func updateVideoQuality(resolution: VideoResolution, frameRate: VideoFrameRate) { diff --git a/RemoteCam/RemoteCmdFlatBuffers.swift b/RemoteCam/RemoteCmdFlatBuffers.swift index 73caa820..04453566 100644 --- a/RemoteCam/RemoteCmdFlatBuffers.swift +++ b/RemoteCam/RemoteCmdFlatBuffers.swift @@ -41,6 +41,8 @@ func serializeToFlatBuffer(_ msg: Message) -> Data? { case let m as RemoteCmd.SetZoom: return m.toFlatBuffer() case let m as RemoteCmd.SetZoomResp: return m.toFlatBuffer() case let m as RemoteCmd.FocusAtPoint: return m.toFlatBuffer() + case let m as RemoteCmd.SetExposure: return m.toFlatBuffer() + case let m as RemoteCmd.SetExposureResp: return m.toFlatBuffer() case let m as RemoteCmd.SetCameraPreviewMode: return m.toFlatBuffer() case let m as RemoteCmd.CameraPreviewModeResp: return m.toFlatBuffer() case let m as RemoteCmd.EndSession: return m.toFlatBuffer() @@ -421,6 +423,7 @@ private func encodeCapabilitiesEnvelope( devicesVector = fbb.createVector(ofOffsets: deviceOffsets) } let activeIDOffset = c.activeDeviceID.map { fbb.create(string: $0) } ?? Offset() + let exposureOffset = encodeExposureState(c.exposure, &fbb) let capsOffset = RemoteShutter_CameraCapabilities.createCameraCapabilities( &fbb, @@ -430,7 +433,9 @@ private func encodeCapabilitiesEnvelope( activeDeviceIdOffset: activeIDOffset, supportsFocusPoint: c.supportsFocusPoint, supportsPreviewMode: c.supportsPreviewMode, - supportsMulticam: c.supportsMulticam) + supportsMulticam: c.supportsMulticam, + supportsManualExposure: c.supportsManualExposure, + exposureOffset: exposureOffset) let stateOffset = RemoteShutter_CameraState.createCameraState( &fbb, @@ -602,6 +607,36 @@ extension RemoteCmd.FocusAtPoint { } } +extension RemoteCmd.SetExposure { + func toFlatBuffer() -> Data { + var fbb = FlatBufferBuilder() + let params: Offset + switch intent { + case .auto: + params = RemoteShutter_CommandParameters.createCommandParameters(&fbb, exposureMode: .auto) + case let .manual(durationSeconds, iso): + params = RemoteShutter_CommandParameters.createCommandParameters( + &fbb, exposureMode: .manual, exposureDurationSeconds: durationSeconds, exposureIso: iso) + } + return buildCommand(&fbb, action: .setexposure, parameters: params) + } +} + +extension RemoteCmd.SetExposureResp { + func toFlatBuffer() -> Data { + var fbb = FlatBufferBuilder() + let errorOffset = (error as NSError?).map { fbb.create(string: RemoteCmd.wireErrorMessage($0)) } ?? Offset() + let exposureOffset = encodeExposureState(state, &fbb) + let resp = RemoteShutter_CameraStateResponse.createCameraStateResponse( + &fbb, + action: .setexposure, + success: error == nil, + errorOffset: errorOffset, + exposureOffset: exposureOffset) + return buildResponse(&fbb, action: .setexposure, response: resp) + } +} + extension RemoteCmd.EndSession { func toFlatBuffer() -> Data { var fbb = FlatBufferBuilder() @@ -1141,6 +1176,49 @@ func fromFBPreviewMode(_ mode: RemoteShutter_CameraPreviewModeEnum) -> CameraPre } } +// MARK: - Exposure conversions + +func toFBExposureMode(_ mode: ExposureMode) -> RemoteShutter_ExposureMode { + switch mode { + case .auto: return .auto + case .manual: return .manual + } +} + +/// Unknown => legacy peer or field absent: no exposure truth. +func fromFBExposureMode(_ mode: RemoteShutter_ExposureMode) -> ExposureMode? { + switch mode { + case .auto: return .auto + case .manual: return .manual + case .unknown: return nil + } +} + +func encodeExposureState(_ state: ExposureState?, _ fbb: inout FlatBufferBuilder) -> Offset { + guard let state else { return Offset() } + return RemoteShutter_ExposureState.createExposureState( + &fbb, + mode: toFBExposureMode(state.mode), + durationSeconds: state.durationSeconds, + iso: state.iso, + minDurationSeconds: state.minDurationSeconds, + maxDurationSeconds: state.maxDurationSeconds, + minIso: state.minISO, + maxIso: state.maxISO) +} + +func decodeExposureState(_ fb: RemoteShutter_ExposureState?) -> ExposureState? { + guard let fb, let mode = fromFBExposureMode(fb.mode) else { return nil } + return ExposureState( + mode: mode, + durationSeconds: fb.durationSeconds, + iso: fb.iso, + minDurationSeconds: fb.minDurationSeconds, + maxDurationSeconds: fb.maxDurationSeconds, + minISO: fb.minIso, + maxISO: fb.maxIso) +} + // MARK: - SetAspectRatio toFlatBuffer() extension RemoteCmd.SetAspectRatio { @@ -1361,6 +1439,17 @@ extension RemoteCmd { case .focusatpoint: return FocusAtPoint(x: params?.focusPointX ?? 0.5, y: params?.focusPointY ?? 0.5) + case .setexposure: + // Unknown mode (a malformed or future payload) is treated as Auto: + // the safe state, and the response tells the sender the truth. + switch params?.exposureMode ?? .unknown { + case .manual: + return SetExposure(intent: .manual(durationSeconds: params?.exposureDurationSeconds ?? 0, + iso: params?.exposureIso ?? 0)) + case .auto, .unknown: + return SetExposure(intent: .auto) + } + case .setcamerapreviewmode: return SetCameraPreviewMode(mode: fromFBPreviewMode(params?.cameraPreviewMode ?? .unknown)) @@ -1434,6 +1523,9 @@ extension RemoteCmd { case .requestcapabilities: return decodeCameraCapabilitiesResp(resp, error: nsError) + case .setexposure: + return SetExposureResp(state: decodeExposureState(resp.exposure), error: nsError) + case .switchlens: let state = resp.currentState let lensType: CameraLensType? = state != nil ? fromFBLens(state!.currentLens) : nil @@ -1547,6 +1639,8 @@ extension RemoteCmd { supportsPreviewMode: caps?.supportsPreviewMode ?? false, supportsMulticam: caps?.supportsMulticam ?? false, previewMode: state.map { fromFBPreviewMode($0.previewMode) } ?? .on, + supportsManualExposure: caps?.supportsManualExposure ?? false, + exposure: decodeExposureState(caps?.exposure), error: error ) } diff --git a/RemoteCam/RemoteCmds.swift b/RemoteCam/RemoteCmds.swift index f2466d8d..df21c573 100644 --- a/RemoteCam/RemoteCmds.swift +++ b/RemoteCam/RemoteCmds.swift @@ -409,6 +409,34 @@ public class RemoteCmd: Message, @unchecked Sendable { } } + // MARK: - Exposure Remote Commands + + /// Monitor -> camera: auto or manual (shutter + ISO) exposure. The camera + /// clamps into its active format's range and answers with `SetExposureResp` + /// carrying the applied truth. Only sent to peers that advertised + /// `CameraCapabilitiesResp.supportsManualExposure`. + public class SetExposure: Message, @unchecked Sendable { + public let intent: ExposureIntent + + public init(intent: ExposureIntent) { + self.intent = intent + super.init(sender: nil) + } + } + + /// Camera -> monitor: the exposure state after a `SetExposure` (applied + /// mode/values plus the active format's range). + public class SetExposureResp: Message, @unchecked Sendable { + public let state: ExposureState? + public let error: Error? + + public init(state: ExposureState?, error: Error?) { + self.state = state + self.error = error + super.init(sender: nil) + } + } + /// "I am leaving on purpose." Sent by whichever side ends the session /// deliberately, so the peer stops reconnecting instead of chasing a /// session nobody is coming back to. Fire-and-forget: an unplanned @@ -574,6 +602,12 @@ public class RemoteCmd: Message, @unchecked Sendable { /// The camera's current local-preview mode, so the monitor can reflect /// it from the first capabilities exchange. public let previewMode: CameraPreviewMode + /// True when the ACTIVE device can do custom exposure. The monitor's + /// exposure gate reads this so it never sends `SetExposure` to a peer + /// that cannot honor it — and shows no control at all. + public let supportsManualExposure: Bool + /// Current exposure truth + ranges, so the panel opens populated. + public let exposure: ExposureState? public let error: Error? public init(frontCamera: CameraInfo?, backCamera: CameraInfo?, @@ -589,6 +623,8 @@ public class RemoteCmd: Message, @unchecked Sendable { supportsPreviewMode: Bool = false, supportsMulticam: Bool = false, previewMode: CameraPreviewMode = .on, + supportsManualExposure: Bool = false, + exposure: ExposureState? = nil, error: Error?) { self.frontCamera = frontCamera self.backCamera = backCamera @@ -605,6 +641,8 @@ public class RemoteCmd: Message, @unchecked Sendable { self.supportsPreviewMode = supportsPreviewMode self.supportsMulticam = supportsMulticam self.previewMode = previewMode + self.supportsManualExposure = supportsManualExposure + self.exposure = exposure self.error = error super.init(sender: nil) } diff --git a/RemoteCam/SessionCoordinator.swift b/RemoteCam/SessionCoordinator.swift index 0c7f6628..7745bf50 100644 --- a/RemoteCam/SessionCoordinator.swift +++ b/RemoteCam/SessionCoordinator.swift @@ -241,6 +241,14 @@ public actor SessionCoordinator { /// Test support. func peerSupportsPreviewModeForTesting() -> Bool { peerSupportsPreviewMode } + /// Whether the connected camera peer's ACTIVE device advertised manual + /// exposure — the feature gate for `RemoteCmd.SetExposure`. Re-absorbed on + /// every capabilities refresh because it changes with the device. + private var peerSupportsManualExposure = false + + /// Test support. + func peerSupportsManualExposureForTesting() -> Bool { peerSupportsManualExposure } + /// Monitor side: at least one VP9 preview frame has arrived. Proves the /// camera peer speaks VP9, which gates sending `RemoteCmd.RequestKeyframe`. private var monitorReceivedVP9Frame = false @@ -792,6 +800,7 @@ public actor SessionCoordinator { peerAdvertisedCameraDevices = false peerSupportsFocusPoint = false peerSupportsPreviewMode = false + peerSupportsManualExposure = false monitorReceivedVP9Frame = false // The session is being torn down for good (deliberate leave, EndSession, // or a dead link) — a fresh session starts single-cam until a director @@ -1297,6 +1306,9 @@ public actor SessionCoordinator { // without a focus point simply ignores it. try? await ctrl.focusAtPoint(x: focus.x, y: focus.y) + case let exposure as RemoteCmd.SetExposure: + await handleSetExposure(exposure, ctrl: ctrl) + case let lens as RemoteCmd.SwitchLens: do { let (lensType, available, zoom, range) = try await ctrl.switchLens(to: lens.lensType) @@ -1389,10 +1401,22 @@ public actor SessionCoordinator { peerAdvertisedCameraDevices = !capabilities.cameraDevices.isEmpty peerSupportsFocusPoint = capabilities.supportsFocusPoint peerSupportsPreviewMode = capabilities.supportsPreviewMode + peerSupportsManualExposure = capabilities.supportsManualExposure monitor?.updateCapabilities(capabilities) monitor?.updatePreviewMode(capabilities.previewMode) } + /// Camera side of `SetExposure`: apply, then echo the device's truth (or + /// the error) so the monitor never shows a state the camera didn't confirm. + private func handleSetExposure(_ cmd: RemoteCmd.SetExposure, ctrl: CameraControlling) async { + do { + let state = try await ctrl.setExposure(cmd.intent) + await sendOrGoToScanning(RemoteCmd.SetExposureResp(state: state, error: nil)) + } catch { + await sendOrGoToScanning(RemoteCmd.SetExposureResp(state: nil, error: error as NSError)) + } + } + /// "The switch didn't stick." The message rides in the NSError domain — /// the convention every monitor's error display reads (`error._domain`). private func couldNotSwitchCameraError() -> NSError { @@ -1685,6 +1709,11 @@ public actor SessionCoordinator { // Fire-and-forget; focusing is allowed while recording too. try? await ctrl.focusAtPoint(x: focus.x, y: focus.y) + case let exposure as RemoteCmd.SetExposure: + // Allowed while recording: the policy caps the shutter at the frame + // duration so the clip's frame rate holds. + await handleSetExposure(exposure, ctrl: ctrl) + case let lens as RemoteCmd.SwitchLens: do { let (lensType, available, zoom, range) = try await ctrl.switchLens(to: lens.lensType) @@ -2391,6 +2420,18 @@ public actor SessionCoordinator { } sendMessage(RemoteCmd.FocusAtPoint(x: focus.x, y: focus.y)) + case let exposure as UICmd.SetExposure: + // Wire-safety gate mirroring FocusAtPoint: never send action 33 to a + // peer whose active camera cannot honor it. + guard peerSupportsManualExposure else { + debugLog("SetExposure dropped: peer did not advertise manual-exposure support") + break + } + sendMessage(RemoteCmd.SetExposure(intent: exposure.intent)) + + case let exposureResp as RemoteCmd.SetExposureResp: + monitor?.updateExposure(exposureResp.state) + case let preview as UICmd.SetCameraPreviewMode: // Wire-safety gate mirroring FocusAtPoint: never send action 24 to a // peer that predates it (it would misread the unknown action). @@ -2737,6 +2778,18 @@ public actor SessionCoordinator { } sendMessage(RemoteCmd.FocusAtPoint(x: focus.x, y: focus.y)) + case let exposure as UICmd.SetExposure: + // Wire-safety gate mirroring FocusAtPoint: never send action 33 to a + // peer whose active camera cannot honor it. + guard peerSupportsManualExposure else { + debugLog("SetExposure dropped: peer did not advertise manual-exposure support") + break + } + sendMessage(RemoteCmd.SetExposure(intent: exposure.intent)) + + case let exposureResp as RemoteCmd.SetExposureResp: + monitor?.updateExposure(exposureResp.state) + case let preview as UICmd.SetCameraPreviewMode: guard peerSupportsPreviewMode else { debugLog("SetCameraPreviewMode dropped: peer did not advertise preview-mode support") diff --git a/RemoteCam/UICmds.swift b/RemoteCam/UICmds.swift index 4edaf284..6756cfa7 100644 --- a/RemoteCam/UICmds.swift +++ b/RemoteCam/UICmds.swift @@ -241,6 +241,16 @@ public class UICmd { } } + /// Monitor screen -> session: set auto/manual exposure on the camera peer. + public class SetExposure: Message, @unchecked Sendable { + public let intent: ExposureIntent + + public init(intent: ExposureIntent) { + self.intent = intent + super.init(sender: nil) + } + } + public class SetZoomResp: Message, @unchecked Sendable { public let zoomFactor: CGFloat? public let currentLens: CameraLensType? diff --git a/RemoteCamTests/CaptureIntegrationTests.swift b/RemoteCamTests/CaptureIntegrationTests.swift index 4d3646b0..d5f06722 100644 --- a/RemoteCamTests/CaptureIntegrationTests.swift +++ b/RemoteCamTests/CaptureIntegrationTests.swift @@ -404,4 +404,54 @@ final class CaptureIntegrationTests: XCTestCase { XCTAssertFalse(after?.isSuspended ?? true, "toggle must never land on a suspended device") print("📸 toggle \(before?.localizedName ?? "?") → \(after?.localizedName ?? "?"): frames in \(Int(latency * 1000))ms") } + + // MARK: - Manual exposure (Docs/pro-controls.md hardware probe) + + /// Probe question 1: which physical devices accept custom exposure — the + /// header says virtual multi-lens devices refuse it, and the lens-swap + /// design hinges on whether that holds on current iOS. Prints one line per + /// device; never fails (the answer is data, not a pass/fail). + func testProbeCustomExposureSupportPerDevice() async throws { + try await startRealRig() + let types: [AVCaptureDevice.DeviceType] = [ + .builtInWideAngleCamera, .builtInUltraWideCamera, .builtInTelephotoCamera, + .builtInDualCamera, .builtInDualWideCamera, .builtInTripleCamera + ] + let devices = AVCaptureDevice.DiscoverySession( + deviceTypes: types, mediaType: .video, position: .unspecified).devices + for device in devices { + let format = device.activeFormat + print("🌗 PROBE \(device.localizedName) [\(device.deviceType.rawValue)] custom=\(device.isExposureModeSupported(.custom)) " + + "shutter=\(CMTimeGetSeconds(format.minExposureDuration))–\(CMTimeGetSeconds(format.maxExposureDuration))s " + + "ISO=\(format.minISO)–\(format.maxISO)") + } + } + + /// Manual exposure applied to the active device reads back within + /// tolerance, and Auto restores continuous AE and the frame rate. + func testManualExposureAppliesAndAutoRestores() async throws { + try await startRealRig() + guard await waitForFrames(since: 0) != nil else { + return XCTFail("startup never delivered frames — \(await diagnostics())") + } + guard let device = rig.engine.videoDeviceInput?.device, device.isExposureModeSupported(.custom) else { + throw XCTSkip("active device does not support custom exposure") + } + let fpsBefore = device.activeVideoMaxFrameDuration + + let wanted = 1.0 / 250 + let state = try await rig.setExposure(ExposureIntent.manual(durationSeconds: wanted, iso: device.activeFormat.minISO * 2)) + XCTAssertEqual(state.mode, .manual) + XCTAssertEqual(state.durationSeconds, wanted, accuracy: wanted * 0.1) + XCTAssertEqual(device.exposureMode, .custom) + + // A long shutter may legitimately stretch the frame duration in photo + // mode; Auto must bring the frame rate back to what quality chose. + _ = try await rig.setExposure(ExposureIntent.manual(durationSeconds: 0.5, iso: 0)) + let restored = try await rig.setExposure(ExposureIntent.auto) + XCTAssertEqual(restored.mode, .auto) + XCTAssertEqual(device.exposureMode, .continuousAutoExposure) + XCTAssertEqual(CMTimeGetSeconds(device.activeVideoMaxFrameDuration), + CMTimeGetSeconds(fpsBefore), accuracy: 0.001) + } } diff --git a/RemoteCamTests/ExposurePolicyTests.swift b/RemoteCamTests/ExposurePolicyTests.swift new file mode 100644 index 00000000..c9beee97 --- /dev/null +++ b/RemoteCamTests/ExposurePolicyTests.swift @@ -0,0 +1,79 @@ +import XCTest +@testable import RemoteShutter + +final class ExposurePolicyTests: XCTestCase { + + /// A typical iPhone wide camera at 30 fps. + private let phone = ExposureFacts( + supportsCustom: true, + minDurationSeconds: 1.0 / 10_000, + maxDurationSeconds: 1.0, + minISO: 32, + maxISO: 3200, + maxFrameDurationSeconds: 1.0 / 30, + currentDurationSeconds: 1.0 / 120, + currentISO: 64) + + func testAutoIntentIsAlwaysAuto() { + XCTAssertEqual(ExposurePolicy.resolve(.auto, facts: phone, isRecording: false), .auto) + XCTAssertEqual(ExposurePolicy.resolve(.auto, facts: phone, isRecording: true), .auto) + var noCustom = phone + noCustom.supportsCustom = false + XCTAssertEqual(ExposurePolicy.resolve(.auto, facts: noCustom, isRecording: false), .auto) + } + + func testUnsupportedDeviceFallsBack() { + var virtual = phone + virtual.supportsCustom = false + XCTAssertEqual( + ExposurePolicy.resolve(.manual(durationSeconds: 0.01, iso: 100), facts: virtual, isRecording: false), + .unsupported) + } + + func testInRangeValuesPassThrough() { + let plan = ExposurePolicy.resolve(.manual(durationSeconds: 1.0 / 250, iso: 400), facts: phone, isRecording: false) + XCTAssertEqual(plan, .manual(durationSeconds: 1.0 / 250, iso: 400)) + } + + func testValuesClampIntoFormatRange() { + let tooLong = ExposurePolicy.resolve(.manual(durationSeconds: 30, iso: 1_000_000), facts: phone, isRecording: false) + XCTAssertEqual(tooLong, .manual(durationSeconds: 1.0, iso: 3200)) + + let tooShort = ExposurePolicy.resolve(.manual(durationSeconds: 1e-9, iso: 1), facts: phone, isRecording: false) + XCTAssertEqual(tooShort, .manual(durationSeconds: 1.0 / 10_000, iso: 32)) + } + + func testZeroKeepsCurrentValue() { + let isoOnly = ExposurePolicy.resolve(.manual(durationSeconds: 0, iso: 800), facts: phone, isRecording: false) + XCTAssertEqual(isoOnly, .manual(durationSeconds: 1.0 / 120, iso: 800)) + + let shutterOnly = ExposurePolicy.resolve(.manual(durationSeconds: 0.5, iso: 0), facts: phone, isRecording: false) + XCTAssertEqual(shutterOnly, .manual(durationSeconds: 0.5, iso: 64)) + } + + /// A long shutter while recording would lengthen the frame duration and + /// change the clip's frame rate mid-take; the policy caps it at 1/fps. + func testRecordingCapsShutterAtFrameDuration() { + let recording = ExposurePolicy.resolve(.manual(durationSeconds: 0.5, iso: 100), facts: phone, isRecording: true) + XCTAssertEqual(recording, .manual(durationSeconds: 1.0 / 30, iso: 100)) + + let photo = ExposurePolicy.resolve(.manual(durationSeconds: 0.5, iso: 100), facts: phone, isRecording: false) + XCTAssertEqual(photo, .manual(durationSeconds: 0.5, iso: 100)) + } + + /// A frame duration below the sensor's minimum shutter must not invert the + /// range: the floor wins. + func testRecordingCapNeverDropsBelowMinimumShutter() { + var odd = phone + odd.maxFrameDurationSeconds = 1.0 / 100_000 + let plan = ExposurePolicy.resolve(.manual(durationSeconds: 1.0 / 60, iso: 100), facts: odd, isRecording: true) + XCTAssertEqual(plan, .manual(durationSeconds: 1.0 / 10_000, iso: 100)) + } + + func testUnknownFrameDurationDoesNotCap() { + var noFPS = phone + noFPS.maxFrameDurationSeconds = 0 + let plan = ExposurePolicy.resolve(.manual(durationSeconds: 0.5, iso: 100), facts: noFPS, isRecording: true) + XCTAssertEqual(plan, .manual(durationSeconds: 0.5, iso: 100)) + } +} diff --git a/RemoteCamTests/LoopbackSessionTests.swift b/RemoteCamTests/LoopbackSessionTests.swift index 5260fc62..6638ebcb 100644 --- a/RemoteCamTests/LoopbackSessionTests.swift +++ b/RemoteCamTests/LoopbackSessionTests.swift @@ -705,6 +705,61 @@ class LoopbackSessionTests: XCTestCase { XCTAssertEqual(monitorState, .monitor) } + // MARK: - Manual exposure + + func testSetExposureHappyPathAcrossTheWire() async { + let fakeCamera = await connectCameraAndMonitor() + let gate = await monitorCoordinator.peerSupportsManualExposureForTesting() + XCTAssertTrue(gate, "the fake camera advertises manual exposure by default") + monitorTransport.sentMessages.removeAll() + cameraTransport.sentMessages.removeAll() + + monitorCoordinator.tell(UICmd.SetExposure(intent: .manual(durationSeconds: 1.0 / 250, iso: 400))) + await drainBothSessions() + + XCTAssertEqual(fakeCamera.exposureCalls, [.manual(durationSeconds: 1.0 / 250, iso: 400)]) + XCTAssertTrue(fakeCamera.takePictureCalls.isEmpty) + // The camera echoes its truth; the monitor stays put (a setting, not a + // request state that could wedge the screen). + let resp = cameraTransport.sentMessages.compactMap { $0 as? RemoteCmd.SetExposureResp }.last + XCTAssertEqual(resp?.state?.mode, .manual) + XCTAssertEqual(resp?.state?.durationSeconds ?? 0, 1.0 / 250, accuracy: 1e-9) + XCTAssertEqual(resp?.state?.iso ?? 0, 400) + let monitorState = await monitorCoordinator.currentStateName() + XCTAssertEqual(monitorState, .monitor) + + monitorCoordinator.tell(UICmd.SetExposure(intent: .auto)) + await drainBothSessions() + XCTAssertEqual(fakeCamera.exposureCalls.last, .auto) + let autoResp = cameraTransport.sentMessages.compactMap { $0 as? RemoteCmd.SetExposureResp }.last + XCTAssertEqual(autoResp?.state?.mode, .auto) + } + + /// Mirrors the focus gate: a camera whose active device cannot do custom + /// exposure (or a legacy peer) never receives action 33. + func testSetExposureIsNeverSentToPeerWithoutSupport() async { + await connectBothSessions() + let fakeCamera = LoopbackFakeCamera() + fakeCamera.advertisesManualExposure = false + fakeCamera.coordinator = cameraCoordinator + cameraCoordinator.tell(UICmd.BecomeCamera(sender: nil, ctrl: fakeCamera)) + await drainBothSessions() + await becomeMonitor(mode: .Photo) + let gate = await monitorCoordinator.peerSupportsManualExposureForTesting() + XCTAssertFalse(gate) + monitorTransport.sentMessages.removeAll() + + monitorCoordinator.tell(UICmd.SetExposure(intent: .manual(durationSeconds: 0.5, iso: 100))) + await drainBothSessions() + + XCTAssertFalse(monitorTransport.sentMessages.contains { $0 is RemoteCmd.SetExposure }, + "SetExposure must be gated on advertised supports_manual_exposure") + XCTAssertTrue(fakeCamera.exposureCalls.isEmpty) + XCTAssertTrue(fakeCamera.takePictureCalls.isEmpty) + let monitorState = await monitorCoordinator.currentStateName() + XCTAssertEqual(monitorState, .monitor) + } + /// Safety gate mirroring SelectCameraDevice: old peers decode the unknown /// FocusAtPoint action as TakePicture, so the monitor must never send it to a /// peer whose capabilities did not advertise focus-point support. diff --git a/RemoteCamTests/MonitorPresenterTests.swift b/RemoteCamTests/MonitorPresenterTests.swift index 5c44cf2d..280b08d6 100644 --- a/RemoteCamTests/MonitorPresenterTests.swift +++ b/RemoteCamTests/MonitorPresenterTests.swift @@ -93,6 +93,37 @@ class MonitorPresenterTests: XCTestCase { XCTAssertEqual(display.videoRecordingConfigured, 1) } + // MARK: - Exposure echo + + func testUpdateExposureLandsInViewModelOnMain() { + let state = ExposureState(mode: .manual, durationSeconds: 1.0 / 125, iso: 200, + minDurationSeconds: 1.0 / 8000, maxDurationSeconds: 0.5, minISO: 50, maxISO: 1600) + presenter.updateExposure(state) + presenter.updateExposure(nil) // an errored response carries no truth: keep the last one + drain() + XCTAssertEqual(display.viewModel.exposure, state) + } + + func testCapabilitiesCarryExposureSupportAndTruth() { + let state = ExposureState(mode: .auto, durationSeconds: 1.0 / 60, iso: 100, + minDurationSeconds: 1.0 / 8000, maxDurationSeconds: 0.5, minISO: 50, maxISO: 1600) + presenter.updateCapabilities(RemoteCmd.CameraCapabilitiesResp( + frontCamera: nil, backCamera: nil, + currentCamera: .back, currentLens: .wideAngle, currentZoom: 1.0, + supportsManualExposure: true, exposure: state, error: nil)) + drain() + XCTAssertTrue(display.viewModel.supportsManualExposure) + XCTAssertEqual(display.viewModel.exposure, state) + + // A swap to a device that can't: the control disappears. + presenter.updateCapabilities(RemoteCmd.CameraCapabilitiesResp( + frontCamera: nil, backCamera: nil, + currentCamera: .back, currentLens: .wideAngle, currentZoom: 1.0, error: nil)) + drain() + XCTAssertFalse(display.viewModel.supportsManualExposure) + XCTAssertNil(display.viewModel.exposure) + } + // MARK: - BecomeMonitorFailed func testBecomeMonitorFailedExitsMonitor() { diff --git a/RemoteCamTests/RemoteCmdSerializationTests.swift b/RemoteCamTests/RemoteCmdSerializationTests.swift index 7e96549b..5d581492 100644 --- a/RemoteCamTests/RemoteCmdSerializationTests.swift +++ b/RemoteCamTests/RemoteCmdSerializationTests.swift @@ -59,6 +59,8 @@ final class RemoteCmdSerializationTests: XCTestCase { case let m as RemoteCmd.SetZoom: return m.toFlatBuffer() case let m as RemoteCmd.SetZoomResp: return m.toFlatBuffer() case let m as RemoteCmd.FocusAtPoint: return m.toFlatBuffer() + case let m as RemoteCmd.SetExposure: return m.toFlatBuffer() + case let m as RemoteCmd.SetExposureResp: return m.toFlatBuffer() case let m as RemoteCmd.SetCameraPreviewMode: return m.toFlatBuffer() case let m as RemoteCmd.CameraPreviewModeResp: return m.toFlatBuffer() case let m as RemoteCmd.CameraCapabilitiesResp: return m.toFlatBuffer() @@ -359,6 +361,57 @@ final class RemoteCmdSerializationTests: XCTestCase { XCTAssertTrue(decoded.supportsFocusPoint) } + // MARK: - 11c. SetExposure + + private let sampleExposure = ExposureState( + mode: .manual, durationSeconds: 1.0 / 250, iso: 400, + minDurationSeconds: 1.0 / 10_000, maxDurationSeconds: 1.0, minISO: 32, maxISO: 3200) + + func testSetExposure_manualRoundTrip() { + let original = RemoteCmd.SetExposure(intent: .manual(durationSeconds: 1.0 / 250, iso: 400)) + let decoded: RemoteCmd.SetExposure = roundTrip(original) + XCTAssertEqual(decoded.intent, .manual(durationSeconds: 1.0 / 250, iso: 400)) + } + + func testSetExposure_autoRoundTrip() { + let decoded: RemoteCmd.SetExposure = roundTrip(RemoteCmd.SetExposure(intent: .auto)) + XCTAssertEqual(decoded.intent, .auto) + } + + func testSetExposureResp_roundTrip() { + let decoded: RemoteCmd.SetExposureResp = roundTrip(RemoteCmd.SetExposureResp(state: sampleExposure, error: nil)) + XCTAssertEqual(decoded.state, sampleExposure) + XCTAssertNil(decoded.error) + } + + func testSetExposureResp_errorRoundTrip() { + let err = NSError(domain: "No camera device available", code: 0) + let decoded: RemoteCmd.SetExposureResp = roundTrip(RemoteCmd.SetExposureResp(state: nil, error: err)) + XCTAssertNil(decoded.state) + XCTAssertEqual((decoded.error as NSError?)?.domain, "No camera device available") + } + + func testCameraCapabilities_manualExposureRoundTrip() { + let original = RemoteCmd.CameraCapabilitiesResp( + frontCamera: nil, backCamera: nil, + currentCamera: .back, currentLens: .wideAngle, currentZoom: 1.0, + supportsManualExposure: true, exposure: sampleExposure, error: nil) + let decoded: RemoteCmd.CameraCapabilitiesResp = roundTrip(original) + XCTAssertTrue(decoded.supportsManualExposure) + XCTAssertEqual(decoded.exposure, sampleExposure) + } + + /// A peer that predates exposure control leaves the fields absent: the + /// monitor must read "no support, no truth", never a fabricated Auto. + func testCameraCapabilities_legacyPeerHasNoExposure() { + let original = RemoteCmd.CameraCapabilitiesResp( + frontCamera: nil, backCamera: nil, + currentCamera: .back, currentLens: .wideAngle, currentZoom: 1.0, error: nil) + let decoded: RemoteCmd.CameraCapabilitiesResp = roundTrip(original) + XCTAssertFalse(decoded.supportsManualExposure) + XCTAssertNil(decoded.exposure) + } + // MARK: - 12. SetZoomResp func testSetZoomResp_roundTrip() { diff --git a/RemoteCamTests/SessionTestSupport.swift b/RemoteCamTests/SessionTestSupport.swift index 1f64d2be..b243c459 100644 --- a/RemoteCamTests/SessionTestSupport.swift +++ b/RemoteCamTests/SessionTestSupport.swift @@ -122,6 +122,8 @@ class FakeCameraControlling: CameraControlling, @unchecked Sendable { var gatherCapabilitiesCalls = 0 var zoomCalls: [CGFloat] = [] var focusCalls: [CGPoint] = [] + var exposureCalls: [ExposureIntent] = [] + var advertisesManualExposure = true var lensSwitches: [CameraLensType] = [] var torchToggles = 0 var chimes: [Int] = [] @@ -169,6 +171,19 @@ class FakeCameraControlling: CameraControlling, @unchecked Sendable { if let errorToThrow { throw errorToThrow } focusCalls.append(CGPoint(x: CGFloat(x), y: CGFloat(y))) } + /// Echoes the intent clamped into a fixed phone-like range, like the engine. + func setExposure(_ intent: ExposureIntent) async throws -> ExposureState { + if let errorToThrow { throw errorToThrow } + exposureCalls.append(intent) + switch intent { + case .auto: + return ExposureState(mode: .auto, durationSeconds: 1.0 / 120, iso: 64, + minDurationSeconds: 1.0 / 10_000, maxDurationSeconds: 1.0, minISO: 32, maxISO: 3200) + case let .manual(duration, iso): + return ExposureState(mode: .manual, durationSeconds: duration, iso: iso, + minDurationSeconds: 1.0 / 10_000, maxDurationSeconds: 1.0, minISO: 32, maxISO: 3200) + } + } // swiftlint:disable:next large_tuple func switchLens(to lensType: CameraLensType) async throws -> (CameraLensType, [CameraLensType], CGFloat, RemoteCmd.ZoomRange) { if let errorToThrow { throw errorToThrow } @@ -302,6 +317,7 @@ class FakeCameraControlling: CameraControlling, @unchecked Sendable { supportsFocusPoint: advertisesFocusPoint, supportsPreviewMode: advertisesPreviewMode, previewMode: storedPreviewMode, + supportsManualExposure: advertisesManualExposure, error: nil) } diff --git a/RemoteShutter.xcodeproj/project.pbxproj b/RemoteShutter.xcodeproj/project.pbxproj index eec82d51..4db93cd5 100644 --- a/RemoteShutter.xcodeproj/project.pbxproj +++ b/RemoteShutter.xcodeproj/project.pbxproj @@ -182,6 +182,7 @@ CAFEBABE0012000000000002 /* VP9StreamingTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = CAFEBABE0012000000000001 /* VP9StreamingTests.swift */; }; CAFEBABE0099000000000002 /* HEVCFrameEncoder.swift in Sources */ = {isa = PBXBuildFile; fileRef = CAFEBABE0099000000000001 /* HEVCFrameEncoder.swift */; }; CAFEBABE00F0000000000002 /* FocusPointMappingTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = CAFEBABE00F0000000000001 /* FocusPointMappingTests.swift */; }; + E0E0206A0000000000000004 /* ExposurePolicyTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = E0E0206A0000000000000003 /* ExposurePolicyTests.swift */; }; CAFEBABE0121000000000002 /* MonitorChromeTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = CAFEBABE0121000000000001 /* MonitorChromeTests.swift */; }; CAFEBABE0100000000000002 /* PeerCompatibility.swift in Sources */ = {isa = PBXBuildFile; fileRef = CAFEBABE0100000000000001 /* PeerCompatibility.swift */; }; CB5F78DFB9D567955BC863AF /* SoundManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 99FC5340BB98B2BD307FFA1A /* SoundManager.swift */; }; @@ -201,6 +202,7 @@ FADEC0DE0001000000000003 /* FrameCreditWindow.swift in Sources */ = {isa = PBXBuildFile; fileRef = FADEC0DE0001000000000001 /* FrameCreditWindow.swift */; }; FADEC0DE0002000000000002 /* PeerLinkStatus.swift in Sources */ = {isa = PBXBuildFile; fileRef = FADEC0DE0002000000000001 /* PeerLinkStatus.swift */; }; FC0CF5A101FE65A9800F0B238 /* FocusPointMapping.swift in Sources */ = {isa = PBXBuildFile; fileRef = FC0CF5A201FE65A9800F0B238 /* FocusPointMapping.swift */; }; + E0E0206A0000000000000002 /* ExposurePolicy.swift in Sources */ = {isa = PBXBuildFile; fileRef = E0E0206A0000000000000001 /* ExposurePolicy.swift */; }; CAFEBABE0120000000000001 /* MonitorChrome.swift in Sources */ = {isa = PBXBuildFile; fileRef = CAFEBABE0120000000000002 /* MonitorChrome.swift */; }; CAFEBABE0177000000000001 /* SessionDebugConsole.swift in Sources */ = {isa = PBXBuildFile; fileRef = CAFEBABE0177000000000002 /* SessionDebugConsole.swift */; }; CAFEBABE0130000000000001 /* CaptureSyncMetadata.swift in Sources */ = {isa = PBXBuildFile; fileRef = CAFEBABE0130000000000002 /* CaptureSyncMetadata.swift */; }; @@ -449,6 +451,7 @@ CAFEBABE0012000000000001 /* VP9StreamingTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VP9StreamingTests.swift; sourceTree = ""; }; CAFEBABE0099000000000001 /* HEVCFrameEncoder.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HEVCFrameEncoder.swift; sourceTree = ""; }; CAFEBABE00F0000000000001 /* FocusPointMappingTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FocusPointMappingTests.swift; sourceTree = ""; }; + E0E0206A0000000000000003 /* ExposurePolicyTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ExposurePolicyTests.swift; sourceTree = ""; }; CAFEBABE0121000000000001 /* MonitorChromeTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MonitorChromeTests.swift; sourceTree = ""; }; CAFEBABE0100000000000001 /* PeerCompatibility.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PeerCompatibility.swift; sourceTree = ""; }; CD857DFD7882DAA5012B70C9 /* FlatBufferSchemas.fbs */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text; path = FlatBufferSchemas.fbs; sourceTree = ""; }; @@ -463,6 +466,7 @@ FADEC0DE0001000000000001 /* FrameCreditWindow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FrameCreditWindow.swift; sourceTree = ""; }; FADEC0DE0002000000000001 /* PeerLinkStatus.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PeerLinkStatus.swift; sourceTree = ""; }; FC0CF5A201FE65A9800F0B238 /* FocusPointMapping.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = FocusPointMapping.swift; sourceTree = ""; }; + E0E0206A0000000000000001 /* ExposurePolicy.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ExposurePolicy.swift; sourceTree = ""; }; CAFEBABE0120000000000002 /* MonitorChrome.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = MonitorChrome.swift; sourceTree = ""; }; CAFEBABE0177000000000002 /* SessionDebugConsole.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SessionDebugConsole.swift; sourceTree = ""; }; CAFEBABE0130000000000002 /* CaptureSyncMetadata.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = CaptureSyncMetadata.swift; sourceTree = ""; }; @@ -632,6 +636,7 @@ AABB00032E930002009TESTS /* RemoteCmdSerializationTests.swift */, CAFEBABE0001000000000001 /* CropRectTests.swift */, CAFEBABE00F0000000000001 /* FocusPointMappingTests.swift */, + E0E0206A0000000000000003 /* ExposurePolicyTests.swift */, CAFEBABE0121000000000001 /* MonitorChromeTests.swift */, CAFEBABE0131000000000001 /* CaptureSyncMetadataTests.swift */, CAFEBABE0133000000000001 /* ClockOffsetEstimatorTests.swift */, @@ -731,6 +736,7 @@ 0684A2D81BE6E9D400F0B238 /* RemoteCamSession */, 0684A2D01BE65A9800F0B238 /* OrientationUtils.swift */, FC0CF5A201FE65A9800F0B238 /* FocusPointMapping.swift */, + E0E0206A0000000000000001 /* ExposurePolicy.swift */, CAFEBABE0120000000000002 /* MonitorChrome.swift */, CAFEBABE0177000000000002 /* SessionDebugConsole.swift */, CAFEBABE0130000000000002 /* CaptureSyncMetadata.swift */, @@ -1238,6 +1244,7 @@ 0673275D2E2DF142003E5F94 /* PermissionManager.swift in Sources */, 0684A2D11BE65A9800F0B238 /* OrientationUtils.swift in Sources */, FC0CF5A101FE65A9800F0B238 /* FocusPointMapping.swift in Sources */, + E0E0206A0000000000000002 /* ExposurePolicy.swift in Sources */, CAFEBABE0120000000000001 /* MonitorChrome.swift in Sources */, CAFEBABE0177000000000001 /* SessionDebugConsole.swift in Sources */, CAFEBABE0130000000000001 /* CaptureSyncMetadata.swift in Sources */, @@ -1324,6 +1331,7 @@ AABB00042E930002009TESTS /* RemoteCmdSerializationTests.swift in Sources */, CAFEBABE0001000000000002 /* CropRectTests.swift in Sources */, CAFEBABE00F0000000000002 /* FocusPointMappingTests.swift in Sources */, + E0E0206A0000000000000004 /* ExposurePolicyTests.swift in Sources */, CAFEBABE0121000000000002 /* MonitorChromeTests.swift in Sources */, CAFEBABE0131000000000002 /* CaptureSyncMetadataTests.swift in Sources */, CAFEBABE0133000000000002 /* ClockOffsetEstimatorTests.swift in Sources */, From 63cca9837cba85e903fc1018d71c77a51d4eaaec Mon Sep 17 00:00:00 2001 From: Dario Lencina Date: Sun, 23 Aug 2026 23:02:37 -0700 Subject: [PATCH 02/14] =?UTF-8?q?Cinematic=20video,=20pro-controls=20UI,?= =?UTF-8?q?=20and=20IAP=2010=20=E2=80=94=20one-PR=20scope=20(#206)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - CinematicPolicy + SetCinematic=34 + CinematicState on the wire, gated on supports_cinematic_video; engine owns isCinematicVideoCaptureEnabled/ simulatedAperture in one apply function (iOS 26 availability-guarded), Cinematic tap-to-focus routing, mode/recording locks per policy. - Manual-exposure lens hop: entering Manual on a virtual device swaps to activePrimaryConstituent at runtime only when the device refuses .custom. - Monitor UI: PRO tray tile -> ProControlsPanel (Auto/Manual segmented, detented shutter/ISO/aperture dials, echo-driven values, VoiceOver adjustable), camera-side readout chip; behind ENABLE_PRO_CONTROLS. - IAP "10" Pro Controls mirroring tap-to-focus, 15-locale strings. Co-Authored-By: Claude Fable 5 --- Docs/pro-controls.md | 10 +- RemoteCam/CameraControlling.swift | 3 + RemoteCam/CameraRig.swift | 22 +- RemoteCam/CameraScreenView.swift | 17 ++ RemoteCam/CameraViewModel.swift | 34 +++ RemoteCam/CaptureEngine.swift | 246 ++++++++++++++++- RemoteCam/CinematicPolicy.swift | 180 ++++++++++++ RemoteCam/FeatureFlags.swift | 5 + RemoteCam/FlatBufferSchemas.fbs | 29 +- RemoteCam/FlatBufferSchemas_generated.swift | 123 ++++++++- RemoteCam/MonitorChrome.swift | 6 +- RemoteCam/MonitorPresenter.swift | 7 + RemoteCam/MonitorView.swift | 56 +++- RemoteCam/MonitorViewController+SwiftUI.swift | 9 + RemoteCam/MonitorViewModel.swift | 4 + RemoteCam/MulticamView.swift | 5 +- RemoteCam/ProControlsPanel.swift | 256 ++++++++++++++++++ RemoteCam/PurchaseManaging.swift | 2 +- RemoteCam/RemoteCmdFlatBuffers.swift | 74 ++++- RemoteCam/RemoteCmds.swift | 33 +++ RemoteCam/SessionCoordinator.swift | 46 ++++ RemoteCam/SettingsView.swift | 3 + RemoteCam/SettingsViewModel.swift | 6 + RemoteCam/StoreManager.swift | 13 +- RemoteCam/SwiftConstants.swift | 3 + RemoteCam/UICmds.swift | 10 + RemoteCam/WelcomeViewModel.swift | 4 + RemoteCamTests/CinematicPolicyTests.swift | 104 +++++++ RemoteCamTests/LoopbackSessionTests.swift | 46 ++++ .../MonitorScreenSnapshotTests.swift | 23 ++ .../RemoteCmdSerializationTests.swift | 41 +++ RemoteCamTests/SessionTestSupport.swift | 19 ++ RemoteCamTests/StoreManagerTests.swift | 3 +- RemoteShutter.xcodeproj/project.pbxproj | 12 + fastlane/iap_localizations.json | 62 +++++ 35 files changed, 1488 insertions(+), 28 deletions(-) create mode 100644 RemoteCam/CinematicPolicy.swift create mode 100644 RemoteCam/ProControlsPanel.swift create mode 100644 RemoteCamTests/CinematicPolicyTests.swift diff --git a/Docs/pro-controls.md b/Docs/pro-controls.md index 56d7b1be..b13e52b6 100644 --- a/Docs/pro-controls.md +++ b/Docs/pro-controls.md @@ -148,9 +148,9 @@ own `CMTime` range on the camera — the wire never carries a timescale. `RemoteCmd.SetExposure/Resp`, `RemoteCmd.SetCinematic/Resp` in `RemoteCmds.swift`; encode/decode in `RemoteCmdFlatBuffers.swift` next to `SetZoom`. `CameraCapabilitiesResp` gains the two flags and two state tables. -`not_enough_light` changes are pushed by the camera on the existing -`CameraStateReport` channel (action 31) so the monitor hint is live without -polling. +`not_enough_light` is sampled from the device's scene-monitoring statuses +whenever a Cinematic response or capabilities refresh is built — the hint +updates with the next echo rather than by push. ## Monitor → camera path (both controls) @@ -228,8 +228,8 @@ applyCinematicIntentLocked() - An `AVCaptureMetadataOutput` is added to the session only while Cinematic is on (the header requires its `metadataObjectTypes` be set to the Cinematic set); nothing else in the app consumes it. -- KVO on `cinematicVideoCaptureSceneMonitoringStatuses` (main-hopped via the - rig) drives `not_enough_light` and the on-camera/monitor hint. +- `cinematicVideoCaptureSceneMonitoringStatuses` drives `not_enough_light`, + sampled when each response is built. - Tap-to-focus while Cinematic is on routes to `setCinematicVideoTrackingFocus(at: poi, focusMode: .strong)` instead of touching `focusMode` (which would throw). The same `FocusPointMapping` diff --git a/RemoteCam/CameraControlling.swift b/RemoteCam/CameraControlling.swift index 656dd73f..ce659c01 100644 --- a/RemoteCam/CameraControlling.swift +++ b/RemoteCam/CameraControlling.swift @@ -49,6 +49,9 @@ protocol CameraControlling: AnyObject, Sendable { /// Auto or manual (shutter + ISO) exposure. The device clamps into its /// active format's range; the returned state is the truth to echo. func setExposure(_ intent: ExposureIntent) async throws -> ExposureState + /// Cinematic video (iOS 26+) on/off + simulated aperture; the returned + /// truth is what the monitor renders. + func setCinematic(_ intent: CinematicIntent) async throws -> CinematicState func switchLens(to lensType: CameraLensType) async throws -> (CameraLensType, [CameraLensType], CGFloat, RemoteCmd.ZoomRange) func toggleFlash() async throws -> AVCaptureDevice.FlashMode func toggleTorch() async throws -> AVCaptureDevice.TorchMode diff --git a/RemoteCam/CameraRig.swift b/RemoteCam/CameraRig.swift index 70fae1eb..03204588 100644 --- a/RemoteCam/CameraRig.swift +++ b/RemoteCam/CameraRig.swift @@ -84,7 +84,13 @@ final class CameraRig: @unchecked Sendable { private let currentCameraModeShared = Locked(RecordingMode.Photo) var currentCameraMode: RecordingMode { get { currentCameraModeShared.value } - set { currentCameraModeShared.value = newValue } + set { + let leftVideoMode = currentCameraModeShared.value == .Video && newValue != .Video + currentCameraModeShared.value = newValue + // Cinematic only applies to video: leaving the mode switches the + // effect off (the engine is a no-op when it wasn't on). + if leftVideoMode { engine.disableCinematicIfActive() } + } } // MARK: - Shell seams @@ -148,6 +154,10 @@ final class CameraRig: @unchecked Sendable { // The exposure policy caps a long shutter at the frame duration while // a clip is rolling; recording truth lives in the pipeline. engine.isRecordingProvider = { [pipeline] in pipeline.isRecording } + // Cinematic is a video-recording effect; mode truth lives here. + engine.isVideoModeProvider = { [currentCameraModeShared] in + currentCameraModeShared.value == .Video + } // Captures the session ref (not self) so recording acks/responses still // reach the actor if the rig deallocates mid-recording. pipeline.sendMessage = { [session] msg in @@ -520,7 +530,15 @@ extension CameraRig: CameraControlling { } func setExposure(_ intent: ExposureIntent) async throws -> ExposureState { - try await engine.setExposure(intent) + let state = try await engine.setExposure(intent) + cameraViewModel.updateExposureReadout(state) + return state + } + + func setCinematic(_ intent: CinematicIntent) async throws -> CinematicState { + let state = try await engine.setCinematic(intent) + cameraViewModel.updateCinematicReadout(state) + return state } func focusAtPoint(x: Float, y: Float) async throws { diff --git a/RemoteCam/CameraScreenView.swift b/RemoteCam/CameraScreenView.swift index f075e21b..b08a1da8 100644 --- a/RemoteCam/CameraScreenView.swift +++ b/RemoteCam/CameraScreenView.swift @@ -69,6 +69,23 @@ struct CameraScreenView: View { .overlay(focusReticleOverlay) } + // Pro-controls chip, top edge: the remote is driving exposure or + // Cinematic; the person at the camera should see what it's set to. + if let readout = viewModel.proReadout { + VStack { + Text(readout) + .font(.system(size: 12, weight: .semibold, design: .monospaced)) + .foregroundColor(.white) + .padding(.horizontal, 10) + .padding(.vertical, 5) + .background(Capsule().fill(.ultraThinMaterial)) + .padding(.top, 54) + Spacer() + } + .allowsHitTesting(false) + .transition(.opacity) + } + // Animated "recording" badge, top center — visible only in video mode. VStack { if viewModel.isRecordingIndicatorVisible { diff --git a/RemoteCam/CameraViewModel.swift b/RemoteCam/CameraViewModel.swift index 410395e3..be6ac49b 100644 --- a/RemoteCam/CameraViewModel.swift +++ b/RemoteCam/CameraViewModel.swift @@ -176,6 +176,40 @@ class CameraViewModel: ObservableObject { } } + // MARK: - Pro-controls readout + /// What the remote is driving, shown as a chip on the preview so the + /// person holding the camera can see it ("M 1/125 · ISO 400 · f/2.8"). + /// nil when everything is automatic. + @Published var proReadout: String? + + private var exposureReadoutText: String? + private var cinematicReadoutText: String? + + func updateExposureReadout(_ state: ExposureState) { + DispatchQueue.main.async { [weak self] in + guard let self else { return } + self.exposureReadoutText = state.mode == .manual + ? "M \(ProDialStops.shutterLabel(state.durationSeconds)) · \(ProDialStops.isoLabel(state.iso))" + : nil + self.recomposeProReadout() + } + } + + func updateCinematicReadout(_ state: CinematicState) { + DispatchQueue.main.async { [weak self] in + guard let self else { return } + self.cinematicReadoutText = state.enabled + ? "CINEMATIC \(ProDialStops.apertureLabel(state.simulatedAperture))" + : nil + self.recomposeProReadout() + } + } + + private func recomposeProReadout() { + let parts = [exposureReadoutText, cinematicReadoutText].compactMap { $0 } + proReadout = parts.isEmpty ? nil : parts.joined(separator: " · ") + } + // MARK: - Remote Focus Indicator /// Shown when a remote focus command arrives, so the person holding the /// camera sees the tap register — the same reticle the monitor draws. diff --git a/RemoteCam/CaptureEngine.swift b/RemoteCam/CaptureEngine.swift index ab1a3e5a..7c5c08bf 100644 --- a/RemoteCam/CaptureEngine.swift +++ b/RemoteCam/CaptureEngine.swift @@ -128,6 +128,17 @@ final class CaptureEngine: NSObject, AVCapturePhotoCaptureDelegate { /// Recording truth lives in the rig's pipeline; the policy needs it to cap /// a long shutter at the frame duration while a clip is rolling. var isRecordingProvider: () -> Bool = { false } + /// While Manual is active on a virtual multi-lens device the engine swaps + /// to the physical constituent (virtual devices refuse `.custom`); this + /// remembers the virtual device to restore when exposure returns to Auto. + private var manualExposureRestoreDeviceID: String? + + // MARK: - Cinematic Video (iOS 26+) + /// The monitor's Cinematic intent; the session is made to match by exactly + /// one function, `applyCinematicIntentLocked()`. sessionQueue-confined. + private var cinematicIntent: CinematicIntent = .off + /// Cinematic is a video-recording effect; mode truth lives in the rig. + var isVideoModeProvider: () -> Bool = { false } // MARK: - Callbacks to the view controller /// Forwards a finished photo capture. `(data, nil)` on success, `(nil, error)` @@ -276,7 +287,10 @@ final class CaptureEngine: NSObject, AVCapturePhotoCaptureDelegate { // The AVCaptureDevice outlives this session: leave it in auto so // the next session (or the system Camera app) starts clean. self.exposureIntent = .auto + self.manualExposureRestoreDeviceID = nil _ = self.applyExposureIntentLocked() + self.cinematicIntent = .off + _ = self.applyCinematicIntentLocked() if self.captureSession.isRunning { self.captureSession.stopRunning() } @@ -382,6 +396,7 @@ final class CaptureEngine: NSObject, AVCapturePhotoCaptureDelegate { applyDesiredTorchLocked() // restore torch onto the new camera (no-op if it has none) resetFocusExposureToAutoLocked() // a stale focus point must not carry across a device change _ = applyExposureIntentLocked() // the new device must match the monitor's exposure intent + _ = applyCinematicIntentLocked() // support flips with the device; re-enable or fall off honestly // Swapping away from a dead device must also revive a session that a // runtime error stopped — otherwise the new camera never delivers. if isExpectedToRun && !captureSession.isRunning { @@ -1018,6 +1033,20 @@ final class CaptureEngine: NSObject, AVCapturePhotoCaptureDelegate { ) } + /// Availability bridges for capability gathering (the gather site cannot + /// use #available inline in an argument list). + private func supportsCinematicVideoLocked() -> Bool { + guard #available(iOS 26.0, macCatalyst 26.0, *) else { return false } + guard let device = videoDeviceInput?.device else { return false } + return cinematicRangeFormatLocked(device) != nil + } + + private func currentCinematicStateLocked() -> CinematicState? { + guard #available(iOS 26.0, macCatalyst 26.0, *) else { return nil } + guard let input = videoDeviceInput, let device = videoDeviceInput?.device else { return nil } + return cinematicStateLocked(input: input, device: device) + } + // MARK: - Current Camera Capabilities for Toggle Response func gatherCurrentCameraCapabilities() async -> RemoteCmd.CameraCapabilitiesResp? { await onSessionQueue { self.gatherCurrentCameraCapabilitiesLocked() } @@ -1061,11 +1090,14 @@ final class CaptureEngine: NSObject, AVCapturePhotoCaptureDelegate { // release the director UI ships. supportsMulticam: FeatureFlags.ENABLE_MULTICAM, previewMode: CameraPreviewModeStore().load(), - // A property of the ACTIVE device (virtual multi-lens devices and - // most Mac cameras refuse .custom), so it is re-advertised on every - // capabilities refresh after a swap. - supportsManualExposure: currentDevice.isExposureModeSupported(.custom), + // A property of the ACTIVE device, re-advertised on every + // capabilities refresh after a swap. Virtual devices qualify when + // their active physical lens accepts .custom (the engine hops to + // it when Manual is engaged). + supportsManualExposure: deviceSupportsManualExposureLocked(currentDevice), exposure: exposureStateLocked(currentDevice), + supportsCinematicVideo: supportsCinematicVideoLocked(), + cinematic: currentCinematicStateLocked(), error: nil ) @@ -1140,6 +1172,16 @@ final class CaptureEngine: NSObject, AVCapturePhotoCaptureDelegate { videoOrientation: videoOrientation, mirrored: device.position == .front) + // While Cinematic video is on, focusMode is pinned (setting it throws) + // and taps become Cinematic tracking focus: lock onto the subject at + // the tapped point until it leaves the scene. + if #available(iOS 26.0, macCatalyst 26.0, *), + videoDeviceInput?.isCinematicVideoCaptureEnabled == true { + device.setCinematicVideoTrackingFocus(at: poi, focusMode: .strong) + debugLog("🎬 CINEMATIC: tracking focus at \(poi)") + return + } + try device.lockForConfiguration() defer { device.unlockForConfiguration() } @@ -1190,6 +1232,7 @@ final class CaptureEngine: NSObject, AVCapturePhotoCaptureDelegate { func setExposure(_ intent: ExposureIntent) async throws -> ExposureState { try await onSessionQueueThrowing { self.exposureIntent = intent + self.reconcileExposureDeviceLocked() guard let state = self.applyExposureIntentLocked() else { throw NSError(domain: "No camera device available", code: 0, userInfo: nil) } @@ -1267,6 +1310,200 @@ final class CaptureEngine: NSObject, AVCapturePhotoCaptureDelegate { return exposureStateLocked(device) } + /// True when Manual exposure is worth offering on this device: it accepts + /// `.custom` itself, or it is a virtual device whose active physical lens + /// does (the engine swaps to that lens when Manual is engaged). + private func deviceSupportsManualExposureLocked(_ device: AVCaptureDevice) -> Bool { + if device.isExposureModeSupported(.custom) { return true } + return device.isVirtualDevice + && device.activePrimaryConstituent?.isExposureModeSupported(.custom) == true + } + + /// The ONLY place a manual-exposure lens swap happens: entering Manual on + /// a virtual device hops to its active physical lens; returning to Auto + /// hops back. Re-apply sites never swap (swapToDeviceLocked calls + /// applyExposureIntentLocked, so a swap from there would recurse). + private func reconcileExposureDeviceLocked() { + dispatchPrecondition(condition: .onQueue(sessionQueue)) + guard let device = videoDeviceInput?.device else { return } + switch exposureIntent { + case .manual: + guard !device.isExposureModeSupported(.custom), + device.isVirtualDevice, + let physical = device.activePrimaryConstituent, + physical.isExposureModeSupported(.custom) else { return } + manualExposureRestoreDeviceID = device.uniqueID + debugLog("🌗 EXPOSURE: manual on virtual \(device.localizedName) — hopping to \(physical.localizedName)") + _ = try? swapToDeviceLocked(physical, orientation: orientation) + case .auto: + guard let restoreID = manualExposureRestoreDeviceID else { return } + manualExposureRestoreDeviceID = nil + let discovered = AVCaptureDevice.DiscoverySession( + deviceTypes: getAllDeviceTypes(), mediaType: .video, + position: .unspecified).devices + guard let virtual = discovered.first(where: { $0.uniqueID == restoreID }) else { return } + debugLog("🌗 EXPOSURE: back to auto — restoring \(virtual.localizedName)") + _ = try? swapToDeviceLocked(virtual, orientation: orientation) + } + } + + // MARK: - Cinematic Video (iOS 26+) + + /// Stores the monitor's intent and makes the session match it. Returns the + /// camera's Cinematic truth afterwards (the response payload). + func setCinematic(_ intent: CinematicIntent) async throws -> CinematicState { + try await onSessionQueueThrowing { + self.cinematicIntent = intent + guard let state = self.applyCinematicIntentLocked() else { + throw NSError(domain: "No camera device available", code: 0, userInfo: nil) + } + return state + } + } + + /// Rig hook for mode changes: leaving video mode switches the effect off + /// (it only applies to recording). Fire-and-forget onto the sessionQueue. + func disableCinematicIfActive() { + sessionQueue.async { + guard case .on = self.cinematicIntent else { return } + self.cinematicIntent = .off + _ = self.applyCinematicIntentLocked() + } + } + + /// The ONE place that touches `isCinematicVideoCaptureEnabled` and + /// `simulatedAperture`. Returns nil only when there is no device. + @discardableResult + private func applyCinematicIntentLocked() -> CinematicState? { + dispatchPrecondition(condition: .onQueue(sessionQueue)) + guard let input = videoDeviceInput, let device = videoDeviceInput?.device else { return nil } + guard #available(iOS 26.0, macCatalyst 26.0, *) else { + cinematicIntent = .off + return CinematicState(enabled: false, simulatedAperture: 0, + minSimulatedAperture: 0, maxSimulatedAperture: 0, + defaultSimulatedAperture: 0, apertureLocked: false, notEnoughLight: false) + } + + let facts = cinematicFactsLocked(input: input, device: device) + let plan = CinematicPolicy.resolve(cinematicIntent, facts: facts, + isRecording: isRecordingProvider(), + isVideoMode: isVideoModeProvider()) + switch plan { + case .noop: + break + + case let .rejected(reason): + // The response carries the unchanged truth; the intent must not + // outlive a rejection or a later re-apply would spring it back. + debugLog("🎬 CINEMATIC: rejected (\(reason))") + cinematicIntent = facts.enabled ? .on(aperture: nil) : .off + + case let .enable(aperture): + captureSession.beginConfiguration() + if !device.activeFormat.isCinematicVideoCaptureSupported, + let format = findCinematicFormatLocked(device) { + captureSession.sessionPreset = .inputPriority + if (try? device.lockForConfiguration()) != nil { + device.activeFormat = format + device.unlockForConfiguration() + } + } + if input.isCinematicVideoCaptureSupported { + input.isCinematicVideoCaptureEnabled = true + } + captureSession.commitConfiguration() + + guard input.isCinematicVideoCaptureEnabled else { + debugLog("🎬 CINEMATIC: session refused to enable — reporting off") + cinematicIntent = .off + break + } + // Cinematic narrows the legal frame rates; clamp into the range's + // OWN CMTimes (never rebuild from integers). + if let range = device.activeFormat.videoFrameRateRangeForCinematicVideo, + (try? device.lockForConfiguration()) != nil { + var duration = device.activeVideoMaxFrameDuration + if CMTimeCompare(duration, range.minFrameDuration) < 0 { duration = range.minFrameDuration } + if CMTimeCompare(duration, range.maxFrameDuration) > 0 { duration = range.maxFrameDuration } + device.activeVideoMaxFrameDuration = duration + device.activeVideoMinFrameDuration = duration + // Zoom is narrowed too. + let clampedZoom = max(device.activeFormat.videoMinZoomFactorForCinematicVideo, + min(device.videoZoomFactor, device.activeFormat.videoMaxZoomFactorForCinematicVideo)) + device.videoZoomFactor = clampedZoom + currentZoomFactor = clampedZoom + device.unlockForConfiguration() + } + if let aperture, device.activeFormat.minSimulatedAperture > 0 { + input.simulatedAperture = aperture + } + debugLog("🎬 CINEMATIC: enabled f/\(input.simulatedAperture) on \(device.localizedName)") + + case let .apertureOnly(aperture): + if device.activeFormat.minSimulatedAperture > 0 { + input.simulatedAperture = aperture + } + + case .disable: + captureSession.beginConfiguration() + input.isCinematicVideoCaptureEnabled = false + captureSession.commitConfiguration() + // Restore the format/frame rate the quality setting chose (this + // also re-applies zoom, torch and the exposure intent). + _ = setVideoQualityLocked(resolution: currentVideoResolution, + frameRate: currentVideoFrameRate, + isRecording: false) + debugLog("🎬 CINEMATIC: disabled") + } + return cinematicStateLocked(input: input, device: device) + } + + @available(iOS 26.0, macCatalyst 26.0, *) + private func cinematicFactsLocked(input: AVCaptureDeviceInput, device: AVCaptureDevice) -> CinematicFacts { + let format = cinematicRangeFormatLocked(device) + return CinematicFacts( + supported: format != nil, + enabled: input.isCinematicVideoCaptureEnabled, + minAperture: format?.minSimulatedAperture ?? 0, + maxAperture: format?.maxSimulatedAperture ?? 0, + defaultAperture: format?.defaultSimulatedAperture ?? 0, + currentAperture: input.simulatedAperture) + } + + @available(iOS 26.0, macCatalyst 26.0, *) + private func cinematicStateLocked(input: AVCaptureDeviceInput, device: AVCaptureDevice) -> CinematicState { + let facts = cinematicFactsLocked(input: input, device: device) + return CinematicState( + enabled: facts.enabled, + simulatedAperture: facts.currentAperture, + minSimulatedAperture: facts.minAperture, + maxSimulatedAperture: facts.maxAperture, + defaultSimulatedAperture: facts.defaultAperture, + apertureLocked: isRecordingProvider(), + notEnoughLight: device.cinematicVideoCaptureSceneMonitoringStatuses.contains(.notEnoughLight)) + } + + /// The format whose aperture range the truth is reported from: the active + /// format when it can do Cinematic, else the best candidate a switch would + /// land on. nil = this device cannot do Cinematic at all. + @available(iOS 26.0, macCatalyst 26.0, *) + private func cinematicRangeFormatLocked(_ device: AVCaptureDevice) -> AVCaptureDevice.Format? { + if device.activeFormat.isCinematicVideoCaptureSupported { return device.activeFormat } + return findCinematicFormatLocked(device) + } + + /// Prefers a Cinematic-capable format at the current resolution; falls back + /// to the first Cinematic format of any size. + @available(iOS 26.0, macCatalyst 26.0, *) + private func findCinematicFormatLocked(_ device: AVCaptureDevice) -> AVCaptureDevice.Format? { + let cinematic = device.formats.filter { $0.isCinematicVideoCaptureSupported } + let target = currentVideoResolution.dimensions + return cinematic.first(where: { + let dims = CMVideoFormatDescriptionGetDimensions($0.formatDescription) + return dims.width == target.width && dims.height == target.height + }) ?? cinematic.first + } + // MARK: - Enhanced Zoom Control Methods func setZoom(zoomFactor: CGFloat) async throws -> (CGFloat, CameraLensType, RemoteCmd.ZoomRange) { try await onSessionQueueThrowing { try self.setZoomLocked(zoomFactor: zoomFactor) } @@ -1584,6 +1821,7 @@ final class CaptureEngine: NSObject, AVCapturePhotoCaptureDelegate { applyDesiredTorchLocked() // changing activeFormat/preset also resets the torch _ = applyExposureIntentLocked() // ranges and the frame-rate cap changed with the format + _ = applyCinematicIntentLocked() // a format change silently reverts Cinematic; re-assert the intent currentVideoResolution = resolution currentVideoFrameRate = appliedFrameRate diff --git a/RemoteCam/CinematicPolicy.swift b/RemoteCam/CinematicPolicy.swift new file mode 100644 index 00000000..8002ff8f --- /dev/null +++ b/RemoteCam/CinematicPolicy.swift @@ -0,0 +1,180 @@ +// +// CinematicPolicy.swift +// RemoteShutter +// +// Cinematic video (iOS 26 simulated aperture) as pure values and one decision +// function, mirroring ExposurePolicy: the engine turns a `CinematicIntent` +// into session calls; the policy decides what is allowed. No AVFoundation +// types cross this boundary. See Docs/pro-controls.md. +// + +import Foundation + +/// What the monitor asked for. `aperture` nil (or ≤ 0 on the wire) means +/// "keep the camera's current value" (the format default on first enable). +public enum CinematicIntent: Equatable, Sendable { + case off + case on(aperture: Float?) +} + +/// The booleans and ranges the policy needs from the device + session. +struct CinematicFacts: Equatable, Sendable { + /// iOS 26+ and the active device has a Cinematic-capable format. + var supported: Bool + var enabled: Bool + /// 0 when the device cannot adjust the simulated aperture. + var minAperture: Float + var maxAperture: Float + var defaultAperture: Float + var currentAperture: Float +} + +/// What the engine should do to the session. +enum CinematicPlan: Equatable, Sendable { + case noop + /// Session reconfiguration (begin/commitConfiguration) + aperture. + case enable(aperture: Float?) + /// The effect is already on; only the aperture moves (no reconfig). + case apertureOnly(Float) + case disable + case rejected(CinematicRejection) +} + +enum CinematicRejection: Equatable, Sendable { + /// Cinematic is a video-recording effect; the camera is in photo mode. + case photoMode + /// Apple rejects enable/disable/aperture changes while a take is rolling. + case recording + /// Device or OS cannot do Cinematic video. + case unsupported +} + +/// Snapshot of the camera's Cinematic truth, echoed after every command and +/// carried in capabilities. +public struct CinematicState: Equatable, Sendable { + public var enabled: Bool + public var simulatedAperture: Float + /// 0 = the device cannot adjust the aperture (hide the dial). + public var minSimulatedAperture: Float + public var maxSimulatedAperture: Float + public var defaultSimulatedAperture: Float + /// True while recording: the aperture is set before a take, never during. + public var apertureLocked: Bool + /// The camera reports the scene is too dark for a good Cinematic result. + public var notEnoughLight: Bool + + public init(enabled: Bool, simulatedAperture: Float, + minSimulatedAperture: Float, maxSimulatedAperture: Float, + defaultSimulatedAperture: Float, apertureLocked: Bool, notEnoughLight: Bool) { + self.enabled = enabled + self.simulatedAperture = simulatedAperture + self.minSimulatedAperture = minSimulatedAperture + self.maxSimulatedAperture = maxSimulatedAperture + self.defaultSimulatedAperture = defaultSimulatedAperture + self.apertureLocked = apertureLocked + self.notEnoughLight = notEnoughLight + } +} + +enum CinematicPolicy { + + /// The one decision. Order matters: an unsupported device is unsupported in + /// every mode; a supported one is then bounded by mode and recording. + static func resolve(_ intent: CinematicIntent, + facts: CinematicFacts, + isRecording: Bool, + isVideoMode: Bool) -> CinematicPlan { + switch intent { + case .off: + guard facts.enabled else { return .noop } + guard !isRecording else { return .rejected(.recording) } + return .disable + + case let .on(requestedAperture): + guard facts.supported else { return .rejected(.unsupported) } + guard isVideoMode else { return .rejected(.photoMode) } + guard !isRecording else { return .rejected(.recording) } + + let aperture = clampedAperture(requestedAperture, facts: facts) + if facts.enabled { + guard let aperture, aperture != facts.currentAperture else { return .noop } + return .apertureOnly(aperture) + } + return .enable(aperture: aperture) + } + } + + /// nil when the device cannot adjust the aperture (min == 0) or nothing + /// was requested and nothing is set yet (the format default applies). + static func clampedAperture(_ requested: Float?, facts: CinematicFacts) -> Float? { + guard facts.minAperture > 0 else { return nil } + guard let requested, requested > 0 else { return nil } + return min(max(requested, facts.minAperture), facts.maxAperture) + } +} + +// MARK: - Dial stops + +/// The detents the monitor's dials snap to, in values a photographer +/// recognizes, filtered to what the connected camera's format allows. +enum ProDialStops { + + /// Standard shutter stops from 1/8000 s up to 1 s. + static let allShutterSeconds: [Double] = [ + 1.0 / 8000, 1.0 / 4000, 1.0 / 2000, 1.0 / 1000, 1.0 / 500, 1.0 / 250, + 1.0 / 125, 1.0 / 60, 1.0 / 30, 1.0 / 15, 1.0 / 8, 1.0 / 4, 1.0 / 3, + 1.0 / 2, 1.0 + ] + + /// ISO in ⅓-stops. + static let allISO: [Float] = [ + 25, 32, 40, 50, 64, 80, 100, 125, 160, 200, 250, 320, 400, 500, 640, + 800, 1000, 1250, 1600, 2000, 2500, 3200, 4000, 5000, 6400, 8000, 10_000 + ] + + /// f-numbers in ⅓-stops (the Camera-app Depth Control range). + static let allApertures: [Float] = [ + 1.4, 1.6, 1.8, 2.0, 2.2, 2.5, 2.8, 3.2, 3.5, 4.0, 4.5, 5.0, 5.6, + 6.3, 7.1, 8.0, 9.0, 10, 11, 13, 14, 16 + ] + + static func shutterStops(min: Double, max: Double) -> [Double] { + allShutterSeconds.filter { $0 >= min && $0 <= max } + } + + static func isoStops(min: Float, max: Float) -> [Float] { + allISO.filter { $0 >= min && $0 <= max } + } + + static func apertureStops(min: Float, max: Float) -> [Float] { + guard min > 0 else { return [] } + return allApertures.filter { $0 >= min && $0 <= max } + } + + /// Index of the stop closest to `value` (the dial's resting detent). + static func nearestIndex(of value: T, in stops: [T]) -> Int? { + guard !stops.isEmpty else { return nil } + return stops.enumerated().min { abs($0.element - value) < abs($1.element - value) }?.offset + } + + /// "1/125" below a second, "0.5s" / "1s" at or above. + static func shutterLabel(_ seconds: Double) -> String { + guard seconds > 0 else { return "—" } + if seconds < 0.25 { + return "1/\(Int((1.0 / seconds).rounded()))" + } + let formatted = seconds == seconds.rounded() + ? String(Int(seconds)) : String(format: "%.1f", seconds) + return "\(formatted)s" + } + + static func isoLabel(_ iso: Float) -> String { + "ISO \(Int(iso.rounded()))" + } + + static func apertureLabel(_ aperture: Float) -> String { + let value = aperture == aperture.rounded() + ? String(Int(aperture)) : String(format: "%.1f", aperture) + return "f/\(value)" + } +} diff --git a/RemoteCam/FeatureFlags.swift b/RemoteCam/FeatureFlags.swift index ca40a4dd..eafada3b 100644 --- a/RemoteCam/FeatureFlags.swift +++ b/RemoteCam/FeatureFlags.swift @@ -35,6 +35,11 @@ struct FeatureFlags { /// synced capture. Off until the feature ships (target 9.1.0); while off, /// cameras advertise `supports_multicam=false` and the scanner keeps its /// single-camera flow. + /// Pro controls (issue #206): manual shutter/ISO + Cinematic video from + /// the monitor. Gates only the monitor UI; the wire capability is always + /// advertised (harmless without a control). + static let ENABLE_PRO_CONTROLS = true + static let ENABLE_MULTICAM = true /// Route a single connected camera to the multicam director too, instead diff --git a/RemoteCam/FlatBufferSchemas.fbs b/RemoteCam/FlatBufferSchemas.fbs index 2e676cf2..b17d6581 100644 --- a/RemoteCam/FlatBufferSchemas.fbs +++ b/RemoteCam/FlatBufferSchemas.fbs @@ -68,11 +68,16 @@ enum CommandAction : byte { // state_recording_phase, elapsed ticks). RequestCameraStateReport = 32, // monitor/director -> camera: re-push the // current CameraStateReport (e.g. on connection). - SetExposure = 33 // monitor -> camera: auto / manual (shutter + ISO). + SetExposure = 33, // monitor -> camera: auto / manual (shutter + ISO). // Payload in CommandParameters (exposure_*); the // camera answers with a CameraStateResponse carrying // ExposureState. Only sent to peers advertising // supports_manual_exposure. + SetCinematic = 34 // monitor -> camera: Cinematic video on/off + simulated + // aperture (iOS 26+). Payload in CommandParameters + // (cinematic_*); answered with a CameraStateResponse + // carrying CinematicState. Only sent to peers + // advertising supports_cinematic_video. } // Auto vs. manual exposure. Unknown = legacy peer / field absent. @@ -239,6 +244,9 @@ table CommandParameters { exposure_mode: ExposureMode; exposure_duration_seconds: double; exposure_iso: float; + // SetCinematic payload. Aperture 0 = keep the camera's current value. + cinematic_enabled: bool; + simulated_aperture: float; } // MARK: - Command Structure @@ -263,6 +271,20 @@ table ExposureState { max_iso: float; } +// The camera's Cinematic-video truth: whether the effect is on, the applied +// simulated aperture and the active format's range (0 = aperture not +// adjustable), whether the aperture is locked (recording in progress — Apple +// rejects mid-take changes), and the scene-monitoring "too dark" hint. +table CinematicState { + enabled: bool; + simulated_aperture: float; + min_simulated_aperture: float; + max_simulated_aperture: float; + default_simulated_aperture: float; + aperture_locked: bool; + not_enough_light: bool; +} + table ZoomRange { min_zoom: double; max_zoom: double; @@ -361,6 +383,10 @@ table CameraCapabilities { // SetExposure to such a peer and shows no exposure control. supports_manual_exposure: bool; exposure: ExposureState; + // False/absent = the active device/OS cannot record Cinematic video; a + // monitor must not send SetCinematic and shows no Cinematic control. + supports_cinematic_video: bool; + cinematic: CinematicState; } // MARK: - Response Structure @@ -381,6 +407,7 @@ table CameraStateResponse { clock_sync_camera_clock_ms: uint64; // ClockSyncPing response: camera clock at receipt capture_id_echo: string; // ScheduledCapture ack: the accepted capture id exposure: ExposureState; // SetExposure response: applied values + ranges + cinematic: CinematicState; // SetCinematic response: applied truth + range } // MARK: - Frame Data diff --git a/RemoteCam/FlatBufferSchemas_generated.swift b/RemoteCam/FlatBufferSchemas_generated.swift index e5090ca7..5d6b5c20 100644 --- a/RemoteCam/FlatBufferSchemas_generated.swift +++ b/RemoteCam/FlatBufferSchemas_generated.swift @@ -42,8 +42,9 @@ public enum RemoteShutter_CommandAction: Int8, Enum, Verifiable { case camerastatereport = 31 case requestcamerastatereport = 32 case setexposure = 33 + case setcinematic = 34 - public static var max: RemoteShutter_CommandAction { return .setexposure } + public static var max: RemoteShutter_CommandAction { return .setcinematic } public static var min: RemoteShutter_CommandAction { return .unknown } } @@ -381,6 +382,8 @@ public struct RemoteShutter_CommandParameters: FlatBufferObject, Verifiable { case exposureMode = 66 case exposureDurationSeconds = 68 case exposureIso = 70 + case cinematicEnabled = 72 + case simulatedAperture = 74 var v: Int32 { Int32(self.rawValue) } var p: VOffset { self.rawValue } } @@ -424,7 +427,9 @@ public struct RemoteShutter_CommandParameters: FlatBufferObject, Verifiable { public var exposureMode: RemoteShutter_ExposureMode { let o = _accessor.offset(VTOFFSET.exposureMode.v); return o == 0 ? .unknown : RemoteShutter_ExposureMode(rawValue: _accessor.readBuffer(of: Int8.self, at: o)) ?? .unknown } public var exposureDurationSeconds: Double { let o = _accessor.offset(VTOFFSET.exposureDurationSeconds.v); return o == 0 ? 0.0 : _accessor.readBuffer(of: Double.self, at: o) } public var exposureIso: Float32 { let o = _accessor.offset(VTOFFSET.exposureIso.v); return o == 0 ? 0.0 : _accessor.readBuffer(of: Float32.self, at: o) } - public static func startCommandParameters(_ fbb: inout FlatBufferBuilder) -> UOffset { fbb.startTable(with: 34) } + public var cinematicEnabled: Bool { let o = _accessor.offset(VTOFFSET.cinematicEnabled.v); return o == 0 ? false : _accessor.readBuffer(of: Bool.self, at: o) } + public var simulatedAperture: Float32 { let o = _accessor.offset(VTOFFSET.simulatedAperture.v); return o == 0 ? 0.0 : _accessor.readBuffer(of: Float32.self, at: o) } + public static func startCommandParameters(_ fbb: inout FlatBufferBuilder) -> UOffset { fbb.startTable(with: 36) } public static func add(sendToRemote: Bool, _ fbb: inout FlatBufferBuilder) { fbb.add(element: sendToRemote, def: false, at: VTOFFSET.sendToRemote.p) } public static func add(zoomFactor: Double, _ fbb: inout FlatBufferBuilder) { fbb.add(element: zoomFactor, def: 0.0, at: VTOFFSET.zoomFactor.p) } @@ -460,6 +465,9 @@ public struct RemoteShutter_CommandParameters: FlatBufferObject, Verifiable { public static func add(exposureMode: RemoteShutter_ExposureMode, _ fbb: inout FlatBufferBuilder) { fbb.add(element: exposureMode.rawValue, def: 0, at: VTOFFSET.exposureMode.p) } public static func add(exposureDurationSeconds: Double, _ fbb: inout FlatBufferBuilder) { fbb.add(element: exposureDurationSeconds, def: 0.0, at: VTOFFSET.exposureDurationSeconds.p) } public static func add(exposureIso: Float32, _ fbb: inout FlatBufferBuilder) { fbb.add(element: exposureIso, def: 0.0, at: VTOFFSET.exposureIso.p) } + public static func add(cinematicEnabled: Bool, _ fbb: inout FlatBufferBuilder) { fbb.add(element: cinematicEnabled, def: false, + at: VTOFFSET.cinematicEnabled.p) } + public static func add(simulatedAperture: Float32, _ fbb: inout FlatBufferBuilder) { fbb.add(element: simulatedAperture, def: 0.0, at: VTOFFSET.simulatedAperture.p) } public static func endCommandParameters(_ fbb: inout FlatBufferBuilder, start: UOffset) -> Offset { let end = Offset(offset: fbb.endTable(at: start)); return end } public static func createCommandParameters( _ fbb: inout FlatBufferBuilder, @@ -496,7 +504,9 @@ public struct RemoteShutter_CommandParameters: FlatBufferObject, Verifiable { stateRecordingElapsedMs: UInt64 = 0, exposureMode: RemoteShutter_ExposureMode = .unknown, exposureDurationSeconds: Double = 0.0, - exposureIso: Float32 = 0.0 + exposureIso: Float32 = 0.0, + cinematicEnabled: Bool = false, + simulatedAperture: Float32 = 0.0 ) -> Offset { let __start = RemoteShutter_CommandParameters.startCommandParameters(&fbb) RemoteShutter_CommandParameters.add(sendToRemote: sendToRemote, &fbb) @@ -533,6 +543,8 @@ public struct RemoteShutter_CommandParameters: FlatBufferObject, Verifiable { RemoteShutter_CommandParameters.add(exposureMode: exposureMode, &fbb) RemoteShutter_CommandParameters.add(exposureDurationSeconds: exposureDurationSeconds, &fbb) RemoteShutter_CommandParameters.add(exposureIso: exposureIso, &fbb) + RemoteShutter_CommandParameters.add(cinematicEnabled: cinematicEnabled, &fbb) + RemoteShutter_CommandParameters.add(simulatedAperture: simulatedAperture, &fbb) return RemoteShutter_CommandParameters.endCommandParameters(&fbb, start: __start) } @@ -572,6 +584,8 @@ public struct RemoteShutter_CommandParameters: FlatBufferObject, Verifiable { try _v.visit(field: VTOFFSET.exposureMode.p, fieldName: "exposureMode", required: false, type: RemoteShutter_ExposureMode.self) try _v.visit(field: VTOFFSET.exposureDurationSeconds.p, fieldName: "exposureDurationSeconds", required: false, type: Double.self) try _v.visit(field: VTOFFSET.exposureIso.p, fieldName: "exposureIso", required: false, type: Float32.self) + try _v.visit(field: VTOFFSET.cinematicEnabled.p, fieldName: "cinematicEnabled", required: false, type: Bool.self) + try _v.visit(field: VTOFFSET.simulatedAperture.p, fieldName: "simulatedAperture", required: false, type: Float32.self) _v.finish() } } @@ -692,6 +706,82 @@ public struct RemoteShutter_ExposureState: FlatBufferObject, Verifiable { } } +public struct RemoteShutter_CinematicState: FlatBufferObject, Verifiable { + + static func validateVersion() { FlatBuffersVersion_25_2_10() } + public var __buffer: ByteBuffer! { return _accessor.bb } + private var _accessor: Table + + public static var id: String { "RCAM" } + public static func finish(_ fbb: inout FlatBufferBuilder, end: Offset, prefix: Bool = false) { fbb.finish(offset: end, fileId: RemoteShutter_CinematicState.id, addPrefix: prefix) } + private init(_ t: Table) { _accessor = t } + public init(_ bb: ByteBuffer, o: Int32) { _accessor = Table(bb: bb, position: o) } + + private enum VTOFFSET: VOffset { + case enabled = 4 + case simulatedAperture = 6 + case minSimulatedAperture = 8 + case maxSimulatedAperture = 10 + case defaultSimulatedAperture = 12 + case apertureLocked = 14 + case notEnoughLight = 16 + var v: Int32 { Int32(self.rawValue) } + var p: VOffset { self.rawValue } + } + + public var enabled: Bool { let o = _accessor.offset(VTOFFSET.enabled.v); return o == 0 ? false : _accessor.readBuffer(of: Bool.self, at: o) } + public var simulatedAperture: Float32 { let o = _accessor.offset(VTOFFSET.simulatedAperture.v); return o == 0 ? 0.0 : _accessor.readBuffer(of: Float32.self, at: o) } + public var minSimulatedAperture: Float32 { let o = _accessor.offset(VTOFFSET.minSimulatedAperture.v); return o == 0 ? 0.0 : _accessor.readBuffer(of: Float32.self, at: o) } + public var maxSimulatedAperture: Float32 { let o = _accessor.offset(VTOFFSET.maxSimulatedAperture.v); return o == 0 ? 0.0 : _accessor.readBuffer(of: Float32.self, at: o) } + public var defaultSimulatedAperture: Float32 { let o = _accessor.offset(VTOFFSET.defaultSimulatedAperture.v); return o == 0 ? 0.0 : _accessor.readBuffer(of: Float32.self, at: o) } + public var apertureLocked: Bool { let o = _accessor.offset(VTOFFSET.apertureLocked.v); return o == 0 ? false : _accessor.readBuffer(of: Bool.self, at: o) } + public var notEnoughLight: Bool { let o = _accessor.offset(VTOFFSET.notEnoughLight.v); return o == 0 ? false : _accessor.readBuffer(of: Bool.self, at: o) } + public static func startCinematicState(_ fbb: inout FlatBufferBuilder) -> UOffset { fbb.startTable(with: 7) } + public static func add(enabled: Bool, _ fbb: inout FlatBufferBuilder) { fbb.add(element: enabled, def: false, + at: VTOFFSET.enabled.p) } + public static func add(simulatedAperture: Float32, _ fbb: inout FlatBufferBuilder) { fbb.add(element: simulatedAperture, def: 0.0, at: VTOFFSET.simulatedAperture.p) } + public static func add(minSimulatedAperture: Float32, _ fbb: inout FlatBufferBuilder) { fbb.add(element: minSimulatedAperture, def: 0.0, at: VTOFFSET.minSimulatedAperture.p) } + public static func add(maxSimulatedAperture: Float32, _ fbb: inout FlatBufferBuilder) { fbb.add(element: maxSimulatedAperture, def: 0.0, at: VTOFFSET.maxSimulatedAperture.p) } + public static func add(defaultSimulatedAperture: Float32, _ fbb: inout FlatBufferBuilder) { fbb.add(element: defaultSimulatedAperture, def: 0.0, at: VTOFFSET.defaultSimulatedAperture.p) } + public static func add(apertureLocked: Bool, _ fbb: inout FlatBufferBuilder) { fbb.add(element: apertureLocked, def: false, + at: VTOFFSET.apertureLocked.p) } + public static func add(notEnoughLight: Bool, _ fbb: inout FlatBufferBuilder) { fbb.add(element: notEnoughLight, def: false, + at: VTOFFSET.notEnoughLight.p) } + public static func endCinematicState(_ fbb: inout FlatBufferBuilder, start: UOffset) -> Offset { let end = Offset(offset: fbb.endTable(at: start)); return end } + public static func createCinematicState( + _ fbb: inout FlatBufferBuilder, + enabled: Bool = false, + simulatedAperture: Float32 = 0.0, + minSimulatedAperture: Float32 = 0.0, + maxSimulatedAperture: Float32 = 0.0, + defaultSimulatedAperture: Float32 = 0.0, + apertureLocked: Bool = false, + notEnoughLight: Bool = false + ) -> Offset { + let __start = RemoteShutter_CinematicState.startCinematicState(&fbb) + RemoteShutter_CinematicState.add(enabled: enabled, &fbb) + RemoteShutter_CinematicState.add(simulatedAperture: simulatedAperture, &fbb) + RemoteShutter_CinematicState.add(minSimulatedAperture: minSimulatedAperture, &fbb) + RemoteShutter_CinematicState.add(maxSimulatedAperture: maxSimulatedAperture, &fbb) + RemoteShutter_CinematicState.add(defaultSimulatedAperture: defaultSimulatedAperture, &fbb) + RemoteShutter_CinematicState.add(apertureLocked: apertureLocked, &fbb) + RemoteShutter_CinematicState.add(notEnoughLight: notEnoughLight, &fbb) + return RemoteShutter_CinematicState.endCinematicState(&fbb, start: __start) + } + + public static func verify(_ verifier: inout Verifier, at position: Int, of type: T.Type) throws where T: Verifiable { + var _v = try verifier.visitTable(at: position) + try _v.visit(field: VTOFFSET.enabled.p, fieldName: "enabled", required: false, type: Bool.self) + try _v.visit(field: VTOFFSET.simulatedAperture.p, fieldName: "simulatedAperture", required: false, type: Float32.self) + try _v.visit(field: VTOFFSET.minSimulatedAperture.p, fieldName: "minSimulatedAperture", required: false, type: Float32.self) + try _v.visit(field: VTOFFSET.maxSimulatedAperture.p, fieldName: "maxSimulatedAperture", required: false, type: Float32.self) + try _v.visit(field: VTOFFSET.defaultSimulatedAperture.p, fieldName: "defaultSimulatedAperture", required: false, type: Float32.self) + try _v.visit(field: VTOFFSET.apertureLocked.p, fieldName: "apertureLocked", required: false, type: Bool.self) + try _v.visit(field: VTOFFSET.notEnoughLight.p, fieldName: "notEnoughLight", required: false, type: Bool.self) + _v.finish() + } +} + public struct RemoteShutter_ZoomRange: FlatBufferObject, Verifiable { static func validateVersion() { FlatBuffersVersion_25_2_10() } @@ -1214,6 +1304,8 @@ public struct RemoteShutter_CameraCapabilities: FlatBufferObject, Verifiable { case supportsMulticam = 16 case supportsManualExposure = 18 case exposure = 20 + case supportsCinematicVideo = 22 + case cinematic = 24 var v: Int32 { Int32(self.rawValue) } var p: VOffset { self.rawValue } } @@ -1230,7 +1322,9 @@ public struct RemoteShutter_CameraCapabilities: FlatBufferObject, Verifiable { public var supportsMulticam: Bool { let o = _accessor.offset(VTOFFSET.supportsMulticam.v); return o == 0 ? false : _accessor.readBuffer(of: Bool.self, at: o) } public var supportsManualExposure: Bool { let o = _accessor.offset(VTOFFSET.supportsManualExposure.v); return o == 0 ? false : _accessor.readBuffer(of: Bool.self, at: o) } public var exposure: RemoteShutter_ExposureState? { let o = _accessor.offset(VTOFFSET.exposure.v); return o == 0 ? nil : RemoteShutter_ExposureState(_accessor.bb, o: _accessor.indirect(o + _accessor.position)) } - public static func startCameraCapabilities(_ fbb: inout FlatBufferBuilder) -> UOffset { fbb.startTable(with: 9) } + public var supportsCinematicVideo: Bool { let o = _accessor.offset(VTOFFSET.supportsCinematicVideo.v); return o == 0 ? false : _accessor.readBuffer(of: Bool.self, at: o) } + public var cinematic: RemoteShutter_CinematicState? { let o = _accessor.offset(VTOFFSET.cinematic.v); return o == 0 ? nil : RemoteShutter_CinematicState(_accessor.bb, o: _accessor.indirect(o + _accessor.position)) } + public static func startCameraCapabilities(_ fbb: inout FlatBufferBuilder) -> UOffset { fbb.startTable(with: 11) } public static func add(frontCamera: Offset, _ fbb: inout FlatBufferBuilder) { fbb.add(offset: frontCamera, at: VTOFFSET.frontCamera.p) } public static func add(backCamera: Offset, _ fbb: inout FlatBufferBuilder) { fbb.add(offset: backCamera, at: VTOFFSET.backCamera.p) } public static func addVectorOf(cameraDevices: Offset, _ fbb: inout FlatBufferBuilder) { fbb.add(offset: cameraDevices, at: VTOFFSET.cameraDevices.p) } @@ -1244,6 +1338,9 @@ public struct RemoteShutter_CameraCapabilities: FlatBufferObject, Verifiable { public static func add(supportsManualExposure: Bool, _ fbb: inout FlatBufferBuilder) { fbb.add(element: supportsManualExposure, def: false, at: VTOFFSET.supportsManualExposure.p) } public static func add(exposure: Offset, _ fbb: inout FlatBufferBuilder) { fbb.add(offset: exposure, at: VTOFFSET.exposure.p) } + public static func add(supportsCinematicVideo: Bool, _ fbb: inout FlatBufferBuilder) { fbb.add(element: supportsCinematicVideo, def: false, + at: VTOFFSET.supportsCinematicVideo.p) } + public static func add(cinematic: Offset, _ fbb: inout FlatBufferBuilder) { fbb.add(offset: cinematic, at: VTOFFSET.cinematic.p) } public static func endCameraCapabilities(_ fbb: inout FlatBufferBuilder, start: UOffset) -> Offset { let end = Offset(offset: fbb.endTable(at: start)); return end } public static func createCameraCapabilities( _ fbb: inout FlatBufferBuilder, @@ -1255,7 +1352,9 @@ public struct RemoteShutter_CameraCapabilities: FlatBufferObject, Verifiable { supportsPreviewMode: Bool = false, supportsMulticam: Bool = false, supportsManualExposure: Bool = false, - exposureOffset exposure: Offset = Offset() + exposureOffset exposure: Offset = Offset(), + supportsCinematicVideo: Bool = false, + cinematicOffset cinematic: Offset = Offset() ) -> Offset { let __start = RemoteShutter_CameraCapabilities.startCameraCapabilities(&fbb) RemoteShutter_CameraCapabilities.add(frontCamera: frontCamera, &fbb) @@ -1267,6 +1366,8 @@ public struct RemoteShutter_CameraCapabilities: FlatBufferObject, Verifiable { RemoteShutter_CameraCapabilities.add(supportsMulticam: supportsMulticam, &fbb) RemoteShutter_CameraCapabilities.add(supportsManualExposure: supportsManualExposure, &fbb) RemoteShutter_CameraCapabilities.add(exposure: exposure, &fbb) + RemoteShutter_CameraCapabilities.add(supportsCinematicVideo: supportsCinematicVideo, &fbb) + RemoteShutter_CameraCapabilities.add(cinematic: cinematic, &fbb) return RemoteShutter_CameraCapabilities.endCameraCapabilities(&fbb, start: __start) } @@ -1281,6 +1382,8 @@ public struct RemoteShutter_CameraCapabilities: FlatBufferObject, Verifiable { try _v.visit(field: VTOFFSET.supportsMulticam.p, fieldName: "supportsMulticam", required: false, type: Bool.self) try _v.visit(field: VTOFFSET.supportsManualExposure.p, fieldName: "supportsManualExposure", required: false, type: Bool.self) try _v.visit(field: VTOFFSET.exposure.p, fieldName: "exposure", required: false, type: ForwardOffset.self) + try _v.visit(field: VTOFFSET.supportsCinematicVideo.p, fieldName: "supportsCinematicVideo", required: false, type: Bool.self) + try _v.visit(field: VTOFFSET.cinematic.p, fieldName: "cinematic", required: false, type: ForwardOffset.self) _v.finish() } } @@ -1311,6 +1414,7 @@ public struct RemoteShutter_CameraStateResponse: FlatBufferObject, Verifiable { case clockSyncCameraClockMs = 26 case captureIdEcho = 28 case exposure = 30 + case cinematic = 32 var v: Int32 { Int32(self.rawValue) } var p: VOffset { self.rawValue } } @@ -1336,7 +1440,8 @@ public struct RemoteShutter_CameraStateResponse: FlatBufferObject, Verifiable { public var captureIdEcho: String? { let o = _accessor.offset(VTOFFSET.captureIdEcho.v); return o == 0 ? nil : _accessor.string(at: o) } public var captureIdEchoSegmentArray: [UInt8]? { return _accessor.getVector(at: VTOFFSET.captureIdEcho.v) } public var exposure: RemoteShutter_ExposureState? { let o = _accessor.offset(VTOFFSET.exposure.v); return o == 0 ? nil : RemoteShutter_ExposureState(_accessor.bb, o: _accessor.indirect(o + _accessor.position)) } - public static func startCameraStateResponse(_ fbb: inout FlatBufferBuilder) -> UOffset { fbb.startTable(with: 14) } + public var cinematic: RemoteShutter_CinematicState? { let o = _accessor.offset(VTOFFSET.cinematic.v); return o == 0 ? nil : RemoteShutter_CinematicState(_accessor.bb, o: _accessor.indirect(o + _accessor.position)) } + public static func startCameraStateResponse(_ fbb: inout FlatBufferBuilder) -> UOffset { fbb.startTable(with: 15) } public static func add(action: RemoteShutter_CommandAction, _ fbb: inout FlatBufferBuilder) { fbb.add(element: action.rawValue, def: 0, at: VTOFFSET.action.p) } public static func add(success: Bool, _ fbb: inout FlatBufferBuilder) { fbb.add(element: success, def: false, at: VTOFFSET.success.p) } @@ -1352,6 +1457,7 @@ public struct RemoteShutter_CameraStateResponse: FlatBufferObject, Verifiable { public static func add(clockSyncCameraClockMs: UInt64, _ fbb: inout FlatBufferBuilder) { fbb.add(element: clockSyncCameraClockMs, def: 0, at: VTOFFSET.clockSyncCameraClockMs.p) } public static func add(captureIdEcho: Offset, _ fbb: inout FlatBufferBuilder) { fbb.add(offset: captureIdEcho, at: VTOFFSET.captureIdEcho.p) } public static func add(exposure: Offset, _ fbb: inout FlatBufferBuilder) { fbb.add(offset: exposure, at: VTOFFSET.exposure.p) } + public static func add(cinematic: Offset, _ fbb: inout FlatBufferBuilder) { fbb.add(offset: cinematic, at: VTOFFSET.cinematic.p) } public static func endCameraStateResponse(_ fbb: inout FlatBufferBuilder, start: UOffset) -> Offset { let end = Offset(offset: fbb.endTable(at: start)); return end } public static func createCameraStateResponse( _ fbb: inout FlatBufferBuilder, @@ -1368,7 +1474,8 @@ public struct RemoteShutter_CameraStateResponse: FlatBufferObject, Verifiable { clockSyncEchoT0Ms: UInt64 = 0, clockSyncCameraClockMs: UInt64 = 0, captureIdEchoOffset captureIdEcho: Offset = Offset(), - exposureOffset exposure: Offset = Offset() + exposureOffset exposure: Offset = Offset(), + cinematicOffset cinematic: Offset = Offset() ) -> Offset { let __start = RemoteShutter_CameraStateResponse.startCameraStateResponse(&fbb) RemoteShutter_CameraStateResponse.add(action: action, &fbb) @@ -1385,6 +1492,7 @@ public struct RemoteShutter_CameraStateResponse: FlatBufferObject, Verifiable { RemoteShutter_CameraStateResponse.add(clockSyncCameraClockMs: clockSyncCameraClockMs, &fbb) RemoteShutter_CameraStateResponse.add(captureIdEcho: captureIdEcho, &fbb) RemoteShutter_CameraStateResponse.add(exposure: exposure, &fbb) + RemoteShutter_CameraStateResponse.add(cinematic: cinematic, &fbb) return RemoteShutter_CameraStateResponse.endCameraStateResponse(&fbb, start: __start) } @@ -1404,6 +1512,7 @@ public struct RemoteShutter_CameraStateResponse: FlatBufferObject, Verifiable { try _v.visit(field: VTOFFSET.clockSyncCameraClockMs.p, fieldName: "clockSyncCameraClockMs", required: false, type: UInt64.self) try _v.visit(field: VTOFFSET.captureIdEcho.p, fieldName: "captureIdEcho", required: false, type: ForwardOffset.self) try _v.visit(field: VTOFFSET.exposure.p, fieldName: "exposure", required: false, type: ForwardOffset.self) + try _v.visit(field: VTOFFSET.cinematic.p, fieldName: "cinematic", required: false, type: ForwardOffset.self) _v.finish() } } diff --git a/RemoteCam/MonitorChrome.swift b/RemoteCam/MonitorChrome.swift index 2082702e..aa35dff3 100644 --- a/RemoteCam/MonitorChrome.swift +++ b/RemoteCam/MonitorChrome.swift @@ -73,6 +73,8 @@ enum MonitorTrayItem: Equatable { /// Puts the peer camera's *local* preview to sleep. It keeps capturing and /// keeps streaming here. case cameraStandby + /// Manual exposure + Cinematic video (opens the pro panel). + case proControls case settings case help } @@ -87,7 +89,8 @@ enum MonitorTray { supportsHDR: Bool, supportsCameraStandby: Bool, resolutionCount: Int, - frameRateCount: Int) -> [MonitorTrayItem] { + frameRateCount: Int, + showsProControls: Bool = false) -> [MonitorTrayItem] { var items: [MonitorTrayItem] = [] // Shorts runs to a fixed duration, so a self-timer has nothing to delay. @@ -108,6 +111,7 @@ enum MonitorTray { } if supportsCameraStandby { items.append(.cameraStandby) } + if showsProControls { items.append(.proControls) } items.append(.settings) items.append(.help) diff --git a/RemoteCam/MonitorPresenter.swift b/RemoteCam/MonitorPresenter.swift index c4cb0030..249fed97 100644 --- a/RemoteCam/MonitorPresenter.swift +++ b/RemoteCam/MonitorPresenter.swift @@ -108,6 +108,11 @@ public final class MonitorPresenter { onMain { $0.viewModel.exposure = state } } + func updateCinematic(_ state: CinematicState?) { + guard let state else { return } + onMain { $0.viewModel.cinematic = state } + } + func updateLens(_ lensType: CameraLensType?, availableLenses: [CameraLensType]?, currentZoom: CGFloat?, @@ -135,6 +140,8 @@ public final class MonitorPresenter { display.viewModel.supportsCameraStandby = capabilities.supportsPreviewMode display.viewModel.supportsManualExposure = capabilities.supportsManualExposure display.viewModel.exposure = capabilities.exposure + display.viewModel.supportsCinematicVideo = capabilities.supportsCinematicVideo + display.viewModel.cinematic = capabilities.cinematic guard let cameraInfo = capabilities.getCurrentCameraInfo() else { return } // Update lens controls in view model diff --git a/RemoteCam/MonitorView.swift b/RemoteCam/MonitorView.swift index 3e1e8517..6151b825 100644 --- a/RemoteCam/MonitorView.swift +++ b/RemoteCam/MonitorView.swift @@ -36,8 +36,15 @@ struct MonitorView: View { let onFocusTap: (CGPoint) -> Void /// Toggles the connected camera's local-preview mode (on ⇄ standby). let onToggleCameraStandby: () -> Void + /// Pro controls (defaulted so previews/snapshots need not wire them). + var onExposureChange: (ExposureIntent) -> Void = { _ in } + var onCinematicChange: (CinematicIntent) -> Void = { _ in } + /// The purchase gate, checked when opening the pro panel; a locked user is + /// routed to settings/paywall by `onSettingsTapped` (mirrors tap-to-focus). + var isProControlsUnlocked: () -> Bool = { true } @State private var isTrayOpen = false + @State private var isProPanelOpen = false var body: some View { GeometryReader { geometry in @@ -53,6 +60,10 @@ struct MonitorView: View { trayLayer } + if isProPanelOpen { + proPanelLayer + } + if viewModel.isVideoTransferring { VideoTransferProgressView( progress: viewModel.videoTransferProgress, @@ -363,7 +374,8 @@ struct MonitorView: View { supportsHDR: viewModel.supportsHDR, supportsCameraStandby: viewModel.supportsCameraStandby, resolutionCount: viewModel.supportedResolutions.count, - frameRateCount: availableFrameRates.count), + frameRateCount: availableFrameRates.count, + showsProControls: showsProControls), timerValue: Int(viewModel.timerSliderValue), aspectRatio: viewModel.currentAspectRatio, resolution: viewModel.currentVideoResolution, @@ -379,6 +391,32 @@ struct MonitorView: View { } } + /// The PRO tile exists only when the connected camera offers something for + /// it to control (manual exposure anywhere; Cinematic in video mode). + private var showsProControls: Bool { + guard FeatureFlags.ENABLE_PRO_CONTROLS else { return false } + let videoish = viewModel.uiState == .videoMode || viewModel.uiState == .videoRecording + return viewModel.supportsManualExposure + || (videoish && viewModel.supportsCinematicVideo) + } + + private var proPanelLayer: some View { + ZStack(alignment: .bottom) { + Color.black.opacity(0.02) + .ignoresSafeArea() + .onTapGesture { + withAnimation(.spring(response: 0.32, dampingFraction: 0.85)) { + isProPanelOpen = false + } + } + + ProControlsPanel(viewModel: viewModel, + onExposureChange: onExposureChange, + onCinematicChange: onCinematicChange) + .transition(.move(edge: .bottom)) + } + } + private var availableFrameRates: [VideoFrameRate] { let rates = viewModel.resolutionFrameRates[viewModel.currentVideoResolution] return (rates?.isEmpty == false) ? rates! : viewModel.supportedFrameRates @@ -417,6 +455,16 @@ struct MonitorView: View { // it is worth watching settle. onToggleCameraStandby() + case .proControls: + toggleTray() + if isProControlsUnlocked() { + withAnimation(.spring(response: 0.32, dampingFraction: 0.85)) { + isProPanelOpen = true + } + } else { + onSettingsTapped() // paywall, mirroring tap-to-focus + } + case .settings: toggleTray() onSettingsTapped() @@ -703,7 +751,7 @@ struct MonitorTrayPanel: View { case .frameRate: return frameRate.displayName case .format: return photoFormat.displayName // Glyph-only: state is carried by the symbol. - case .hdr, .cameraStandby, .settings, .help: return nil + case .hdr, .cameraStandby, .proControls, .settings, .help: return nil } } @@ -722,6 +770,8 @@ struct MonitorTrayPanel: View { case .aspect, .resolution, .frameRate, .format, .hdr: return isQualityEnabled // Not a capture setting: usable mid-recording. case .cameraStandby: return true + // The panel itself explains what recording locks (aperture). + case .proControls: return true case .settings: return isSettingsEnabled case .help: return true } @@ -781,6 +831,7 @@ struct MonitorTrayTile: View { case .format: return "doc" case .hdr: return "camera.filters" case .cameraStandby: return isActive ? "moon.zzz.fill" : "moon.zzz" + case .proControls: return "camera.aperture" case .settings: return "gearshape.fill" case .help: return "questionmark" } @@ -795,6 +846,7 @@ struct MonitorTrayTile: View { case .format: return NSLocalizedString("FORMAT", comment: "tray tile") case .hdr: return NSLocalizedString("HDR", comment: "tray tile") case .cameraStandby: return NSLocalizedString("STANDBY", comment: "tray tile") + case .proControls: return NSLocalizedString("PRO", comment: "tray tile") case .settings: return NSLocalizedString("SETTINGS", comment: "tray tile") case .help: return NSLocalizedString("HELP", comment: "tray tile") } diff --git a/RemoteCam/MonitorViewController+SwiftUI.swift b/RemoteCam/MonitorViewController+SwiftUI.swift index e01b6be0..5eb44f81 100644 --- a/RemoteCam/MonitorViewController+SwiftUI.swift +++ b/RemoteCam/MonitorViewController+SwiftUI.swift @@ -61,6 +61,15 @@ extension MonitorViewController { }, onToggleCameraStandby: { [weak self] in self?.handleToggleCameraStandby() + }, + onExposureChange: { [weak self] intent in + self?.session ! UICmd.SetExposure(intent: intent) + }, + onCinematicChange: { [weak self] intent in + self?.session ! UICmd.SetCinematic(intent: intent) + }, + isProControlsUnlocked: { + StoreManager.shared.hasProControlsFeature() } ) diff --git a/RemoteCam/MonitorViewModel.swift b/RemoteCam/MonitorViewModel.swift index 4f76a615..4b1455ed 100644 --- a/RemoteCam/MonitorViewModel.swift +++ b/RemoteCam/MonitorViewModel.swift @@ -266,6 +266,10 @@ class MonitorViewModel: ObservableObject { /// The camera's echoed exposure truth (mode, shutter, ISO, ranges). The /// monitor renders only this, never the value it last dragged to. @Published var exposure: ExposureState? + /// Whether the peer can record Cinematic video (iOS 26+ camera). + @Published var supportsCinematicVideo: Bool = false + /// The camera's echoed Cinematic truth. + @Published var cinematic: CinematicState? // MARK: - Video Quality Update Methods func updateVideoQuality(resolution: VideoResolution, frameRate: VideoFrameRate) { diff --git a/RemoteCam/MulticamView.swift b/RemoteCam/MulticamView.swift index 78e8620f..2d74c9c2 100644 --- a/RemoteCam/MulticamView.swift +++ b/RemoteCam/MulticamView.swift @@ -872,9 +872,10 @@ struct RigTrayPanel: View { MonitorTrayTile(item: .help, value: nil, isActive: false, isEnabled: true, action: onOpenHelp) - case .frameRate: + case .frameRate, .proControls: // Not offered by `RigTray.items` — frame rate rides the single - // quality tile's intersection cycle. + // quality tile's intersection cycle, and pro controls are a 1:1 + // monitor feature (multicam broadcast is a tracked follow-up). EmptyView() } } diff --git a/RemoteCam/ProControlsPanel.swift b/RemoteCam/ProControlsPanel.swift new file mode 100644 index 00000000..ff85062e --- /dev/null +++ b/RemoteCam/ProControlsPanel.swift @@ -0,0 +1,256 @@ +// +// ProControlsPanel.swift +// RemoteShutter +// +// The pro-controls bottom panel on the monitor: manual exposure (shutter + +// ISO) and Cinematic video (iOS 26 simulated aperture). Every value shown is +// the CAMERA's echoed truth (`MonitorViewModel.exposure` / `.cinematic`) — +// the panel never displays a value the camera did not confirm. Controls are +// rendered only when the connected camera advertised the capability. +// See Docs/pro-controls.md. +// + +import SwiftUI + +struct ProControlsPanel: View { + @ObservedObject var viewModel: MonitorViewModel + let onExposureChange: (ExposureIntent) -> Void + let onCinematicChange: (CinematicIntent) -> Void + + private var isVideoMode: Bool { + viewModel.uiState == .videoMode || viewModel.uiState == .videoRecording + } + + var body: some View { + VStack(spacing: 16) { + Capsule() + .fill(Color.white.opacity(0.3)) + .frame(width: 36, height: 5) + + if viewModel.supportsManualExposure, let exposure = viewModel.exposure { + exposureSection(exposure) + } + + if isVideoMode, viewModel.supportsCinematicVideo, let cinematic = viewModel.cinematic { + if viewModel.supportsManualExposure { + Divider().overlay(Color.white.opacity(0.15)) + } + cinematicSection(cinematic) + } + } + .padding(.top, 10) + .padding(.horizontal, 20) + .padding(.bottom, 28) + .frame(maxWidth: .infinity) + .background( + RoundedRectangle(cornerRadius: 24, style: .continuous) + .fill(.ultraThinMaterial) + .ignoresSafeArea(edges: .bottom) + ) + } + + // MARK: - Exposure + + @ViewBuilder + private func exposureSection(_ exposure: ExposureState) -> some View { + HStack { + sectionTitle(NSLocalizedString("EXPOSURE", comment: "pro panel section")) + Spacer() + Picker("", selection: Binding( + get: { exposure.mode == .manual }, + set: { manual in + // Manual with zeros = "lock what auto is doing right now", + // so the dials pick up from a correctly exposed frame. + onExposureChange(manual ? .manual(durationSeconds: 0, iso: 0) : .auto) + })) { + Text(NSLocalizedString("Auto", comment: "exposure mode")).tag(false) + Text(NSLocalizedString("Manual", comment: "exposure mode")).tag(true) + } + .pickerStyle(.segmented) + .frame(width: 170) + } + + if exposure.mode == .manual { + ProDial( + caption: NSLocalizedString("SHUTTER", comment: "pro dial"), + stops: ProDialStops.shutterStops(min: exposure.minDurationSeconds, + max: exposure.maxDurationSeconds), + value: exposure.durationSeconds, + label: { ProDialStops.shutterLabel($0) }, + accessibilityValue: { seconds in + String(format: NSLocalizedString("%@ second", comment: "shutter a11y"), + ProDialStops.shutterLabel(seconds)) + }, + onSelect: { onExposureChange(.manual(durationSeconds: $0, iso: 0)) }) + + ProDial( + caption: "ISO", + stops: ProDialStops.isoStops(min: exposure.minISO, max: exposure.maxISO) + .map { Double($0) }, + value: Double(exposure.iso), + label: { String(Int($0.rounded())) }, + accessibilityValue: { "ISO \(Int($0.rounded()))" }, + onSelect: { onExposureChange(.manual(durationSeconds: 0, iso: Float($0))) }) + } else { + // What auto is choosing right now — what Manual would take over. + Text("\(ProDialStops.shutterLabel(exposure.durationSeconds)) · \(ProDialStops.isoLabel(exposure.iso))") + .font(.system(size: 14, weight: .semibold, design: .monospaced)) + .foregroundColor(.white.opacity(0.7)) + } + } + + // MARK: - Cinematic + + @ViewBuilder + private func cinematicSection(_ cinematic: CinematicState) -> some View { + HStack { + sectionTitle(NSLocalizedString("CINEMATIC", comment: "pro panel section")) + Spacer() + Toggle("", isOn: Binding( + get: { cinematic.enabled }, + set: { isOn in onCinematicChange(isOn ? .on(aperture: nil) : .off) })) + .labelsHidden() + .tint(AppTheme.accent) + // Apple rejects enabling/disabling mid-take. + .disabled(cinematic.apertureLocked) + .accessibilityLabel(NSLocalizedString("Cinematic video", comment: "a11y")) + } + + if cinematic.enabled, cinematic.minSimulatedAperture > 0 { + ProDial( + caption: NSLocalizedString("APERTURE", comment: "pro dial"), + stops: ProDialStops.apertureStops(min: cinematic.minSimulatedAperture, + max: cinematic.maxSimulatedAperture) + .map { Double($0) }, + value: Double(cinematic.simulatedAperture), + label: { ProDialStops.apertureLabel(Float($0)) }, + accessibilityValue: { ProDialStops.apertureLabel(Float($0)) }, + onSelect: { onCinematicChange(.on(aperture: Float($0))) }) + .disabled(cinematic.apertureLocked) + .opacity(cinematic.apertureLocked ? 0.4 : 1) + + if cinematic.apertureLocked { + footnote(NSLocalizedString("Aperture is set before recording", + comment: "cinematic hint")) + } + } + + if cinematic.enabled && cinematic.notEnoughLight { + footnote(NSLocalizedString("Scene too dark for Cinematic", + comment: "cinematic hint")) + } + } + + // MARK: - Bits + + private func sectionTitle(_ text: String) -> some View { + Text(text) + .font(.system(size: 12, weight: .semibold)) + .tracking(1) + .foregroundColor(.white.opacity(0.6)) + } + + private func footnote(_ text: String) -> some View { + Text(text) + .font(.caption) + .foregroundColor(.white.opacity(0.7)) + } +} + +// MARK: - Dial + +/// A detented value dial: chevrons step one stop, dragging scrubs stops with a +/// haptic tick per detent. Shows the camera's echoed value; a step calls +/// `onSelect` with the neighboring stop and waits for the echo to move the +/// label (the remote never claims a state the camera did not confirm). +struct ProDial: View { + let caption: String + let stops: [Double] + let value: Double + let label: (Double) -> String + let accessibilityValue: (Double) -> String + let onSelect: (Double) -> Void + + /// Points of horizontal drag per detent. + private static let dragStride: CGFloat = 24 + + @State private var dragBaseIndex: Int? + @State private var lastDraggedIndex: Int? + + private var currentIndex: Int { ProDialStops.nearestIndex(of: value, in: stops) ?? 0 } + + var body: some View { + HStack(spacing: 14) { + Text(caption) + .font(.system(size: 11, weight: .semibold)) + .tracking(0.5) + .foregroundColor(.white.opacity(0.6)) + .frame(width: 64, alignment: .leading) + + stepButton(systemName: "chevron.left", step: -1) + + Text(stops.isEmpty ? "—" : label(stops[currentIndex])) + .font(.system(size: 17, weight: .bold, design: .monospaced)) + .foregroundColor(.white) + .frame(maxWidth: .infinity) + .contentShape(Rectangle()) + .gesture(dragGesture) + + stepButton(systemName: "chevron.right", step: +1) + } + .frame(minHeight: 44) + .accessibilityElement(children: .ignore) + .accessibilityLabel(caption) + .accessibilityValue(stops.isEmpty ? "" : accessibilityValue(stops[currentIndex])) + .accessibilityAdjustableAction { direction in + switch direction { + case .increment: select(currentIndex + 1) + case .decrement: select(currentIndex - 1) + @unknown default: break + } + } + } + + private func stepButton(systemName: String, step: Int) -> some View { + Button { select(currentIndex + step) } label: { + Image(systemName: systemName) + .font(.system(size: 15, weight: .semibold)) + .foregroundColor(.white.opacity(0.8)) + .frame(width: 44, height: 44) + .contentShape(Rectangle()) + } + } + + private var dragGesture: some Gesture { + DragGesture(minimumDistance: 4) + .onChanged { gesture in + let base = dragBaseIndex ?? currentIndex + dragBaseIndex = base + let offset = Int((gesture.translation.width / Self.dragStride).rounded()) + let target = max(0, min(stops.count - 1, base + offset)) + if target != (lastDraggedIndex ?? base) { + lastDraggedIndex = target + tick() + onSelect(stops[target]) + } + } + .onEnded { _ in + dragBaseIndex = nil + lastDraggedIndex = nil + } + } + + private func select(_ index: Int) { + guard !stops.isEmpty else { return } + let clamped = max(0, min(stops.count - 1, index)) + guard clamped != currentIndex else { return } + tick() + onSelect(stops[clamped]) + } + + private func tick() { + #if !targetEnvironment(macCatalyst) + UISelectionFeedbackGenerator().selectionChanged() + #endif + } +} diff --git a/RemoteCam/PurchaseManaging.swift b/RemoteCam/PurchaseManaging.swift index 43feeb05..a91f1813 100644 --- a/RemoteCam/PurchaseManaging.swift +++ b/RemoteCam/PurchaseManaging.swift @@ -12,7 +12,7 @@ extension PurchaseManaging { func observePurchaseNotifications() { let names: [Notification.Name] = [ .removeAds, .proModeAcquired, .enableTorch, .enableVideoOnly, - .tapToFocusAcquired, .proSubscriptionAcquired + .tapToFocusAcquired, .proControlsAcquired, .proSubscriptionAcquired ] for name in names { let observer = NotificationCenter.default.addObserver( diff --git a/RemoteCam/RemoteCmdFlatBuffers.swift b/RemoteCam/RemoteCmdFlatBuffers.swift index 04453566..43fac5d7 100644 --- a/RemoteCam/RemoteCmdFlatBuffers.swift +++ b/RemoteCam/RemoteCmdFlatBuffers.swift @@ -43,6 +43,8 @@ func serializeToFlatBuffer(_ msg: Message) -> Data? { case let m as RemoteCmd.FocusAtPoint: return m.toFlatBuffer() case let m as RemoteCmd.SetExposure: return m.toFlatBuffer() case let m as RemoteCmd.SetExposureResp: return m.toFlatBuffer() + case let m as RemoteCmd.SetCinematic: return m.toFlatBuffer() + case let m as RemoteCmd.SetCinematicResp: return m.toFlatBuffer() case let m as RemoteCmd.SetCameraPreviewMode: return m.toFlatBuffer() case let m as RemoteCmd.CameraPreviewModeResp: return m.toFlatBuffer() case let m as RemoteCmd.EndSession: return m.toFlatBuffer() @@ -424,6 +426,7 @@ private func encodeCapabilitiesEnvelope( } let activeIDOffset = c.activeDeviceID.map { fbb.create(string: $0) } ?? Offset() let exposureOffset = encodeExposureState(c.exposure, &fbb) + let cinematicOffset = encodeCinematicState(c.cinematic, &fbb) let capsOffset = RemoteShutter_CameraCapabilities.createCameraCapabilities( &fbb, @@ -435,7 +438,9 @@ private func encodeCapabilitiesEnvelope( supportsPreviewMode: c.supportsPreviewMode, supportsMulticam: c.supportsMulticam, supportsManualExposure: c.supportsManualExposure, - exposureOffset: exposureOffset) + exposureOffset: exposureOffset, + supportsCinematicVideo: c.supportsCinematicVideo, + cinematicOffset: cinematicOffset) let stateOffset = RemoteShutter_CameraState.createCameraState( &fbb, @@ -637,6 +642,36 @@ extension RemoteCmd.SetExposureResp { } } +extension RemoteCmd.SetCinematic { + func toFlatBuffer() -> Data { + var fbb = FlatBufferBuilder() + let params: Offset + switch intent { + case .off: + params = RemoteShutter_CommandParameters.createCommandParameters(&fbb, cinematicEnabled: false) + case let .on(aperture): + params = RemoteShutter_CommandParameters.createCommandParameters( + &fbb, cinematicEnabled: true, simulatedAperture: aperture ?? 0) + } + return buildCommand(&fbb, action: .setcinematic, parameters: params) + } +} + +extension RemoteCmd.SetCinematicResp { + func toFlatBuffer() -> Data { + var fbb = FlatBufferBuilder() + let errorOffset = (error as NSError?).map { fbb.create(string: RemoteCmd.wireErrorMessage($0)) } ?? Offset() + let cinematicOffset = encodeCinematicState(state, &fbb) + let resp = RemoteShutter_CameraStateResponse.createCameraStateResponse( + &fbb, + action: .setcinematic, + success: error == nil, + errorOffset: errorOffset, + cinematicOffset: cinematicOffset) + return buildResponse(&fbb, action: .setcinematic, response: resp) + } +} + extension RemoteCmd.EndSession { func toFlatBuffer() -> Data { var fbb = FlatBufferBuilder() @@ -1219,6 +1254,31 @@ func decodeExposureState(_ fb: RemoteShutter_ExposureState?) -> ExposureState? { maxISO: fb.maxIso) } +func encodeCinematicState(_ state: CinematicState?, _ fbb: inout FlatBufferBuilder) -> Offset { + guard let state else { return Offset() } + return RemoteShutter_CinematicState.createCinematicState( + &fbb, + enabled: state.enabled, + simulatedAperture: state.simulatedAperture, + minSimulatedAperture: state.minSimulatedAperture, + maxSimulatedAperture: state.maxSimulatedAperture, + defaultSimulatedAperture: state.defaultSimulatedAperture, + apertureLocked: state.apertureLocked, + notEnoughLight: state.notEnoughLight) +} + +func decodeCinematicState(_ fb: RemoteShutter_CinematicState?) -> CinematicState? { + guard let fb else { return nil } + return CinematicState( + enabled: fb.enabled, + simulatedAperture: fb.simulatedAperture, + minSimulatedAperture: fb.minSimulatedAperture, + maxSimulatedAperture: fb.maxSimulatedAperture, + defaultSimulatedAperture: fb.defaultSimulatedAperture, + apertureLocked: fb.apertureLocked, + notEnoughLight: fb.notEnoughLight) +} + // MARK: - SetAspectRatio toFlatBuffer() extension RemoteCmd.SetAspectRatio { @@ -1450,6 +1510,13 @@ extension RemoteCmd { return SetExposure(intent: .auto) } + case .setcinematic: + if params?.cinematicEnabled == true { + let aperture = params?.simulatedAperture ?? 0 + return SetCinematic(intent: .on(aperture: aperture > 0 ? aperture : nil)) + } + return SetCinematic(intent: .off) + case .setcamerapreviewmode: return SetCameraPreviewMode(mode: fromFBPreviewMode(params?.cameraPreviewMode ?? .unknown)) @@ -1526,6 +1593,9 @@ extension RemoteCmd { case .setexposure: return SetExposureResp(state: decodeExposureState(resp.exposure), error: nsError) + case .setcinematic: + return SetCinematicResp(state: decodeCinematicState(resp.cinematic), error: nsError) + case .switchlens: let state = resp.currentState let lensType: CameraLensType? = state != nil ? fromFBLens(state!.currentLens) : nil @@ -1641,6 +1711,8 @@ extension RemoteCmd { previewMode: state.map { fromFBPreviewMode($0.previewMode) } ?? .on, supportsManualExposure: caps?.supportsManualExposure ?? false, exposure: decodeExposureState(caps?.exposure), + supportsCinematicVideo: caps?.supportsCinematicVideo ?? false, + cinematic: decodeCinematicState(caps?.cinematic), error: error ) } diff --git a/RemoteCam/RemoteCmds.swift b/RemoteCam/RemoteCmds.swift index df21c573..9d3cf012 100644 --- a/RemoteCam/RemoteCmds.swift +++ b/RemoteCam/RemoteCmds.swift @@ -437,6 +437,30 @@ public class RemoteCmd: Message, @unchecked Sendable { } } + /// Monitor -> camera: Cinematic video on/off + simulated aperture (iOS + /// 26+). Answered with `SetCinematicResp`. Only sent to peers that + /// advertised `CameraCapabilitiesResp.supportsCinematicVideo`. + public class SetCinematic: Message, @unchecked Sendable { + public let intent: CinematicIntent + + public init(intent: CinematicIntent) { + self.intent = intent + super.init(sender: nil) + } + } + + /// Camera -> monitor: the Cinematic truth after a `SetCinematic`. + public class SetCinematicResp: Message, @unchecked Sendable { + public let state: CinematicState? + public let error: Error? + + public init(state: CinematicState?, error: Error?) { + self.state = state + self.error = error + super.init(sender: nil) + } + } + /// "I am leaving on purpose." Sent by whichever side ends the session /// deliberately, so the peer stops reconnecting instead of chasing a /// session nobody is coming back to. Fire-and-forget: an unplanned @@ -608,6 +632,11 @@ public class RemoteCmd: Message, @unchecked Sendable { public let supportsManualExposure: Bool /// Current exposure truth + ranges, so the panel opens populated. public let exposure: ExposureState? + /// True when the peer's active device can record Cinematic video + /// (iOS 26+). Gates `RemoteCmd.SetCinematic` and the monitor control. + public let supportsCinematicVideo: Bool + /// Current Cinematic truth + aperture range. + public let cinematic: CinematicState? public let error: Error? public init(frontCamera: CameraInfo?, backCamera: CameraInfo?, @@ -625,6 +654,8 @@ public class RemoteCmd: Message, @unchecked Sendable { previewMode: CameraPreviewMode = .on, supportsManualExposure: Bool = false, exposure: ExposureState? = nil, + supportsCinematicVideo: Bool = false, + cinematic: CinematicState? = nil, error: Error?) { self.frontCamera = frontCamera self.backCamera = backCamera @@ -643,6 +674,8 @@ public class RemoteCmd: Message, @unchecked Sendable { self.previewMode = previewMode self.supportsManualExposure = supportsManualExposure self.exposure = exposure + self.supportsCinematicVideo = supportsCinematicVideo + self.cinematic = cinematic self.error = error super.init(sender: nil) } diff --git a/RemoteCam/SessionCoordinator.swift b/RemoteCam/SessionCoordinator.swift index 7745bf50..1649cdda 100644 --- a/RemoteCam/SessionCoordinator.swift +++ b/RemoteCam/SessionCoordinator.swift @@ -249,6 +249,13 @@ public actor SessionCoordinator { /// Test support. func peerSupportsManualExposureForTesting() -> Bool { peerSupportsManualExposure } + /// Whether the connected camera peer advertised Cinematic-video support — + /// the feature gate for `RemoteCmd.SetCinematic`. + private var peerSupportsCinematicVideo = false + + /// Test support. + func peerSupportsCinematicVideoForTesting() -> Bool { peerSupportsCinematicVideo } + /// Monitor side: at least one VP9 preview frame has arrived. Proves the /// camera peer speaks VP9, which gates sending `RemoteCmd.RequestKeyframe`. private var monitorReceivedVP9Frame = false @@ -801,6 +808,7 @@ public actor SessionCoordinator { peerSupportsFocusPoint = false peerSupportsPreviewMode = false peerSupportsManualExposure = false + peerSupportsCinematicVideo = false monitorReceivedVP9Frame = false // The session is being torn down for good (deliberate leave, EndSession, // or a dead link) — a fresh session starts single-cam until a director @@ -1309,6 +1317,9 @@ public actor SessionCoordinator { case let exposure as RemoteCmd.SetExposure: await handleSetExposure(exposure, ctrl: ctrl) + case let cinematic as RemoteCmd.SetCinematic: + await handleSetCinematic(cinematic, ctrl: ctrl) + case let lens as RemoteCmd.SwitchLens: do { let (lensType, available, zoom, range) = try await ctrl.switchLens(to: lens.lensType) @@ -1402,6 +1413,7 @@ public actor SessionCoordinator { peerSupportsFocusPoint = capabilities.supportsFocusPoint peerSupportsPreviewMode = capabilities.supportsPreviewMode peerSupportsManualExposure = capabilities.supportsManualExposure + peerSupportsCinematicVideo = capabilities.supportsCinematicVideo monitor?.updateCapabilities(capabilities) monitor?.updatePreviewMode(capabilities.previewMode) } @@ -1417,6 +1429,16 @@ public actor SessionCoordinator { } } + /// Camera side of `SetCinematic`, mirroring `handleSetExposure`. + private func handleSetCinematic(_ cmd: RemoteCmd.SetCinematic, ctrl: CameraControlling) async { + do { + let state = try await ctrl.setCinematic(cmd.intent) + await sendOrGoToScanning(RemoteCmd.SetCinematicResp(state: state, error: nil)) + } catch { + await sendOrGoToScanning(RemoteCmd.SetCinematicResp(state: nil, error: error as NSError)) + } + } + /// "The switch didn't stick." The message rides in the NSError domain — /// the convention every monitor's error display reads (`error._domain`). private func couldNotSwitchCameraError() -> NSError { @@ -1714,6 +1736,10 @@ public actor SessionCoordinator { // duration so the clip's frame rate holds. await handleSetExposure(exposure, ctrl: ctrl) + case let cinematic as RemoteCmd.SetCinematic: + // The policy rejects mid-take changes; the response says so. + await handleSetCinematic(cinematic, ctrl: ctrl) + case let lens as RemoteCmd.SwitchLens: do { let (lensType, available, zoom, range) = try await ctrl.switchLens(to: lens.lensType) @@ -2432,6 +2458,16 @@ public actor SessionCoordinator { case let exposureResp as RemoteCmd.SetExposureResp: monitor?.updateExposure(exposureResp.state) + case let cinematic as UICmd.SetCinematic: + guard peerSupportsCinematicVideo else { + debugLog("SetCinematic dropped: peer did not advertise Cinematic support") + break + } + sendMessage(RemoteCmd.SetCinematic(intent: cinematic.intent)) + + case let cinematicResp as RemoteCmd.SetCinematicResp: + monitor?.updateCinematic(cinematicResp.state) + case let preview as UICmd.SetCameraPreviewMode: // Wire-safety gate mirroring FocusAtPoint: never send action 24 to a // peer that predates it (it would misread the unknown action). @@ -2790,6 +2826,16 @@ public actor SessionCoordinator { case let exposureResp as RemoteCmd.SetExposureResp: monitor?.updateExposure(exposureResp.state) + case let cinematic as UICmd.SetCinematic: + guard peerSupportsCinematicVideo else { + debugLog("SetCinematic dropped: peer did not advertise Cinematic support") + break + } + sendMessage(RemoteCmd.SetCinematic(intent: cinematic.intent)) + + case let cinematicResp as RemoteCmd.SetCinematicResp: + monitor?.updateCinematic(cinematicResp.state) + case let preview as UICmd.SetCameraPreviewMode: guard peerSupportsPreviewMode else { debugLog("SetCameraPreviewMode dropped: peer did not advertise preview-mode support") diff --git a/RemoteCam/SettingsView.swift b/RemoteCam/SettingsView.swift index 6a695446..efe3372e 100644 --- a/RemoteCam/SettingsView.swift +++ b/RemoteCam/SettingsView.swift @@ -73,6 +73,9 @@ struct SettingsView: View { purchaseRow(item: viewModel.enableTorch, icon: "flashlight.on.fill") purchaseRow(item: viewModel.enableVideo, icon: "video.fill") purchaseRow(item: viewModel.tapToFocus, icon: "camera.metering.spot") + if FeatureFlags.ENABLE_PRO_CONTROLS { + purchaseRow(item: viewModel.proControls, icon: "camera.aperture") + } if FeatureFlags.ENABLE_MULTICAM { purchaseRow(item: viewModel.maxCamerasPack, icon: "square.grid.2x2.fill") } diff --git a/RemoteCam/SettingsViewModel.swift b/RemoteCam/SettingsViewModel.swift index d9083120..4621402d 100644 --- a/RemoteCam/SettingsViewModel.swift +++ b/RemoteCam/SettingsViewModel.swift @@ -25,6 +25,7 @@ final class SettingsViewModel: ObservableObject, PurchaseManaging { @Published var enableTorch = PurchaseItem(id: enableTorchPID, title: "", price: "", isPurchased: false) @Published var enableVideo = PurchaseItem(id: enableVideoOnlyPID, title: "", price: "", isPurchased: false) @Published var tapToFocus = PurchaseItem(id: tapToFocusPID, title: "", price: "", isPurchased: false) + @Published var proControls = PurchaseItem(id: proControlsPID, title: "", price: "", isPurchased: false) @Published var maxCamerasPack = PurchaseItem(id: maxCamerasPID, title: "", price: "", isPurchased: false) /// True once the StoreKit product fetch has completed (success or not), so /// the paywall can swap skeleton rows for real names/prices. @@ -136,6 +137,10 @@ final class SettingsViewModel: ObservableObject, PurchaseManaging { tapToFocus.title = product.displayName tapToFocus.price = product.displayPrice tapToFocus.isPurchased = store.hasTapToFocusFeature() + case proControlsPID: + proControls.title = product.displayName + proControls.price = product.displayPrice + proControls.isPurchased = store.hasProControlsFeature() case maxCamerasPID: maxCamerasPack.title = product.displayName maxCamerasPack.price = product.displayPrice @@ -165,6 +170,7 @@ final class SettingsViewModel: ObservableObject, PurchaseManaging { enableTorch.isPurchased = store.hasTorchFeature() enableVideo.isPurchased = store.hasVideoRecordingFeature() tapToFocus.isPurchased = store.hasTapToFocusFeature() + proControls.isPurchased = store.hasProControlsFeature() maxCamerasPack.isPurchased = store.hasMaxCamerasFeature() } diff --git a/RemoteCam/StoreManager.swift b/RemoteCam/StoreManager.swift index 7e2f5b58..22ba4835 100644 --- a/RemoteCam/StoreManager.swift +++ b/RemoteCam/StoreManager.swift @@ -17,6 +17,7 @@ extension Notification.Name { static let enableTorch = Notification.Name("EnableTorch") static let enableVideoOnly = Notification.Name("EnableVideoOnly") static let tapToFocusAcquired = Notification.Name("TapToFocusAcquired") + static let proControlsAcquired = Notification.Name("ProControlsAcquired") static let proSubscriptionAcquired = Notification.Name("ProSubscriptionAcquired") static let maxCamerasAcquired = Notification.Name("MaxCamerasAcquired") } @@ -29,6 +30,7 @@ private enum PurchaseKey { static let enableTorch = "didBuyEnableTorchFeature" static let enableVideoOnly = "didBuyEnableVideoOnlyFeature" static let tapToFocus = "didBuyTapToFocusFeature" + static let proControls = "didBuyProControlsFeature" static let maxCameras = "didBuyMaxCamerasFeature" // Unlike the one-time flags above, this is NOT append-only: refreshPurchaseState // recomputes it from Transaction.currentEntitlements so a lapsed subscription @@ -45,7 +47,7 @@ final class StoreManager: ObservableObject { static let allProductIDs: Set = [ disableAdsPID, enableVideoPID, enableTorchPID, enableVideoOnlyPID, - tapToFocusPID, maxCamerasPID, proMonthlyPID, proYearlyPID + tapToFocusPID, proControlsPID, maxCamerasPID, proMonthlyPID, proYearlyPID ] /// The auto-renewable subscription products (the "Pro" subscription group). @@ -94,6 +96,12 @@ final class StoreManager: ObservableObject { hasFullAccess() || UserDefaults.standard.bool(forKey: PurchaseKey.tapToFocus) } + /// Pro controls (manual shutter/ISO + Cinematic aperture). Unlocked by + /// full access or its own IAP (10). + func hasProControlsFeature() -> Bool { + hasFullAccess() || UserDefaults.standard.bool(forKey: PurchaseKey.proControls) + } + /// The multicam camera caps — the single source for every gate and every /// piece of copy that names a number, so they can never disagree. The /// paid cap is held at 4 until larger rigs are validated on hardware @@ -218,6 +226,8 @@ final class StoreManager: ObservableObject { defaults.set(true, forKey: PurchaseKey.enableVideoOnly) case tapToFocusPID: defaults.set(true, forKey: PurchaseKey.tapToFocus) + case proControlsPID: + defaults.set(true, forKey: PurchaseKey.proControls) case maxCamerasPID: defaults.set(true, forKey: PurchaseKey.maxCameras) case proMonthlyPID, proYearlyPID: @@ -239,6 +249,7 @@ final class StoreManager: ObservableObject { if hasVideoRecordingFeature() { post(.enableVideoOnly) } if hasProSubscription() { post(.proSubscriptionAcquired) } if hasTapToFocusFeature() { post(.tapToFocusAcquired) } + if hasProControlsFeature() { post(.proControlsAcquired) } if hasMaxCamerasFeature() { post(.maxCamerasAcquired) } } diff --git a/RemoteCam/SwiftConstants.swift b/RemoteCam/SwiftConstants.swift index 8dd99694..2118d87f 100644 --- a/RemoteCam/SwiftConstants.swift +++ b/RemoteCam/SwiftConstants.swift @@ -33,6 +33,9 @@ public let enableVideoPID = "06" public let enableTorchPID = "07" public let enableVideoOnlyPID = "08" public let tapToFocusPID = "09" +/// Pro controls (issue #206): manual shutter/ISO + Cinematic aperture from +/// the remote. One product covers both. +public let proControlsPID = "10" /// One-time pack: multicam directing at the paid camera cap. The id is /// cap-agnostic on purpose — the same purchase grows with `maxPaidCameras` /// as larger rigs are validated; only the marketing copy names a number. diff --git a/RemoteCam/UICmds.swift b/RemoteCam/UICmds.swift index 6756cfa7..32ba3d47 100644 --- a/RemoteCam/UICmds.swift +++ b/RemoteCam/UICmds.swift @@ -251,6 +251,16 @@ public class UICmd { } } + /// Monitor screen -> session: Cinematic video on/off + aperture. + public class SetCinematic: Message, @unchecked Sendable { + public let intent: CinematicIntent + + public init(intent: CinematicIntent) { + self.intent = intent + super.init(sender: nil) + } + } + public class SetZoomResp: Message, @unchecked Sendable { public let zoomFactor: CGFloat? public let currentLens: CameraLensType? diff --git a/RemoteCam/WelcomeViewModel.swift b/RemoteCam/WelcomeViewModel.swift index 2fc19cac..1814ecd1 100644 --- a/RemoteCam/WelcomeViewModel.swift +++ b/RemoteCam/WelcomeViewModel.swift @@ -110,6 +110,8 @@ final class WelcomeViewModel: ObservableObject, PurchaseManaging { isPurchased: store.hasVideoRecordingFeature(), icon: "video.fill", tint: "red"), UpgradeItem(id: tapToFocusPID, title: "", price: "", isPurchased: store.hasTapToFocusFeature(), icon: "camera.metering.spot", tint: "green"), + UpgradeItem(id: proControlsPID, title: "", price: "", + isPurchased: store.hasProControlsFeature(), icon: "camera.aperture", tint: "indigo"), ]) if FeatureFlags.ENABLE_MULTICAM { items.append(UpgradeItem(id: maxCamerasPID, title: "", price: "", @@ -145,6 +147,8 @@ final class WelcomeViewModel: ObservableObject, PurchaseManaging { upgrades[i].isPurchased = store.hasVideoRecordingFeature() case tapToFocusPID: upgrades[i].isPurchased = store.hasTapToFocusFeature() + case proControlsPID: + upgrades[i].isPurchased = store.hasProControlsFeature() case maxCamerasPID: upgrades[i].isPurchased = store.hasMaxCamerasFeature() default: diff --git a/RemoteCamTests/CinematicPolicyTests.swift b/RemoteCamTests/CinematicPolicyTests.swift new file mode 100644 index 00000000..82439e85 --- /dev/null +++ b/RemoteCamTests/CinematicPolicyTests.swift @@ -0,0 +1,104 @@ +import XCTest +@testable import RemoteShutter + +final class CinematicPolicyTests: XCTestCase { + + /// An iPhone that supports Cinematic video with an adjustable aperture. + private let phone = CinematicFacts( + supported: true, enabled: false, + minAperture: 1.4, maxAperture: 16, defaultAperture: 2.0, currentAperture: 2.0) + + private func resolve(_ intent: CinematicIntent, facts: CinematicFacts, + recording: Bool = false, video: Bool = true) -> CinematicPlan { + CinematicPolicy.resolve(intent, facts: facts, isRecording: recording, isVideoMode: video) + } + + func testEnableOnSupportedVideoCamera() { + XCTAssertEqual(resolve(.on(aperture: 2.8), facts: phone), .enable(aperture: 2.8)) + XCTAssertEqual(resolve(.on(aperture: nil), facts: phone), .enable(aperture: nil)) + } + + func testApertureClampsIntoRange() { + XCTAssertEqual(resolve(.on(aperture: 0.95), facts: phone), .enable(aperture: 1.4)) + XCTAssertEqual(resolve(.on(aperture: 22), facts: phone), .enable(aperture: 16)) + } + + func testFixedApertureDeviceIgnoresRequestedValue() { + var fixed = phone + fixed.minAperture = 0 + fixed.maxAperture = 0 + XCTAssertEqual(resolve(.on(aperture: 2.8), facts: fixed), .enable(aperture: nil)) + } + + func testPhotoModeRejects() { + XCTAssertEqual(resolve(.on(aperture: 2.8), facts: phone, video: false), .rejected(.photoMode)) + } + + func testUnsupportedRejectsInEveryMode() { + var mac = phone + mac.supported = false + XCTAssertEqual(resolve(.on(aperture: 2.8), facts: mac), .rejected(.unsupported)) + XCTAssertEqual(resolve(.on(aperture: 2.8), facts: mac, video: false), .rejected(.unsupported)) + } + + /// Apple throws on aperture/enable changes mid-take: the policy rejects + /// them so the engine never makes the call. + func testRecordingLocksEverything() { + var enabledPhone = phone + enabledPhone.enabled = true + XCTAssertEqual(resolve(.on(aperture: 4.0), facts: enabledPhone, recording: true), .rejected(.recording)) + XCTAssertEqual(resolve(.off, facts: enabledPhone, recording: true), .rejected(.recording)) + XCTAssertEqual(resolve(.on(aperture: 4.0), facts: phone, recording: true), .rejected(.recording)) + } + + func testApertureOnlyWhenAlreadyEnabled() { + var enabledPhone = phone + enabledPhone.enabled = true + XCTAssertEqual(resolve(.on(aperture: 4.0), facts: enabledPhone), .apertureOnly(4.0)) + // Same aperture, same state: nothing to do. + XCTAssertEqual(resolve(.on(aperture: 2.0), facts: enabledPhone), .noop) + XCTAssertEqual(resolve(.on(aperture: nil), facts: enabledPhone), .noop) + } + + func testDisable() { + var enabledPhone = phone + enabledPhone.enabled = true + XCTAssertEqual(resolve(.off, facts: enabledPhone), .disable) + XCTAssertEqual(resolve(.off, facts: phone), .noop) + } + + // MARK: - Dial stops + + func testShutterStopsFilterToRange() { + let stops = ProDialStops.shutterStops(min: 1.0 / 10_000, max: 1.0 / 3) + XCTAssertEqual(stops.first, 1.0 / 8000) + XCTAssertEqual(stops.last, 1.0 / 3) + XCTAssertFalse(stops.contains(0.5)) + } + + func testISOStopsFilterToRange() { + let stops = ProDialStops.isoStops(min: 32, max: 3200) + XCTAssertEqual(stops.first, 32) + XCTAssertEqual(stops.last, 3200) + } + + func testApertureStopsEmptyForFixedAperture() { + XCTAssertTrue(ProDialStops.apertureStops(min: 0, max: 0).isEmpty) + XCTAssertEqual(ProDialStops.apertureStops(min: 1.4, max: 16).first, 1.4) + } + + func testNearestIndexSnapsToClosestDetent() { + let stops: [Double] = [1.0 / 250, 1.0 / 125, 1.0 / 60] + XCTAssertEqual(ProDialStops.nearestIndex(of: 1.0 / 120, in: stops), 1) + XCTAssertNil(ProDialStops.nearestIndex(of: 1.0, in: [Double]())) + } + + func testLabels() { + XCTAssertEqual(ProDialStops.shutterLabel(1.0 / 125), "1/125") + XCTAssertEqual(ProDialStops.shutterLabel(0.5), "0.5s") + XCTAssertEqual(ProDialStops.shutterLabel(1.0), "1s") + XCTAssertEqual(ProDialStops.isoLabel(400), "ISO 400") + XCTAssertEqual(ProDialStops.apertureLabel(2.8), "f/2.8") + XCTAssertEqual(ProDialStops.apertureLabel(16), "f/16") + } +} diff --git a/RemoteCamTests/LoopbackSessionTests.swift b/RemoteCamTests/LoopbackSessionTests.swift index 6638ebcb..1370dbd3 100644 --- a/RemoteCamTests/LoopbackSessionTests.swift +++ b/RemoteCamTests/LoopbackSessionTests.swift @@ -760,6 +760,52 @@ class LoopbackSessionTests: XCTestCase { XCTAssertEqual(monitorState, .monitor) } + // MARK: - Cinematic video + + func testSetCinematicHappyPathAcrossTheWire() async { + let fakeCamera = await connectCameraAndMonitor(monitorMode: .Video) + cameraTransport.sentMessages.removeAll() + + monitorCoordinator.tell(UICmd.SetCinematic(intent: .on(aperture: 2.8))) + await drainBothSessions() + + XCTAssertEqual(fakeCamera.cinematicCalls, [.on(aperture: 2.8)]) + let resp = cameraTransport.sentMessages.compactMap { $0 as? RemoteCmd.SetCinematicResp }.last + XCTAssertEqual(resp?.state?.enabled, true) + XCTAssertEqual(resp?.state?.simulatedAperture ?? 0, 2.8) + + monitorCoordinator.tell(UICmd.SetCinematic(intent: .off)) + await drainBothSessions() + XCTAssertEqual(fakeCamera.cinematicCalls.last, .off) + let offResp = cameraTransport.sentMessages.compactMap { $0 as? RemoteCmd.SetCinematicResp }.last + XCTAssertEqual(offResp?.state?.enabled, false) + let monitorState = await monitorCoordinator.currentStateName() + XCTAssertEqual(monitorState, .monitor) + } + + /// Mirrors the exposure gate: never send action 34 to a peer that did not + /// advertise Cinematic support. + func testSetCinematicIsNeverSentToPeerWithoutSupport() async { + await connectBothSessions() + let fakeCamera = LoopbackFakeCamera() + fakeCamera.advertisesCinematicVideo = false + fakeCamera.coordinator = cameraCoordinator + cameraCoordinator.tell(UICmd.BecomeCamera(sender: nil, ctrl: fakeCamera)) + await drainBothSessions() + await becomeMonitor(mode: .Video) + let gate = await monitorCoordinator.peerSupportsCinematicVideoForTesting() + XCTAssertFalse(gate) + monitorTransport.sentMessages.removeAll() + + monitorCoordinator.tell(UICmd.SetCinematic(intent: .on(aperture: nil))) + await drainBothSessions() + + XCTAssertFalse(monitorTransport.sentMessages.contains { $0 is RemoteCmd.SetCinematic }, + "SetCinematic must be gated on advertised supports_cinematic_video") + XCTAssertTrue(fakeCamera.cinematicCalls.isEmpty) + XCTAssertTrue(fakeCamera.takePictureCalls.isEmpty) + } + /// Safety gate mirroring SelectCameraDevice: old peers decode the unknown /// FocusAtPoint action as TakePicture, so the monitor must never send it to a /// peer whose capabilities did not advertise focus-point support. diff --git a/RemoteCamTests/MonitorScreenSnapshotTests.swift b/RemoteCamTests/MonitorScreenSnapshotTests.swift index a1a81a01..51a62956 100644 --- a/RemoteCamTests/MonitorScreenSnapshotTests.swift +++ b/RemoteCamTests/MonitorScreenSnapshotTests.swift @@ -63,6 +63,29 @@ final class MonitorScreenSnapshotTests: SnapshotTestCase { assertHasChrome(image) } + /// The pro panel in its richest state: Manual exposure dials + Cinematic + /// on with the aperture dial locked by a recording. + func testProControlsPanelRenders() { + let model = makeConnectedModel() + model.currentMode = .Video + model.uiState = .videoMode + model.supportsManualExposure = true + model.exposure = ExposureState( + mode: .manual, durationSeconds: 1.0 / 125, iso: 400, + minDurationSeconds: 1.0 / 8000, maxDurationSeconds: 0.5, minISO: 32, maxISO: 3200) + model.supportsCinematicVideo = true + model.cinematic = CinematicState( + enabled: true, simulatedAperture: 2.8, + minSimulatedAperture: 1.4, maxSimulatedAperture: 16, + defaultSimulatedAperture: 2.0, apertureLocked: true, notEnoughLight: true) + + let panel = ProControlsPanel(viewModel: model, + onExposureChange: { _ in }, + onCinematicChange: { _ in }) + let image = renderScreen(named: "monitor-pro-panel", panel) + assertRendered(image) + } + func testWaitingForFirstFrame() { // Fresh connection: no frame from the camera yet. let model = MonitorViewModel() diff --git a/RemoteCamTests/RemoteCmdSerializationTests.swift b/RemoteCamTests/RemoteCmdSerializationTests.swift index 5d581492..7b39a8fa 100644 --- a/RemoteCamTests/RemoteCmdSerializationTests.swift +++ b/RemoteCamTests/RemoteCmdSerializationTests.swift @@ -61,6 +61,8 @@ final class RemoteCmdSerializationTests: XCTestCase { case let m as RemoteCmd.FocusAtPoint: return m.toFlatBuffer() case let m as RemoteCmd.SetExposure: return m.toFlatBuffer() case let m as RemoteCmd.SetExposureResp: return m.toFlatBuffer() + case let m as RemoteCmd.SetCinematic: return m.toFlatBuffer() + case let m as RemoteCmd.SetCinematicResp: return m.toFlatBuffer() case let m as RemoteCmd.SetCameraPreviewMode: return m.toFlatBuffer() case let m as RemoteCmd.CameraPreviewModeResp: return m.toFlatBuffer() case let m as RemoteCmd.CameraCapabilitiesResp: return m.toFlatBuffer() @@ -412,6 +414,45 @@ final class RemoteCmdSerializationTests: XCTestCase { XCTAssertNil(decoded.exposure) } + // MARK: - 11d. SetCinematic + + private let sampleCinematic = CinematicState( + enabled: true, simulatedAperture: 2.8, + minSimulatedAperture: 1.4, maxSimulatedAperture: 16, + defaultSimulatedAperture: 2.0, apertureLocked: true, notEnoughLight: true) + + func testSetCinematic_roundTrip() { + let on: RemoteCmd.SetCinematic = roundTrip(RemoteCmd.SetCinematic(intent: .on(aperture: 2.8))) + XCTAssertEqual(on.intent, .on(aperture: 2.8)) + // aperture nil = "keep current"; 0 on the wire must decode back to nil. + let keep: RemoteCmd.SetCinematic = roundTrip(RemoteCmd.SetCinematic(intent: .on(aperture: nil))) + XCTAssertEqual(keep.intent, .on(aperture: nil)) + let off: RemoteCmd.SetCinematic = roundTrip(RemoteCmd.SetCinematic(intent: .off)) + XCTAssertEqual(off.intent, .off) + } + + func testSetCinematicResp_roundTrip() { + let decoded: RemoteCmd.SetCinematicResp = roundTrip(RemoteCmd.SetCinematicResp(state: sampleCinematic, error: nil)) + XCTAssertEqual(decoded.state, sampleCinematic) + XCTAssertNil(decoded.error) + } + + func testCameraCapabilities_cinematicRoundTrip() { + let original = RemoteCmd.CameraCapabilitiesResp( + frontCamera: nil, backCamera: nil, + currentCamera: .back, currentLens: .wideAngle, currentZoom: 1.0, + supportsCinematicVideo: true, cinematic: sampleCinematic, error: nil) + let decoded: RemoteCmd.CameraCapabilitiesResp = roundTrip(original) + XCTAssertTrue(decoded.supportsCinematicVideo) + XCTAssertEqual(decoded.cinematic, sampleCinematic) + // Absent on legacy peers. + let legacy: RemoteCmd.CameraCapabilitiesResp = roundTrip(RemoteCmd.CameraCapabilitiesResp( + frontCamera: nil, backCamera: nil, + currentCamera: .back, currentLens: .wideAngle, currentZoom: 1.0, error: nil)) + XCTAssertFalse(legacy.supportsCinematicVideo) + XCTAssertNil(legacy.cinematic) + } + // MARK: - 12. SetZoomResp func testSetZoomResp_roundTrip() { diff --git a/RemoteCamTests/SessionTestSupport.swift b/RemoteCamTests/SessionTestSupport.swift index b243c459..fae63a2c 100644 --- a/RemoteCamTests/SessionTestSupport.swift +++ b/RemoteCamTests/SessionTestSupport.swift @@ -124,6 +124,9 @@ class FakeCameraControlling: CameraControlling, @unchecked Sendable { var focusCalls: [CGPoint] = [] var exposureCalls: [ExposureIntent] = [] var advertisesManualExposure = true + var cinematicCalls: [CinematicIntent] = [] + var advertisesCinematicVideo = true + var cinematicEnabled = false var lensSwitches: [CameraLensType] = [] var torchToggles = 0 var chimes: [Int] = [] @@ -171,6 +174,21 @@ class FakeCameraControlling: CameraControlling, @unchecked Sendable { if let errorToThrow { throw errorToThrow } focusCalls.append(CGPoint(x: CGFloat(x), y: CGFloat(y))) } + /// Echoes the intent like the engine (fixed phone-like aperture range). + func setCinematic(_ intent: CinematicIntent) async throws -> CinematicState { + if let errorToThrow { throw errorToThrow } + cinematicCalls.append(intent) + switch intent { + case .off: cinematicEnabled = false + case .on: cinematicEnabled = true + } + var aperture: Float = 2.0 + if case let .on(requested) = intent, let requested { aperture = requested } + return CinematicState(enabled: cinematicEnabled, simulatedAperture: aperture, + minSimulatedAperture: 1.4, maxSimulatedAperture: 16, + defaultSimulatedAperture: 2.0, apertureLocked: false, notEnoughLight: false) + } + /// Echoes the intent clamped into a fixed phone-like range, like the engine. func setExposure(_ intent: ExposureIntent) async throws -> ExposureState { if let errorToThrow { throw errorToThrow } @@ -318,6 +336,7 @@ class FakeCameraControlling: CameraControlling, @unchecked Sendable { supportsPreviewMode: advertisesPreviewMode, previewMode: storedPreviewMode, supportsManualExposure: advertisesManualExposure, + supportsCinematicVideo: advertisesCinematicVideo, error: nil) } diff --git a/RemoteCamTests/StoreManagerTests.swift b/RemoteCamTests/StoreManagerTests.swift index a3ce2ddc..f0e447f3 100644 --- a/RemoteCamTests/StoreManagerTests.swift +++ b/RemoteCamTests/StoreManagerTests.swift @@ -187,12 +187,13 @@ final class StoreManagerTests: XCTestCase { func testAllProductIDsContainsEveryProduct() { let ids = StoreManager.allProductIDs - XCTAssertEqual(ids.count, 8) + XCTAssertEqual(ids.count, 9) XCTAssertTrue(ids.contains(disableAdsPID)) XCTAssertTrue(ids.contains(enableVideoPID)) XCTAssertTrue(ids.contains(enableTorchPID)) XCTAssertTrue(ids.contains(enableVideoOnlyPID)) XCTAssertTrue(ids.contains(tapToFocusPID)) + XCTAssertTrue(ids.contains(proControlsPID)) XCTAssertTrue(ids.contains(maxCamerasPID)) XCTAssertTrue(ids.contains(proMonthlyPID)) XCTAssertTrue(ids.contains(proYearlyPID)) diff --git a/RemoteShutter.xcodeproj/project.pbxproj b/RemoteShutter.xcodeproj/project.pbxproj index 4db93cd5..0ffdfce2 100644 --- a/RemoteShutter.xcodeproj/project.pbxproj +++ b/RemoteShutter.xcodeproj/project.pbxproj @@ -183,6 +183,7 @@ CAFEBABE0099000000000002 /* HEVCFrameEncoder.swift in Sources */ = {isa = PBXBuildFile; fileRef = CAFEBABE0099000000000001 /* HEVCFrameEncoder.swift */; }; CAFEBABE00F0000000000002 /* FocusPointMappingTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = CAFEBABE00F0000000000001 /* FocusPointMappingTests.swift */; }; E0E0206A0000000000000004 /* ExposurePolicyTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = E0E0206A0000000000000003 /* ExposurePolicyTests.swift */; }; + E0E0206D0000000000000002 /* CinematicPolicyTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = E0E0206D0000000000000001 /* CinematicPolicyTests.swift */; }; CAFEBABE0121000000000002 /* MonitorChromeTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = CAFEBABE0121000000000001 /* MonitorChromeTests.swift */; }; CAFEBABE0100000000000002 /* PeerCompatibility.swift in Sources */ = {isa = PBXBuildFile; fileRef = CAFEBABE0100000000000001 /* PeerCompatibility.swift */; }; CB5F78DFB9D567955BC863AF /* SoundManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 99FC5340BB98B2BD307FFA1A /* SoundManager.swift */; }; @@ -203,6 +204,8 @@ FADEC0DE0002000000000002 /* PeerLinkStatus.swift in Sources */ = {isa = PBXBuildFile; fileRef = FADEC0DE0002000000000001 /* PeerLinkStatus.swift */; }; FC0CF5A101FE65A9800F0B238 /* FocusPointMapping.swift in Sources */ = {isa = PBXBuildFile; fileRef = FC0CF5A201FE65A9800F0B238 /* FocusPointMapping.swift */; }; E0E0206A0000000000000002 /* ExposurePolicy.swift in Sources */ = {isa = PBXBuildFile; fileRef = E0E0206A0000000000000001 /* ExposurePolicy.swift */; }; + E0E0206B0000000000000002 /* CinematicPolicy.swift in Sources */ = {isa = PBXBuildFile; fileRef = E0E0206B0000000000000001 /* CinematicPolicy.swift */; }; + E0E0206C0000000000000002 /* ProControlsPanel.swift in Sources */ = {isa = PBXBuildFile; fileRef = E0E0206C0000000000000001 /* ProControlsPanel.swift */; }; CAFEBABE0120000000000001 /* MonitorChrome.swift in Sources */ = {isa = PBXBuildFile; fileRef = CAFEBABE0120000000000002 /* MonitorChrome.swift */; }; CAFEBABE0177000000000001 /* SessionDebugConsole.swift in Sources */ = {isa = PBXBuildFile; fileRef = CAFEBABE0177000000000002 /* SessionDebugConsole.swift */; }; CAFEBABE0130000000000001 /* CaptureSyncMetadata.swift in Sources */ = {isa = PBXBuildFile; fileRef = CAFEBABE0130000000000002 /* CaptureSyncMetadata.swift */; }; @@ -452,6 +455,7 @@ CAFEBABE0099000000000001 /* HEVCFrameEncoder.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HEVCFrameEncoder.swift; sourceTree = ""; }; CAFEBABE00F0000000000001 /* FocusPointMappingTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FocusPointMappingTests.swift; sourceTree = ""; }; E0E0206A0000000000000003 /* ExposurePolicyTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ExposurePolicyTests.swift; sourceTree = ""; }; + E0E0206D0000000000000001 /* CinematicPolicyTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CinematicPolicyTests.swift; sourceTree = ""; }; CAFEBABE0121000000000001 /* MonitorChromeTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MonitorChromeTests.swift; sourceTree = ""; }; CAFEBABE0100000000000001 /* PeerCompatibility.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PeerCompatibility.swift; sourceTree = ""; }; CD857DFD7882DAA5012B70C9 /* FlatBufferSchemas.fbs */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text; path = FlatBufferSchemas.fbs; sourceTree = ""; }; @@ -467,6 +471,8 @@ FADEC0DE0002000000000001 /* PeerLinkStatus.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PeerLinkStatus.swift; sourceTree = ""; }; FC0CF5A201FE65A9800F0B238 /* FocusPointMapping.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = FocusPointMapping.swift; sourceTree = ""; }; E0E0206A0000000000000001 /* ExposurePolicy.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ExposurePolicy.swift; sourceTree = ""; }; + E0E0206B0000000000000001 /* CinematicPolicy.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = CinematicPolicy.swift; sourceTree = ""; }; + E0E0206C0000000000000001 /* ProControlsPanel.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ProControlsPanel.swift; sourceTree = ""; }; CAFEBABE0120000000000002 /* MonitorChrome.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = MonitorChrome.swift; sourceTree = ""; }; CAFEBABE0177000000000002 /* SessionDebugConsole.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SessionDebugConsole.swift; sourceTree = ""; }; CAFEBABE0130000000000002 /* CaptureSyncMetadata.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = CaptureSyncMetadata.swift; sourceTree = ""; }; @@ -637,6 +643,7 @@ CAFEBABE0001000000000001 /* CropRectTests.swift */, CAFEBABE00F0000000000001 /* FocusPointMappingTests.swift */, E0E0206A0000000000000003 /* ExposurePolicyTests.swift */, + E0E0206D0000000000000001 /* CinematicPolicyTests.swift */, CAFEBABE0121000000000001 /* MonitorChromeTests.swift */, CAFEBABE0131000000000001 /* CaptureSyncMetadataTests.swift */, CAFEBABE0133000000000001 /* ClockOffsetEstimatorTests.swift */, @@ -737,6 +744,8 @@ 0684A2D01BE65A9800F0B238 /* OrientationUtils.swift */, FC0CF5A201FE65A9800F0B238 /* FocusPointMapping.swift */, E0E0206A0000000000000001 /* ExposurePolicy.swift */, + E0E0206B0000000000000001 /* CinematicPolicy.swift */, + E0E0206C0000000000000001 /* ProControlsPanel.swift */, CAFEBABE0120000000000002 /* MonitorChrome.swift */, CAFEBABE0177000000000002 /* SessionDebugConsole.swift */, CAFEBABE0130000000000002 /* CaptureSyncMetadata.swift */, @@ -1245,6 +1254,8 @@ 0684A2D11BE65A9800F0B238 /* OrientationUtils.swift in Sources */, FC0CF5A101FE65A9800F0B238 /* FocusPointMapping.swift in Sources */, E0E0206A0000000000000002 /* ExposurePolicy.swift in Sources */, + E0E0206B0000000000000002 /* CinematicPolicy.swift in Sources */, + E0E0206C0000000000000002 /* ProControlsPanel.swift in Sources */, CAFEBABE0120000000000001 /* MonitorChrome.swift in Sources */, CAFEBABE0177000000000001 /* SessionDebugConsole.swift in Sources */, CAFEBABE0130000000000001 /* CaptureSyncMetadata.swift in Sources */, @@ -1332,6 +1343,7 @@ CAFEBABE0001000000000002 /* CropRectTests.swift in Sources */, CAFEBABE00F0000000000002 /* FocusPointMappingTests.swift in Sources */, E0E0206A0000000000000004 /* ExposurePolicyTests.swift in Sources */, + E0E0206D0000000000000002 /* CinematicPolicyTests.swift in Sources */, CAFEBABE0121000000000002 /* MonitorChromeTests.swift in Sources */, CAFEBABE0131000000000002 /* CaptureSyncMetadataTests.swift in Sources */, CAFEBABE0133000000000002 /* ClockOffsetEstimatorTests.swift in Sources */, diff --git a/fastlane/iap_localizations.json b/fastlane/iap_localizations.json index a6a2ced1..53896c42 100644 --- a/fastlane/iap_localizations.json +++ b/fastlane/iap_localizations.json @@ -311,6 +311,68 @@ "description": "Коснитесь превью для фокуса." } }, + "10": { + "en-US": { + "name": "Pro Camera Controls", + "description": "Manual shutter, ISO & Cinematic mode." + }, + "da": { + "name": "Pro-kamerastyring", + "description": "Manuel lukker, ISO og Cinematic." + }, + "de-DE": { + "name": "Profi-Kamerasteuerung", + "description": "Manuelle Belichtung, ISO & Cinematic." + }, + "es-MX": { + "name": "Controles Pro de cámara", + "description": "Obturador manual, ISO y modo Cinemático." + }, + "fr-FR": { + "name": "Contrôles Pro", + "description": "Obturateur manuel, ISO et mode Cinématique." + }, + "hi": { + "name": "प्रो कैमरा नियंत्रण", + "description": "मैनुअल शटर, ISO और सिनेमैटिक मोड।" + }, + "it": { + "name": "Controlli Pro", + "description": "Otturatore manuale, ISO e modo Cinema." + }, + "ja": { + "name": "プロカメラコントロール", + "description": "手動シャッター・ISO・シネマティック。" + }, + "ko": { + "name": "프로 카메라 제어", + "description": "수동 셔터, ISO 및 시네마틱 모드." + }, + "ms": { + "name": "Kawalan Kamera Pro", + "description": "Pengatup manual, ISO & mod Sinematik." + }, + "pt-BR": { + "name": "Controles Pro", + "description": "Obturador manual, ISO e modo Cinema." + }, + "ru": { + "name": "Проф. управление камерой", + "description": "Ручная выдержка, ISO и режим Синематик." + }, + "tr": { + "name": "Pro Kamera Kontrolleri", + "description": "Manuel enstantane, ISO ve Sinematik mod." + }, + "vi": { + "name": "Điều khiển máy ảnh Pro", + "description": "Màn trập thủ công, ISO và chế độ Điện ảnh." + }, + "zh-Hans": { + "name": "专业相机控制", + "description": "手动快门、ISO 与电影效果模式。" + } + }, "max_cameras": { "en-US": { "name": "4-Camera Multicam", From e9753d3d9b003e206a3b2fb3dc1f72dd97e8f1fb Mon Sep 17 00:00:00 2001 From: Dario Lencina Date: Sun, 23 Aug 2026 23:19:53 -0700 Subject: [PATCH 03/14] Pro controls are free: remove IAP 10 and purchase gate The PRO panel is included for every user; the only gate left is the capability gate (button appears only when the camera offers the feature). Co-Authored-By: Claude Fable 5 --- Docs/pro-controls.md | 24 +++---- RemoteCam/MonitorView.swift | 12 +--- RemoteCam/MonitorViewController+SwiftUI.swift | 3 - RemoteCam/PurchaseManaging.swift | 2 +- RemoteCam/SettingsView.swift | 3 - RemoteCam/SettingsViewModel.swift | 6 -- RemoteCam/StoreManager.swift | 12 +--- RemoteCam/SwiftConstants.swift | 3 - RemoteCam/WelcomeViewModel.swift | 4 -- RemoteCamTests/StoreManagerTests.swift | 3 +- fastlane/iap_localizations.json | 62 ------------------- 11 files changed, 13 insertions(+), 121 deletions(-) diff --git a/Docs/pro-controls.md b/Docs/pro-controls.md index b13e52b6..101dec03 100644 --- a/Docs/pro-controls.md +++ b/Docs/pro-controls.md @@ -18,8 +18,7 @@ usable by a person standing across the room from the phone. > (`supports_manual_exposure`, `supports_cinematic_video`), so a 10.0.x camera > pairs exactly as before and **a button only appears when the connected camera > offers that feature**. The UI ships behind -> `FeatureFlags.ENABLE_PRO_CONTROLS`; the user-facing unlock mirrors -> tap-to-focus (`StoreManager.hasProControlsFeature()`). +> `FeatureFlags.ENABLE_PRO_CONTROLS` and is free for every user — no IAP. ## What Apple actually lets us do @@ -154,10 +153,9 @@ updates with the next echo rather than by push. ## Monitor → camera path (both controls) -1. **Panel** (`ProControlsPanel` inside `MonitorChrome`). Dragging a dial emits - `UICmd.SetExposure` / `UICmd.SetCinematic` through the same 20 Hz - trailing-edge throttle `handleZoomChange` uses. Locked users are routed to - the paywall before anything is sent (mirrors `handleFocusTap`). +1. **Panel** (`ProControlsPanel`, opened from the tray's PRO tile). Dial + detents emit `UICmd.SetExposure` / `UICmd.SetCinematic` — discrete stop + changes, so no throttle is needed. No purchase gate: the feature is free. 2. **Coordinator send gate** in `.monitor` (photo and video-mode handlers): `guard peerSupportsManualExposure` / `guard peerSupportsCinematicVideo` else drop → `sendMessage(...)`. No new `SessionState`: like zoom, the @@ -308,17 +306,9 @@ follow-up. ## Monetization -One unlock for both controls — "Pro Controls" — mirroring tap-to-focus: -product ID `"10"`, `PurchaseKey.proControls`, -`StoreManager.hasProControlsFeature()` (`hasFullAccess() || purchased`), -`.proControlsAcquired` notification, a `PurchaseItem` in `SettingsViewModel` -and `WelcomeViewModel`, localized IAP strings in -`fastlane/iap_localizations.json` (name ≤ 30, description ≤ 45 chars, 15 -locales). The ASC product is created by hand. - -The wire-level gate and the purchase gate stay separate and additive: the -capability gate protects old peers and hides buttons the camera cannot honor; -the purchase gate protects revenue. +None — pro controls are included for every user. The only gate is the +capability gate: it protects old peers and hides controls the connected +camera cannot honor. ## Design rules diff --git a/RemoteCam/MonitorView.swift b/RemoteCam/MonitorView.swift index 6151b825..ac9d7e9a 100644 --- a/RemoteCam/MonitorView.swift +++ b/RemoteCam/MonitorView.swift @@ -37,11 +37,9 @@ struct MonitorView: View { /// Toggles the connected camera's local-preview mode (on ⇄ standby). let onToggleCameraStandby: () -> Void /// Pro controls (defaulted so previews/snapshots need not wire them). + /// Free for every user — the only gate is the camera's capability. var onExposureChange: (ExposureIntent) -> Void = { _ in } var onCinematicChange: (CinematicIntent) -> Void = { _ in } - /// The purchase gate, checked when opening the pro panel; a locked user is - /// routed to settings/paywall by `onSettingsTapped` (mirrors tap-to-focus). - var isProControlsUnlocked: () -> Bool = { true } @State private var isTrayOpen = false @State private var isProPanelOpen = false @@ -457,12 +455,8 @@ struct MonitorView: View { case .proControls: toggleTray() - if isProControlsUnlocked() { - withAnimation(.spring(response: 0.32, dampingFraction: 0.85)) { - isProPanelOpen = true - } - } else { - onSettingsTapped() // paywall, mirroring tap-to-focus + withAnimation(.spring(response: 0.32, dampingFraction: 0.85)) { + isProPanelOpen = true } case .settings: diff --git a/RemoteCam/MonitorViewController+SwiftUI.swift b/RemoteCam/MonitorViewController+SwiftUI.swift index 5eb44f81..e2e66c1f 100644 --- a/RemoteCam/MonitorViewController+SwiftUI.swift +++ b/RemoteCam/MonitorViewController+SwiftUI.swift @@ -67,9 +67,6 @@ extension MonitorViewController { }, onCinematicChange: { [weak self] intent in self?.session ! UICmd.SetCinematic(intent: intent) - }, - isProControlsUnlocked: { - StoreManager.shared.hasProControlsFeature() } ) diff --git a/RemoteCam/PurchaseManaging.swift b/RemoteCam/PurchaseManaging.swift index a91f1813..43feeb05 100644 --- a/RemoteCam/PurchaseManaging.swift +++ b/RemoteCam/PurchaseManaging.swift @@ -12,7 +12,7 @@ extension PurchaseManaging { func observePurchaseNotifications() { let names: [Notification.Name] = [ .removeAds, .proModeAcquired, .enableTorch, .enableVideoOnly, - .tapToFocusAcquired, .proControlsAcquired, .proSubscriptionAcquired + .tapToFocusAcquired, .proSubscriptionAcquired ] for name in names { let observer = NotificationCenter.default.addObserver( diff --git a/RemoteCam/SettingsView.swift b/RemoteCam/SettingsView.swift index efe3372e..6a695446 100644 --- a/RemoteCam/SettingsView.swift +++ b/RemoteCam/SettingsView.swift @@ -73,9 +73,6 @@ struct SettingsView: View { purchaseRow(item: viewModel.enableTorch, icon: "flashlight.on.fill") purchaseRow(item: viewModel.enableVideo, icon: "video.fill") purchaseRow(item: viewModel.tapToFocus, icon: "camera.metering.spot") - if FeatureFlags.ENABLE_PRO_CONTROLS { - purchaseRow(item: viewModel.proControls, icon: "camera.aperture") - } if FeatureFlags.ENABLE_MULTICAM { purchaseRow(item: viewModel.maxCamerasPack, icon: "square.grid.2x2.fill") } diff --git a/RemoteCam/SettingsViewModel.swift b/RemoteCam/SettingsViewModel.swift index 4621402d..d9083120 100644 --- a/RemoteCam/SettingsViewModel.swift +++ b/RemoteCam/SettingsViewModel.swift @@ -25,7 +25,6 @@ final class SettingsViewModel: ObservableObject, PurchaseManaging { @Published var enableTorch = PurchaseItem(id: enableTorchPID, title: "", price: "", isPurchased: false) @Published var enableVideo = PurchaseItem(id: enableVideoOnlyPID, title: "", price: "", isPurchased: false) @Published var tapToFocus = PurchaseItem(id: tapToFocusPID, title: "", price: "", isPurchased: false) - @Published var proControls = PurchaseItem(id: proControlsPID, title: "", price: "", isPurchased: false) @Published var maxCamerasPack = PurchaseItem(id: maxCamerasPID, title: "", price: "", isPurchased: false) /// True once the StoreKit product fetch has completed (success or not), so /// the paywall can swap skeleton rows for real names/prices. @@ -137,10 +136,6 @@ final class SettingsViewModel: ObservableObject, PurchaseManaging { tapToFocus.title = product.displayName tapToFocus.price = product.displayPrice tapToFocus.isPurchased = store.hasTapToFocusFeature() - case proControlsPID: - proControls.title = product.displayName - proControls.price = product.displayPrice - proControls.isPurchased = store.hasProControlsFeature() case maxCamerasPID: maxCamerasPack.title = product.displayName maxCamerasPack.price = product.displayPrice @@ -170,7 +165,6 @@ final class SettingsViewModel: ObservableObject, PurchaseManaging { enableTorch.isPurchased = store.hasTorchFeature() enableVideo.isPurchased = store.hasVideoRecordingFeature() tapToFocus.isPurchased = store.hasTapToFocusFeature() - proControls.isPurchased = store.hasProControlsFeature() maxCamerasPack.isPurchased = store.hasMaxCamerasFeature() } diff --git a/RemoteCam/StoreManager.swift b/RemoteCam/StoreManager.swift index 22ba4835..9b26d203 100644 --- a/RemoteCam/StoreManager.swift +++ b/RemoteCam/StoreManager.swift @@ -17,7 +17,6 @@ extension Notification.Name { static let enableTorch = Notification.Name("EnableTorch") static let enableVideoOnly = Notification.Name("EnableVideoOnly") static let tapToFocusAcquired = Notification.Name("TapToFocusAcquired") - static let proControlsAcquired = Notification.Name("ProControlsAcquired") static let proSubscriptionAcquired = Notification.Name("ProSubscriptionAcquired") static let maxCamerasAcquired = Notification.Name("MaxCamerasAcquired") } @@ -30,7 +29,6 @@ private enum PurchaseKey { static let enableTorch = "didBuyEnableTorchFeature" static let enableVideoOnly = "didBuyEnableVideoOnlyFeature" static let tapToFocus = "didBuyTapToFocusFeature" - static let proControls = "didBuyProControlsFeature" static let maxCameras = "didBuyMaxCamerasFeature" // Unlike the one-time flags above, this is NOT append-only: refreshPurchaseState // recomputes it from Transaction.currentEntitlements so a lapsed subscription @@ -47,7 +45,7 @@ final class StoreManager: ObservableObject { static let allProductIDs: Set = [ disableAdsPID, enableVideoPID, enableTorchPID, enableVideoOnlyPID, - tapToFocusPID, proControlsPID, maxCamerasPID, proMonthlyPID, proYearlyPID + tapToFocusPID, maxCamerasPID, proMonthlyPID, proYearlyPID ] /// The auto-renewable subscription products (the "Pro" subscription group). @@ -96,11 +94,6 @@ final class StoreManager: ObservableObject { hasFullAccess() || UserDefaults.standard.bool(forKey: PurchaseKey.tapToFocus) } - /// Pro controls (manual shutter/ISO + Cinematic aperture). Unlocked by - /// full access or its own IAP (10). - func hasProControlsFeature() -> Bool { - hasFullAccess() || UserDefaults.standard.bool(forKey: PurchaseKey.proControls) - } /// The multicam camera caps — the single source for every gate and every /// piece of copy that names a number, so they can never disagree. The @@ -226,8 +219,6 @@ final class StoreManager: ObservableObject { defaults.set(true, forKey: PurchaseKey.enableVideoOnly) case tapToFocusPID: defaults.set(true, forKey: PurchaseKey.tapToFocus) - case proControlsPID: - defaults.set(true, forKey: PurchaseKey.proControls) case maxCamerasPID: defaults.set(true, forKey: PurchaseKey.maxCameras) case proMonthlyPID, proYearlyPID: @@ -249,7 +240,6 @@ final class StoreManager: ObservableObject { if hasVideoRecordingFeature() { post(.enableVideoOnly) } if hasProSubscription() { post(.proSubscriptionAcquired) } if hasTapToFocusFeature() { post(.tapToFocusAcquired) } - if hasProControlsFeature() { post(.proControlsAcquired) } if hasMaxCamerasFeature() { post(.maxCamerasAcquired) } } diff --git a/RemoteCam/SwiftConstants.swift b/RemoteCam/SwiftConstants.swift index 2118d87f..8dd99694 100644 --- a/RemoteCam/SwiftConstants.swift +++ b/RemoteCam/SwiftConstants.swift @@ -33,9 +33,6 @@ public let enableVideoPID = "06" public let enableTorchPID = "07" public let enableVideoOnlyPID = "08" public let tapToFocusPID = "09" -/// Pro controls (issue #206): manual shutter/ISO + Cinematic aperture from -/// the remote. One product covers both. -public let proControlsPID = "10" /// One-time pack: multicam directing at the paid camera cap. The id is /// cap-agnostic on purpose — the same purchase grows with `maxPaidCameras` /// as larger rigs are validated; only the marketing copy names a number. diff --git a/RemoteCam/WelcomeViewModel.swift b/RemoteCam/WelcomeViewModel.swift index 1814ecd1..2fc19cac 100644 --- a/RemoteCam/WelcomeViewModel.swift +++ b/RemoteCam/WelcomeViewModel.swift @@ -110,8 +110,6 @@ final class WelcomeViewModel: ObservableObject, PurchaseManaging { isPurchased: store.hasVideoRecordingFeature(), icon: "video.fill", tint: "red"), UpgradeItem(id: tapToFocusPID, title: "", price: "", isPurchased: store.hasTapToFocusFeature(), icon: "camera.metering.spot", tint: "green"), - UpgradeItem(id: proControlsPID, title: "", price: "", - isPurchased: store.hasProControlsFeature(), icon: "camera.aperture", tint: "indigo"), ]) if FeatureFlags.ENABLE_MULTICAM { items.append(UpgradeItem(id: maxCamerasPID, title: "", price: "", @@ -147,8 +145,6 @@ final class WelcomeViewModel: ObservableObject, PurchaseManaging { upgrades[i].isPurchased = store.hasVideoRecordingFeature() case tapToFocusPID: upgrades[i].isPurchased = store.hasTapToFocusFeature() - case proControlsPID: - upgrades[i].isPurchased = store.hasProControlsFeature() case maxCamerasPID: upgrades[i].isPurchased = store.hasMaxCamerasFeature() default: diff --git a/RemoteCamTests/StoreManagerTests.swift b/RemoteCamTests/StoreManagerTests.swift index f0e447f3..a3ce2ddc 100644 --- a/RemoteCamTests/StoreManagerTests.swift +++ b/RemoteCamTests/StoreManagerTests.swift @@ -187,13 +187,12 @@ final class StoreManagerTests: XCTestCase { func testAllProductIDsContainsEveryProduct() { let ids = StoreManager.allProductIDs - XCTAssertEqual(ids.count, 9) + XCTAssertEqual(ids.count, 8) XCTAssertTrue(ids.contains(disableAdsPID)) XCTAssertTrue(ids.contains(enableVideoPID)) XCTAssertTrue(ids.contains(enableTorchPID)) XCTAssertTrue(ids.contains(enableVideoOnlyPID)) XCTAssertTrue(ids.contains(tapToFocusPID)) - XCTAssertTrue(ids.contains(proControlsPID)) XCTAssertTrue(ids.contains(maxCamerasPID)) XCTAssertTrue(ids.contains(proMonthlyPID)) XCTAssertTrue(ids.contains(proYearlyPID)) diff --git a/fastlane/iap_localizations.json b/fastlane/iap_localizations.json index 53896c42..a6a2ced1 100644 --- a/fastlane/iap_localizations.json +++ b/fastlane/iap_localizations.json @@ -311,68 +311,6 @@ "description": "Коснитесь превью для фокуса." } }, - "10": { - "en-US": { - "name": "Pro Camera Controls", - "description": "Manual shutter, ISO & Cinematic mode." - }, - "da": { - "name": "Pro-kamerastyring", - "description": "Manuel lukker, ISO og Cinematic." - }, - "de-DE": { - "name": "Profi-Kamerasteuerung", - "description": "Manuelle Belichtung, ISO & Cinematic." - }, - "es-MX": { - "name": "Controles Pro de cámara", - "description": "Obturador manual, ISO y modo Cinemático." - }, - "fr-FR": { - "name": "Contrôles Pro", - "description": "Obturateur manuel, ISO et mode Cinématique." - }, - "hi": { - "name": "प्रो कैमरा नियंत्रण", - "description": "मैनुअल शटर, ISO और सिनेमैटिक मोड।" - }, - "it": { - "name": "Controlli Pro", - "description": "Otturatore manuale, ISO e modo Cinema." - }, - "ja": { - "name": "プロカメラコントロール", - "description": "手動シャッター・ISO・シネマティック。" - }, - "ko": { - "name": "프로 카메라 제어", - "description": "수동 셔터, ISO 및 시네마틱 모드." - }, - "ms": { - "name": "Kawalan Kamera Pro", - "description": "Pengatup manual, ISO & mod Sinematik." - }, - "pt-BR": { - "name": "Controles Pro", - "description": "Obturador manual, ISO e modo Cinema." - }, - "ru": { - "name": "Проф. управление камерой", - "description": "Ручная выдержка, ISO и режим Синематик." - }, - "tr": { - "name": "Pro Kamera Kontrolleri", - "description": "Manuel enstantane, ISO ve Sinematik mod." - }, - "vi": { - "name": "Điều khiển máy ảnh Pro", - "description": "Màn trập thủ công, ISO và chế độ Điện ảnh." - }, - "zh-Hans": { - "name": "专业相机控制", - "description": "手动快门、ISO 与电影效果模式。" - } - }, "max_cameras": { "en-US": { "name": "4-Camera Multicam", From f828f7a5c81a571845d295d27dc7b6f2a94d0aac Mon Sep 17 00:00:00 2001 From: Dario Lencina Date: Tue, 25 Aug 2026 22:33:59 -0700 Subject: [PATCH 04/14] Advertise manual exposure from constituentDevices, not activePrimaryConstituent activePrimaryConstituent is nil until the virtual device is used in a running session (AVCaptureDevice.h), and the first capabilities exchange fires before the session starts. Every modern iPhone opens on a virtual device, so the camera advertised supports_manual_exposure = false and the PRO tray tile never appeared. The lens choice is now one function (manualExposureLensLocked) used by both the capability and the hop; the tray predicate moved into MonitorTray so it is unit-tested. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01K5uHRuiiLu5FEBiHZkBWo3 --- Docs/pro-controls.md | 19 ++++++---- RemoteCam/CaptureEngine.swift | 33 ++++++++++++++---- RemoteCam/MonitorChrome.swift | 13 +++++++ RemoteCam/MonitorView.swift | 9 ++--- RemoteCamTests/MonitorChromeTests.swift | 46 +++++++++++++++++++++++-- 5 files changed, 99 insertions(+), 21 deletions(-) diff --git a/Docs/pro-controls.md b/Docs/pro-controls.md index 101dec03..51620adf 100644 --- a/Docs/pro-controls.md +++ b/Docs/pro-controls.md @@ -194,12 +194,19 @@ applyExposureIntentLocked() - `isExposureModeSupported(.custom) == false` → `.unsupported`. **Virtual device → physical lens.** Entering Manual on a virtual device swaps -the input to the physical lens currently in use -(`device.activePrimaryConstituent`, iOS 15+), carrying zoom over by the ratio -of the two zoom spaces; returning to Auto swaps back to -`preferredCamera(for:)`. While Manual is on, zoom is the physical lens's own -range (no auto lens switching); the existing `SetZoomResp` range echo already -informs the monitor's zoom slider. +the input to a physical lens that accepts `.custom`: the one currently in use +(`device.activePrimaryConstituent`, iOS 15+) when the session is running, else +the wide lens from `constituentDevices`. Returning to Auto swaps back to the +virtual device. While Manual is on, zoom is the physical lens's own range (no +auto lens switching); the existing `SetZoomResp` range echo already informs +the monitor's zoom slider. + +`supports_manual_exposure` is decided from `constituentDevices`, never from +`activePrimaryConstituent` alone: Apple documents that property as nil until +the virtual device is used in a *running* session, and the first capabilities +exchange fires before the session starts. Every modern iPhone opens on a +virtual device (Triple/DualWide), so a check on the active constituent alone +advertises no manual exposure and the PRO tile never appears. ### Cinematic diff --git a/RemoteCam/CaptureEngine.swift b/RemoteCam/CaptureEngine.swift index 7c5c08bf..9a9f326e 100644 --- a/RemoteCam/CaptureEngine.swift +++ b/RemoteCam/CaptureEngine.swift @@ -955,7 +955,11 @@ final class CaptureEngine: NSObject, AVCapturePhotoCaptureDelegate { // Pro-controls hardware probe (Docs/pro-controls.md): which devices // can do custom exposure, and what the format allows. let format = device.activeFormat + let lenses = device.constituentDevices + .map { "\($0.localizedName):custom=\($0.isExposureModeSupported(.custom))" } + .joined(separator: ", ") debugLog("🌗 EXPOSURE PROBE: \(device.localizedName) custom=\(device.isExposureModeSupported(.custom)) " + + "virtual=\(device.isVirtualDevice) lenses=[\(lenses)] " + "shutter \(CMTimeGetSeconds(format.minExposureDuration))–\(CMTimeGetSeconds(format.maxExposureDuration))s " + "ISO \(format.minISO)–\(format.maxISO)") } @@ -1311,12 +1315,29 @@ final class CaptureEngine: NSObject, AVCapturePhotoCaptureDelegate { } /// True when Manual exposure is worth offering on this device: it accepts - /// `.custom` itself, or it is a virtual device whose active physical lens + /// `.custom` itself, or it is a virtual device with a physical lens that /// does (the engine swaps to that lens when Manual is engaged). private func deviceSupportsManualExposureLocked(_ device: AVCaptureDevice) -> Bool { - if device.isExposureModeSupported(.custom) { return true } - return device.isVirtualDevice - && device.activePrimaryConstituent?.isExposureModeSupported(.custom) == true + manualExposureLensLocked(for: device) != nil + } + + /// The lens Manual exposure runs on: the device itself when it accepts + /// `.custom`; for a virtual device (which refuses it), a constituent that + /// does — the active one while the session runs, else the wide lens. + /// + /// Decided from `constituentDevices`, never from `activePrimaryConstituent` + /// alone: Apple documents that property as nil until the virtual device is + /// used in a RUNNING session, and the first capabilities exchange happens + /// before the session starts — a check on it alone advertised + /// `supports_manual_exposure = false` from every modern iPhone. + private func manualExposureLensLocked(for device: AVCaptureDevice) -> AVCaptureDevice? { + if device.isExposureModeSupported(.custom) { return device } + guard device.isVirtualDevice else { return nil } + if let active = device.activePrimaryConstituent, active.isExposureModeSupported(.custom) { + return active + } + let candidates = device.constituentDevices.filter { $0.isExposureModeSupported(.custom) } + return candidates.first { $0.deviceType == .builtInWideAngleCamera } ?? candidates.first } /// The ONLY place a manual-exposure lens swap happens: entering Manual on @@ -1329,9 +1350,7 @@ final class CaptureEngine: NSObject, AVCapturePhotoCaptureDelegate { switch exposureIntent { case .manual: guard !device.isExposureModeSupported(.custom), - device.isVirtualDevice, - let physical = device.activePrimaryConstituent, - physical.isExposureModeSupported(.custom) else { return } + let physical = manualExposureLensLocked(for: device) else { return } manualExposureRestoreDeviceID = device.uniqueID debugLog("🌗 EXPOSURE: manual on virtual \(device.localizedName) — hopping to \(physical.localizedName)") _ = try? swapToDeviceLocked(physical, orientation: orientation) diff --git a/RemoteCam/MonitorChrome.swift b/RemoteCam/MonitorChrome.swift index aa35dff3..01507756 100644 --- a/RemoteCam/MonitorChrome.swift +++ b/RemoteCam/MonitorChrome.swift @@ -111,12 +111,25 @@ enum MonitorTray { } if supportsCameraStandby { items.append(.cameraStandby) } + // Pro controls are a capability of the connected camera: a peer that + // never advertised them would ignore the commands, so no tile. if showsProControls { items.append(.proControls) } items.append(.settings) items.append(.help) return items } + + /// The PRO tile exists only when the connected camera offers something for + /// it to control: manual exposure in any mode, Cinematic in video modes. + static func showsProControls(for state: MonitorUIState, + supportsManualExposure: Bool, + supportsCinematicVideo: Bool, + flagEnabled: Bool = FeatureFlags.ENABLE_PRO_CONTROLS) -> Bool { + guard flagEnabled else { return false } + let videoish = state == .videoMode || state == .videoRecording + return supportsManualExposure || (videoish && supportsCinematicVideo) + } } // MARK: - Link health diff --git a/RemoteCam/MonitorView.swift b/RemoteCam/MonitorView.swift index ac9d7e9a..c05d33c1 100644 --- a/RemoteCam/MonitorView.swift +++ b/RemoteCam/MonitorView.swift @@ -389,13 +389,10 @@ struct MonitorView: View { } } - /// The PRO tile exists only when the connected camera offers something for - /// it to control (manual exposure anywhere; Cinematic in video mode). private var showsProControls: Bool { - guard FeatureFlags.ENABLE_PRO_CONTROLS else { return false } - let videoish = viewModel.uiState == .videoMode || viewModel.uiState == .videoRecording - return viewModel.supportsManualExposure - || (videoish && viewModel.supportsCinematicVideo) + MonitorTray.showsProControls(for: viewModel.uiState, + supportsManualExposure: viewModel.supportsManualExposure, + supportsCinematicVideo: viewModel.supportsCinematicVideo) } private var proPanelLayer: some View { diff --git a/RemoteCamTests/MonitorChromeTests.swift b/RemoteCamTests/MonitorChromeTests.swift index c6dd69d8..8b9364ee 100644 --- a/RemoteCamTests/MonitorChromeTests.swift +++ b/RemoteCamTests/MonitorChromeTests.swift @@ -159,13 +159,15 @@ final class MonitorChromeTests: XCTestCase { supportsHDR: Bool = false, supportsCameraStandby: Bool = false, resolutionCount: Int = 1, - frameRateCount: Int = 1) -> [MonitorTrayItem] { + frameRateCount: Int = 1, + showsProControls: Bool = false) -> [MonitorTrayItem] { MonitorTray.items(for: state, supportsHEIF: supportsHEIF, supportsHDR: supportsHDR, supportsCameraStandby: supportsCameraStandby, resolutionCount: resolutionCount, - frameRateCount: frameRateCount) + frameRateCount: frameRateCount, + showsProControls: showsProControls) } /// A camera with no optional capabilities gets the irreducible tray. @@ -229,6 +231,46 @@ final class MonitorChromeTests: XCTestCase { XCTAssertEqual(tiles, [.timer, .aspect, .cameraStandby, .settings, .help]) } + // MARK: - Pro controls + + /// Same rule as standby: a peer that never advertised the capability + /// would ignore SetExposure/SetCinematic, so the tile is not offered. + func testProTileIsHiddenWhenPeerDoesNotSupportIt() { + for state in [MonitorUIState.photoMode, .videoMode, .videoRecording, .shortsMode] { + XCTAssertFalse(items(state).contains(.proControls), + "\(state) offered pro controls to a peer that can't do them") + } + } + + /// The tile sits between standby and Settings, in every mode, and stays + /// composed while recording (the panel explains what recording locks). + func testProTileSitsBetweenStandbyAndSettings() { + XCTAssertEqual(items(.photoMode, supportsCameraStandby: true, showsProControls: true), + [.timer, .aspect, .cameraStandby, .proControls, .settings, .help]) + XCTAssertEqual(items(.videoRecording, showsProControls: true), + [.timer, .aspect, .proControls, .settings, .help]) + XCTAssertEqual(items(.shortsMode, showsProControls: true), + [.aspect, .proControls, .settings, .help]) + } + + /// Manual exposure earns the tile in every mode; Cinematic is a video + /// effect and earns it only in video modes; the flag gates everything. + func testProTileDerivation() { + func shows(_ state: MonitorUIState, manual: Bool, cinematic: Bool, flag: Bool = true) -> Bool { + MonitorTray.showsProControls(for: state, supportsManualExposure: manual, + supportsCinematicVideo: cinematic, flagEnabled: flag) + } + for state in [MonitorUIState.photoMode, .videoMode, .videoRecording, .shortsMode] { + XCTAssertTrue(shows(state, manual: true, cinematic: false), "\(state) hid manual exposure") + XCTAssertFalse(shows(state, manual: false, cinematic: false), "\(state) showed a tile with nothing to control") + XCTAssertFalse(shows(state, manual: true, cinematic: true, flag: false), "\(state) ignored the feature flag") + } + XCTAssertTrue(shows(.videoMode, manual: false, cinematic: true)) + XCTAssertTrue(shows(.videoRecording, manual: false, cinematic: true)) + XCTAssertFalse(shows(.photoMode, manual: false, cinematic: true), "Cinematic is not a photo control") + XCTAssertFalse(shows(.shortsMode, manual: false, cinematic: true)) + } + /// Settings and Help are the tray's floor — they are how the viewfinder /// gives up its nav bar. func testEveryModeOffersSettingsAndHelp() { From a2d9d14739165ed1729ead00c93b3536dd5a04ee Mon Sep 17 00:00:00 2001 From: Dario Lencina Date: Wed, 26 Aug 2026 22:14:55 -0700 Subject: [PATCH 05/14] Pro controls on the multicam director; tap a traffic row to see the message MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A single camera lands on the multicam director (MULTICAM_FOR_SINGLE_CAMERA), whose tray rendered nothing for .proControls — so the PRO tile only ever existed on the classic 1:1 monitor nobody reaches. The director now offers the tile for the FOCUSED camera (like torch and zoom): CameraLink carries that camera's echoed exposure/Cinematic truth, RigTray lists the tile when the focused lane advertised a capability, and MulticamController sends SetExposure/SetCinematic only to a camera that advertised them. The rig's photo/video mode is pushed to cameras (SyncMonitorSettings, incl. late joiners) so Cinematic is not refused as photo-mode. ProControlsPanel takes plain values so both monitors share it. Debug console: TrafficEntry carries a reflective MessageDump of the message; tapping a command row unfolds its fields, so a capabilities message shows supportsManualExposure / exposure ranges on the device. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01K5uHRuiiLu5FEBiHZkBWo3 --- Docs/pro-controls.md | 21 +++- RemoteCam/CameraLink.swift | 10 ++ RemoteCam/FeatureFlags.swift | 11 +- RemoteCam/MultiCamChrome.swift | 17 ++- RemoteCam/MulticamController.swift | 105 +++++++++++++++++ RemoteCam/MulticamView.swift | 50 +++++++- RemoteCam/MulticamViewController.swift | 3 + RemoteCam/MulticamViewModel.swift | 24 ++++ RemoteCam/ProControlsPanel.swift | 48 ++++++-- RemoteCam/SessionDebugConsole.swift | 113 +++++++++++++++++- RemoteCamTests/MessageDumpTests.swift | 60 ++++++++++ RemoteCamTests/MultiCamChromeTests.swift | 11 ++ RemoteCamTests/MulticamControllerTests.swift | 114 +++++++++++++++++++ RemoteCamTests/MulticamViewModelTests.swift | 27 ++++- RemoteShutter.xcodeproj/project.pbxproj | 4 + 15 files changed, 588 insertions(+), 30 deletions(-) create mode 100644 RemoteCamTests/MessageDumpTests.swift diff --git a/Docs/pro-controls.md b/Docs/pro-controls.md index 51620adf..bd49f9ad 100644 --- a/Docs/pro-controls.md +++ b/Docs/pro-controls.md @@ -277,9 +277,19 @@ Follows the HIG for camera controls: values a photographer recognizes, direct and reversible adjustments, current state always visible on both devices. **Remote (monitor)** -- Two chrome buttons, each shown only when its capability is advertised: - **Exposure** (`dial.medium`) and **Cinematic** (`camera.aperture`, video - mode only). Each opens the same bottom panel used for quality settings. +- One **PRO** tray tile (`camera.aperture`), listed only when the connected + camera advertised something for it to control (`MonitorTray.showsProControls`: + manual exposure in any mode, Cinematic in video modes). It opens the + `ProControlsPanel`, a bottom panel in the tray's presentation. +- The same tile and panel exist on the **multicam director** — the screen a + single camera lands on while `MULTICAM_FOR_SINGLE_CAMERA` is on. There the + controls drive the **focused** camera, like torch and zoom: the tile + follows that camera's flags, `CameraLink` carries its echoed + `ExposureState`/`CinematicState`, and the command carries the lane + (`MulticamController.setExposure(_:on:)` / `setCinematic(_:on:)`, gated on + that camera's capabilities). The director's photo/video mode is pushed to + every camera (`SyncMonitorSettings`, including late joiners) so a camera + knows it is in video mode before Cinematic is asked of it. - **Exposure**: segmented `Auto | Manual`. Auto shows a live readout of what the camera is choosing (`1/120 · ISO 64`). Manual shows two **detented horizontal dials** (`ProDial`): shutter at standard stops (1/8000 … ¼, ⅓, @@ -307,9 +317,8 @@ and reversible adjustments, current state always visible on both devices. in `CameraViewModel` but persists until the control is off. The "too dark" hint also shows here, next to the chip. -**Watch** — untouched. **Multicam director** — untouched; cameras simply -advertise the flags and the 1:1 path works. Broadcasting to N cameras is a -follow-up. +**Watch** — untouched. **Multicam director** — per focused camera (above); +broadcasting one setting to N cameras is a follow-up. ## Monetization diff --git a/RemoteCam/CameraLink.swift b/RemoteCam/CameraLink.swift index eb4770f5..6c809dc0 100644 --- a/RemoteCam/CameraLink.swift +++ b/RemoteCam/CameraLink.swift @@ -100,6 +100,12 @@ final class CameraLink { var zoomStops: [CGFloat] = [1.0] var wideAngleZoomFactor: CGFloat = 1.0 + /// This camera's exposure / Cinematic truth (issue #206): seeded from its + /// capabilities, replaced by every `SetExposureResp` / `SetCinematicResp` + /// echo. The panel shows these, never the value that was requested. + var exposure: ExposureState? + var cinematic: CinematicState? + init(peerID: MCPeerID) { self.peerID = peerID self.displayName = peerID.displayName @@ -125,6 +131,10 @@ final class CameraLink { $0.frontCamera != nil && $0.backCamera != nil } ?? false, supportsFocusPoint: capabilities?.supportsFocusPoint ?? false, + supportsManualExposure: capabilities?.supportsManualExposure ?? false, + exposure: exposure, + supportsCinematicVideo: capabilities?.supportsCinematicVideo ?? false, + cinematic: cinematic, hasTorch: capabilities?.getCurrentCameraInfo()?.hasTorch ?? false, zoomFactor: zoomFactor, maxZoomFactor: maxZoomFactor, diff --git a/RemoteCam/FeatureFlags.swift b/RemoteCam/FeatureFlags.swift index eafada3b..f4cd70f1 100644 --- a/RemoteCam/FeatureFlags.swift +++ b/RemoteCam/FeatureFlags.swift @@ -31,15 +31,16 @@ struct FeatureFlags { /// one-time buy, and the entitlement code stays in place for when it flips on. static let ENABLE_PRO_SUBSCRIPTION = false - /// Multicam director mode: one monitor controlling several cameras with - /// synced capture. Off until the feature ships (target 9.1.0); while off, - /// cameras advertise `supports_multicam=false` and the scanner keeps its - /// single-camera flow. /// Pro controls (issue #206): manual shutter/ISO + Cinematic video from - /// the monitor. Gates only the monitor UI; the wire capability is always + /// the remote — on both the 1:1 monitor and the multicam director (the + /// screen a single camera lands on while `MULTICAM_FOR_SINGLE_CAMERA` is + /// on). Gates only the remote's UI; the wire capability is always /// advertised (harmless without a control). static let ENABLE_PRO_CONTROLS = true + /// Multicam director mode: one monitor controlling several cameras with + /// synced capture. While off, cameras advertise `supports_multicam=false` + /// and the scanner keeps its single-camera flow. static let ENABLE_MULTICAM = true /// Route a single connected camera to the multicam director too, instead diff --git a/RemoteCam/MultiCamChrome.swift b/RemoteCam/MultiCamChrome.swift index 9551530c..bed29e2c 100644 --- a/RemoteCam/MultiCamChrome.swift +++ b/RemoteCam/MultiCamChrome.swift @@ -58,7 +58,8 @@ enum RigTray { /// Format/HDR stay listed when blocked: the intersection model greys them /// and names the blocking camera in the footnote instead. Aspect, like the /// 1:1 tray's, shows in both modes — every camera can crop. - static func items(mode: MonitorMode, standbyAvailable: Bool) -> [MonitorTrayItem] { + static func items(mode: MonitorMode, standbyAvailable: Bool, + showsProControls: Bool = false) -> [MonitorTrayItem] { var items: [MonitorTrayItem] = [.timer, .aspect] switch mode { @@ -69,8 +70,22 @@ enum RigTray { } if standbyAvailable { items.append(.cameraStandby) } + // Pro controls drive the FOCUSED camera (like torch and zoom), so the + // tile follows that camera's advertised capabilities — same slot as + // the 1:1 tray. + if showsProControls { items.append(.proControls) } items.append(.settings) items.append(.help) return items } } + +extension MonitorMode { + /// The camera-side vocabulary for `RemoteCmd.SyncMonitorSettings`. + var recordingMode: RecordingMode { + switch self { + case .photo: return .Photo + case .video: return .Video + } + } +} diff --git a/RemoteCam/MulticamController.swift b/RemoteCam/MulticamController.swift index 6429e5df..32c77251 100644 --- a/RemoteCam/MulticamController.swift +++ b/RemoteCam/MulticamController.swift @@ -67,6 +67,12 @@ struct MulticamLaneInfo: Equatable { /// This camera can focus at a point — gates the viewfinder's focus tap so /// the user never gets a reticle (or a paywall) for a camera that can't. let supportsFocusPoint: Bool + /// Pro controls (issue #206) for this camera: what it advertised and its + /// current echoed truth. The PRO tile follows the focused lane's flags. + let supportsManualExposure: Bool + let exposure: ExposureState? + let supportsCinematicVideo: Bool + let cinematic: CinematicState? /// This camera's current device has a torch (front cameras don't) — gates /// the torch glyph when this lane is focused. let hasTorch: Bool @@ -221,6 +227,10 @@ public actor MulticamController { /// intersection: it fans to every lane and is re-applied to late joiners. /// 16:9 is the cameras' own default. private var activeAspectRatio: AspectRatio = .sixteenNine + /// The rig's photo/video mode as the director last set it — pushed to + /// every camera (and to late joiners) via `SyncMonitorSettings`. + private var rigMode: MonitorMode = .photo + /// The rig-wide camera-preview mode: standby blanks each camera's own /// on-screen preview (the director is the viewfinder; capture and the /// streamed frames are unaffected). Sent only to cameras that advertised @@ -472,6 +482,15 @@ public actor MulticamController { // A drag, not a press — firehose level. logDebug("director: zoom \(z.factor) → \(z.target.displayName)") handleSetZoom(z.factor, target: z.target) + case let m as MCSetExposure: + logInfo("director: exposure \(m.intent) → \(m.target.displayName)") + handleSetExposure(m.intent, target: m.target) + case let m as MCSetCinematic: + logInfo("director: cinematic \(m.intent) → \(m.target.displayName)") + handleSetCinematic(m.intent, target: m.target) + case let m as MCSetRigMode: + logInfo("director: mode → \(m.mode)") + handleSetRigMode(m.mode) case is MCCapturePhoto: logInfo("director: shutter tap (photo)") handleCapturePhoto() @@ -587,6 +606,8 @@ public actor MulticamController { logInfo("director: caps from \(link.displayName) — torch=\(caps.getCurrentCameraInfo()?.hasTorch ?? false), camera=\(caps.currentCamera)") link.capabilities = caps seedZoom(link, from: caps) + link.exposure = caps.exposure + link.cinematic = caps.cinematic if link.status != .failed { link.status = .linked } // A late joiner may not match the running rig quality: flag it (its // tile badges + the tray offers re-match) rather than silently @@ -608,6 +629,11 @@ public actor MulticamController { if activeAspectRatio != .sixteenNine { sendTo(peer, RemoteCmd.SetAspectRatio(aspectRatio: activeAspectRatio)) } + // And the rig's mode: cameras open in photo mode; one joining a + // video rig is told so (Cinematic is refused in photo mode). + if rigMode != .photo { + sendTo(peer, RemoteCmd.SyncMonitorSettings(mode: rigMode.recordingMode)) + } case let resp as RemoteCmd.ToggleCameraResp: // The focused camera flipped front/back (or picked a device — the @@ -624,8 +650,21 @@ public actor MulticamController { if let caps = resp.cameraCapabilities { link.capabilities = caps seedZoom(link, from: caps) + link.exposure = caps.exposure + link.cinematic = caps.cinematic } + case let resp as RemoteCmd.SetExposureResp: + // The camera's exposure truth after our request — applied, + // clamped, or refused (mode Auto + error). Only this echo moves + // the panel's dials. + if let state = resp.state { link.exposure = state } + surfaceRefusal(resp.error, what: "exposure", on: link) + + case let resp as RemoteCmd.SetCinematicResp: + if let state = resp.state { link.cinematic = state } + surfaceRefusal(resp.error, what: "Cinematic", on: link) + case let resp as RemoteCmd.SetZoomResp: // The focused camera settled on a zoom; reflect its factor and range // on that lane so the pill's thumb and ceiling track the hardware. @@ -866,6 +905,52 @@ public actor MulticamController { sendTo(peer, RemoteCmd.SwitchLens(lensType: lens)) } + // MARK: Pro controls (issue #206) — one camera at a time, like zoom + + /// Manual exposure / Cinematic on one camera. Same gate as focus: the + /// command is dropped unless that camera advertised the capability, so a + /// peer that would ignore (or misread) it is never sent one. + public nonisolated func setExposure(_ intent: ExposureIntent, on peer: MCPeerID) { + tell(MCSetExposure(intent, target: peer)) + } + public nonisolated func setCinematic(_ intent: CinematicIntent, on peer: MCPeerID) { + tell(MCSetCinematic(intent, target: peer)) + } + + private func handleSetExposure(_ intent: ExposureIntent, target: MCPeerID) { + guard links[target]?.capabilities?.supportsManualExposure == true else { return } + sendTo(target, RemoteCmd.SetExposure(intent: intent)) + } + + private func handleSetCinematic(_ intent: CinematicIntent, target: MCPeerID) { + guard links[target]?.capabilities?.supportsCinematicVideo == true else { return } + sendTo(target, RemoteCmd.SetCinematic(intent: intent)) + } + + /// A refused pro-control request is said out loud (the same toast a + /// refused camera switch uses); the echo already reset the panel. + private func surfaceRefusal(_ error: Error?, what: String, on link: CameraLink) { + guard let error else { return } + logWarning("director: \(what) on \(link.displayName) refused — \(error._domain)") + let display = display + let message = "\(link.displayName): \(error._domain)" + OperationQueue.main.addOperation { display?.showTransientError(message) } + } + + /// The rig's photo/video mode is a setting the cameras are told about, + /// like standby and aspect: each camera's own screen follows the + /// director, and Cinematic (a video effect) is only accepted by a camera + /// that knows it is in video mode. + nonisolated func setRigMode(_ mode: MonitorMode) { tell(MCSetRigMode(mode)) } + + private func handleSetRigMode(_ mode: MonitorMode) { + guard rigMode != mode else { return } + rigMode = mode + for peer in order where links[peer]?.status == .linked { + sendTo(peer, RemoteCmd.SyncMonitorSettings(mode: mode.recordingMode)) + } + } + public nonisolated func setFocusedPeer(_ peer: MCPeerID) { tell(MCPeerCommand(.focus, peer)) } private func handleSetFocusedPeer(_ peer: MCPeerID) { @@ -1835,6 +1920,26 @@ final class MCSetZoom: Message, @unchecked Sendable { super.init(sender: nil) } } +final class MCSetExposure: Message, @unchecked Sendable { + let intent: ExposureIntent + let target: MCPeerID + init(_ intent: ExposureIntent, target: MCPeerID) { + self.intent = intent; self.target = target + super.init(sender: nil) + } +} +final class MCSetCinematic: Message, @unchecked Sendable { + let intent: CinematicIntent + let target: MCPeerID + init(_ intent: CinematicIntent, target: MCPeerID) { + self.intent = intent; self.target = target + super.init(sender: nil) + } +} +final class MCSetRigMode: Message, @unchecked Sendable { + let mode: MonitorMode + init(_ mode: MonitorMode) { self.mode = mode; super.init(sender: nil) } +} final class MCSetVideoQuality: Message, @unchecked Sendable { let resolution: VideoResolution diff --git a/RemoteCam/MulticamView.swift b/RemoteCam/MulticamView.swift index 2d74c9c2..f4e8f088 100644 --- a/RemoteCam/MulticamView.swift +++ b/RemoteCam/MulticamView.swift @@ -58,6 +58,11 @@ struct MulticamView: View { /// Tap-to-focus on the named camera (normalized upright coords; the host /// gates on the IAP, the controller on the peer's advertised support). let onFocusTap: (CameraLane, CGPoint) -> Void + /// Pro controls on the named camera (the focused one — the panel is + /// rendered for it, and the command carries it, like every per-camera + /// control here). + let onExposureChange: (CameraLane, ExposureIntent) -> Void + let onCinematicChange: (CameraLane, CinematicIntent) -> Void /// Leave the director screen, back to the scanner (links stay up; the /// scanner re-arms and re-selects the still-connected cameras). let onBack: () -> Void @@ -86,12 +91,14 @@ struct MulticamView: View { countdownOverlay transientErrorToast if viewModel.showingRigTray { rigTrayLayer } + if viewModel.showingProPanel { proPanelLayer } #if DEBUG SessionDebugOverlay() #endif } .animation(.spring(response: 0.32, dampingFraction: 0.85), value: viewModel.showingRigTray) + .animation(.spring(response: 0.32, dampingFraction: 0.85), value: viewModel.showingProPanel) .sheet(isPresented: $viewModel.showingAddCamera) { AddCameraSheet(peers: viewModel.availablePeers, onInvite: onInviteCamera) } @@ -131,6 +138,11 @@ struct MulticamView: View { onSetHDR: onSetHDR, onSetAspectRatio: onSetAspectRatio, onSetStandby: onSetStandby, + showsProControls: viewModel.showsProControls, + onOpenProControls: { + viewModel.showingRigTray = false + viewModel.showingProPanel = true + }, // Close the tray first, as the 1:1 tray does — the // sheet returns to a clean viewfinder. onOpenSettings: { @@ -145,6 +157,27 @@ struct MulticamView: View { } } + /// The focused camera's pro-controls panel, in the tray's presentation. + /// Values are that camera's echoed truth; intents carry the lane. + @ViewBuilder + private var proPanelLayer: some View { + if let focused = viewModel.focusedLane { + ZStack(alignment: .bottom) { + Color.black.opacity(0.02) + .ignoresSafeArea() + .onTapGesture { viewModel.showingProPanel = false } + ProControlsPanel(supportsManualExposure: focused.supportsManualExposure, + exposure: focused.exposure, + supportsCinematicVideo: focused.supportsCinematicVideo, + cinematic: focused.cinematic, + isVideoMode: viewModel.mode == .video, + onExposureChange: { onExposureChange(focused, $0) }, + onCinematicChange: { onCinematicChange(focused, $0) }) + .transition(.move(edge: .bottom)) + } + } + } + // MARK: - Chrome (mirrors MonitorView's chrome, slot for slot) /// Everything floating over the preview, arranged for the docked edge — @@ -814,6 +847,9 @@ struct RigTrayPanel: View { let onSetAspectRatio: (AspectRatio) -> Void /// Rig standby: blank (or wake) every supporting camera's own preview. let onSetStandby: (Bool) -> Void + /// The focused camera offers pro controls; tapping the tile opens them. + var showsProControls: Bool = false + var onOpenProControls: () -> Void = {} /// Open the app's Settings sheet (purchases, restore, preferences). let onOpenSettings: () -> Void /// Open the help sheet (the same one every screen presents). @@ -823,7 +859,8 @@ struct RigTrayPanel: View { var body: some View { TrayPanelShell(footnote: settings.blockerFootnote(for: mode)) { - ForEach(RigTray.items(mode: mode, standbyAvailable: settings.standbyAvailable), + ForEach(RigTray.items(mode: mode, standbyAvailable: settings.standbyAvailable, + showsProControls: showsProControls), id: \.self) { item in tile(for: item) } @@ -872,10 +909,15 @@ struct RigTrayPanel: View { MonitorTrayTile(item: .help, value: nil, isActive: false, isEnabled: true, action: onOpenHelp) - case .frameRate, .proControls: + case .proControls: + // Usable mid-recording: the panel itself explains what recording + // locks (aperture), exactly as the 1:1 tray does. + MonitorTrayTile(item: .proControls, value: nil, + isActive: false, isEnabled: true, + action: onOpenProControls) + case .frameRate: // Not offered by `RigTray.items` — frame rate rides the single - // quality tile's intersection cycle, and pro controls are a 1:1 - // monitor feature (multicam broadcast is a tracked follow-up). + // quality tile's intersection cycle. EmptyView() } } diff --git a/RemoteCam/MulticamViewController.swift b/RemoteCam/MulticamViewController.swift index 23317883..fdae1c41 100644 --- a/RemoteCam/MulticamViewController.swift +++ b/RemoteCam/MulticamViewController.swift @@ -60,6 +60,7 @@ public final class MulticamViewController: UIViewController { self.viewModel.rigSettings.countdown == nil else { return } self.viewModel.mode = self.viewModel.mode == .photo ? .video : .photo logInfo("director: mode → \(self.viewModel.mode)") + self.controller.setRigMode(self.viewModel.mode) }, onAddCamera: { [weak self] in self?.handleAddCameraTapped() }, onInviteCamera: { [weak self] peer in @@ -98,6 +99,8 @@ public final class MulticamViewController: UIViewController { onDisconnectCamera: { [weak self] lane in self?.controller.disconnectCamera(lane.peerID) }, onZoomChange: { [weak self] lane, factor in self?.handleZoomChange(factor, on: lane.peerID) }, onFocusTap: { [weak self] lane, point in self?.handleFocusTap(point, on: lane.peerID) }, + onExposureChange: { [weak self] lane, intent in self?.controller.setExposure(intent, on: lane.peerID) }, + onCinematicChange: { [weak self] lane, intent in self?.controller.setCinematic(intent, on: lane.peerID) }, onBack: { [weak self] in logInfo("director: back → scanner") self?.navigationController?.popViewController(animated: true) diff --git a/RemoteCam/MulticamViewModel.swift b/RemoteCam/MulticamViewModel.swift index 9755090f..a8dd98d7 100644 --- a/RemoteCam/MulticamViewModel.swift +++ b/RemoteCam/MulticamViewModel.swift @@ -38,6 +38,10 @@ final class CameraLane: ObservableObject, Identifiable { var collection: CameraLink.LaneCollectionState { info.collection } var canFlipCamera: Bool { info.canFlipCamera } var supportsFocusPoint: Bool { info.supportsFocusPoint } + var supportsManualExposure: Bool { info.supportsManualExposure } + var exposure: ExposureState? { info.exposure } + var supportsCinematicVideo: Bool { info.supportsCinematicVideo } + var cinematic: CinematicState? { info.cinematic } var zoomFactor: CGFloat { info.zoomFactor } var zoomScale: ZoomScale { ZoomScale(stops: info.zoomStops, @@ -90,6 +94,8 @@ final class MulticamViewModel: ObservableObject { @Published var rigSettings = RigSettingsSnapshot() /// Whether the rig settings tray is showing. @Published var showingRigTray: Bool = false + /// Whether the focused camera's pro-controls panel is showing. + @Published var showingProPanel: Bool = false /// A brief, non-blocking error readout (a refused camera switch, e.g.). /// The toast that renders it clears it after a few seconds. Each report /// carries its own identity — same pattern as `FocusReticle` — so a @@ -131,6 +137,24 @@ final class MulticamViewModel: ObservableObject { displayMode == .focus && (focusedLane?.hasTorch ?? false) } + /// The PRO tile and panel drive the FOCUSED camera (like torch and zoom), + /// so they follow that camera's advertised capabilities, under the 1:1 + /// tray's rule: manual exposure in any mode, Cinematic in video only. + var showsProControls: Bool { + guard let focused = focusedLane, focused.status == .linked else { return false } + return MonitorTray.showsProControls(for: monitorUIState, + supportsManualExposure: focused.supportsManualExposure, + supportsCinematicVideo: focused.supportsCinematicVideo) + } + + /// The director's mode in the 1:1 monitor's vocabulary, for shared rules. + var monitorUIState: MonitorUIState { + switch mode { + case .photo: return .photoMode + case .video: return isRecording ? .videoRecording : .videoMode + } + } + /// The focused camera's zoom scale and current factor for the pill; and /// whether the pill should show at all (the scale collapses to a single /// point on a fixed-focal-length camera, exactly as the 1:1 monitor hides diff --git a/RemoteCam/ProControlsPanel.swift b/RemoteCam/ProControlsPanel.swift index ff85062e..438d4a44 100644 --- a/RemoteCam/ProControlsPanel.swift +++ b/RemoteCam/ProControlsPanel.swift @@ -4,21 +4,51 @@ // // The pro-controls bottom panel on the monitor: manual exposure (shutter + // ISO) and Cinematic video (iOS 26 simulated aperture). Every value shown is -// the CAMERA's echoed truth (`MonitorViewModel.exposure` / `.cinematic`) — -// the panel never displays a value the camera did not confirm. Controls are -// rendered only when the connected camera advertised the capability. +// the CAMERA's echoed truth — the panel never displays a value the camera +// did not confirm. Controls are rendered only when the camera advertised the +// capability. Pure values in, intents out: the 1:1 monitor feeds it from +// `MonitorViewModel`, the multicam director from the focused lane. // See Docs/pro-controls.md. // import SwiftUI struct ProControlsPanel: View { - @ObservedObject var viewModel: MonitorViewModel + let supportsManualExposure: Bool + let exposure: ExposureState? + let supportsCinematicVideo: Bool + let cinematic: CinematicState? + /// Cinematic is a video effect: its section exists only in video modes. + let isVideoMode: Bool let onExposureChange: (ExposureIntent) -> Void let onCinematicChange: (CinematicIntent) -> Void - private var isVideoMode: Bool { - viewModel.uiState == .videoMode || viewModel.uiState == .videoRecording + init(supportsManualExposure: Bool, exposure: ExposureState?, + supportsCinematicVideo: Bool, cinematic: CinematicState?, + isVideoMode: Bool, + onExposureChange: @escaping (ExposureIntent) -> Void, + onCinematicChange: @escaping (CinematicIntent) -> Void) { + self.supportsManualExposure = supportsManualExposure + self.exposure = exposure + self.supportsCinematicVideo = supportsCinematicVideo + self.cinematic = cinematic + self.isVideoMode = isVideoMode + self.onExposureChange = onExposureChange + self.onCinematicChange = onCinematicChange + } + + /// The 1:1 monitor's projection. `MonitorView` observes the view model, + /// so the panel is rebuilt with fresh values on every echo. + init(viewModel: MonitorViewModel, + onExposureChange: @escaping (ExposureIntent) -> Void, + onCinematicChange: @escaping (CinematicIntent) -> Void) { + self.init(supportsManualExposure: viewModel.supportsManualExposure, + exposure: viewModel.exposure, + supportsCinematicVideo: viewModel.supportsCinematicVideo, + cinematic: viewModel.cinematic, + isVideoMode: viewModel.uiState == .videoMode || viewModel.uiState == .videoRecording, + onExposureChange: onExposureChange, + onCinematicChange: onCinematicChange) } var body: some View { @@ -27,12 +57,12 @@ struct ProControlsPanel: View { .fill(Color.white.opacity(0.3)) .frame(width: 36, height: 5) - if viewModel.supportsManualExposure, let exposure = viewModel.exposure { + if supportsManualExposure, let exposure { exposureSection(exposure) } - if isVideoMode, viewModel.supportsCinematicVideo, let cinematic = viewModel.cinematic { - if viewModel.supportsManualExposure { + if isVideoMode, supportsCinematicVideo, let cinematic { + if supportsManualExposure { Divider().overlay(Color.white.opacity(0.15)) } cinematicSection(cinematic) diff --git a/RemoteCam/SessionDebugConsole.swift b/RemoteCam/SessionDebugConsole.swift index 7d57c089..39462a38 100644 --- a/RemoteCam/SessionDebugConsole.swift +++ b/RemoteCam/SessionDebugConsole.swift @@ -15,7 +15,8 @@ // handler anywhere knows it exists. // - `SessionDebugOverlay` renders the collected picture: local session // state, connected devices, the latest CameraStateReport per peer, and a -// rolling command log (frames filtered — they'd drown everything). +// rolling command log (frames filtered — they'd drown everything). Tap a +// command row to see the message's fields (`MessageDump`). // // Copyright © 2026 Security Union LLC. All rights reserved. // @@ -23,6 +24,88 @@ import Combine import SwiftUI +// MARK: - Message dump (always compiled; pure, so it is unit-tested) + +/// Renders any `Message` as "field: value" lines by reflection, so every +/// command — present and future — is inspectable in the console without a +/// per-type describer. Nested payloads (capabilities, exposure state) indent; +/// arrays show their count then their elements; shutter durations also show +/// as a fraction so `0.008` reads as `1/125`. +enum MessageDump { + /// Nesting past this shows a one-line summary instead of more fields. + static let maxDepth = 4 + + static func describe(_ message: Message) -> String { + let lines = fields(of: message, indent: 0) + return lines.isEmpty ? "(no fields)" : lines.joined(separator: "\n") + } + + /// One line per stored property, walking up the class chain (payloads + /// subclass `Message`); the `sender` plumbing is not a field of interest. + private static func fields(of value: Any, indent: Int) -> [String] { + var lines: [String] = [] + var mirror: Mirror? = Mirror(reflecting: value) + while let current = mirror { + for child in current.children { + guard let label = child.label, label != "sender" else { continue } + lines += render(label: label, value: child.value, indent: indent) + } + mirror = current.superclassMirror + } + return lines + } + + private static func render(label: String, value: Any, indent: Int) -> [String] { + let pad = String(repeating: " ", count: indent) + if isScalar(value) { return ["\(pad)\(label): \(scalar(value))"] } + + let mirror = Mirror(reflecting: value) + switch mirror.displayStyle { + case .optional: + guard let inner = mirror.children.first?.value else { return ["\(pad)\(label): nil"] } + return render(label: label, value: inner, indent: indent) + case .collection, .set: + let items = mirror.children.map(\.value) + guard !items.isEmpty else { return ["\(pad)\(label): []"] } + var out = ["\(pad)\(label): [\(items.count)]"] + guard indent < maxDepth else { return out } + for (index, item) in items.enumerated() { + out += render(label: "[\(index)]", value: item, indent: indent + 1) + } + return out + case .struct, .class: + guard indent < maxDepth else { return ["\(pad)\(label): \(scalar(value))"] } + let nested = fields(of: value, indent: indent + 1) + guard !nested.isEmpty else { return ["\(pad)\(label): \(scalar(value))"] } + return ["\(pad)\(label):"] + nested + default: + // Enums (associated values print via description), tuples, ObjC. + return ["\(pad)\(label): \(scalar(value))"] + } + } + + private static func isScalar(_ value: Any) -> Bool { + value is any BinaryInteger || value is any BinaryFloatingPoint + || value is Bool || value is String || value is Date || value is UUID + } + + private static func scalar(_ value: Any) -> String { + switch value { + case let double as Double: return number(double) + case let float as Float: return number(Double(float)) + case let cgFloat as CGFloat: return number(Double(cgFloat)) + case let error as Error: return "error(\(error._domain) \(error._code))" + default: return String(describing: value) + } + } + + /// Sub-second values double as a shutter fraction: `0.008 (1/125)`. + private static func number(_ value: Double) -> String { + guard value > 0, value < 0.25 else { return String(describing: value) } + return "\(value) (1/\(Int((1 / value).rounded())))" + } +} + // MARK: - Facade (always compiled; free in Release) enum SessionDebug { @@ -205,6 +288,9 @@ final class SessionDebugLog: ObservableObject, @unchecked Sendable { let kind: Kind let label: String let peerName: String? + /// The message's fields (`MessageDump`), shown when the row is + /// tapped. Empty for lifecycle notes. + var detail: String = "" } private static let trafficCap = 24 @@ -235,7 +321,8 @@ final class SessionDebugLog: ObservableObject, @unchecked Sendable { at: Date(), kind: direction == .sent ? .sent(ok: ok) : .received, label: String(describing: type(of: message)), - peerName: peer) + peerName: peer, + detail: MessageDump.describe(message)) DispatchQueue.main.async { self.traffic.insert(entry, at: 0) if self.traffic.count > Self.trafficCap { self.traffic.removeLast() } @@ -269,6 +356,8 @@ final class SessionDebugLog: ObservableObject, @unchecked Sendable { struct SessionDebugOverlay: View { @ObservedObject private var log = SessionDebugLog.shared @State private var isOpen = false + /// The traffic row whose message fields are unfolded (one at a time). + @State private var expandedEntryID: UUID? private static let clock: DateFormatter = { let formatter = DateFormatter() @@ -359,10 +448,26 @@ struct SessionDebugOverlay: View { private var trafficSection: some View { VStack(alignment: .leading, spacing: 1) { - header("TRAFFIC (frames hidden)") + header("TRAFFIC (frames hidden · tap a command for its fields)") if log.traffic.isEmpty { caption("quiet") } ForEach(log.traffic) { entry in - trafficRow(entry) + VStack(alignment: .leading, spacing: 2) { + trafficRow(entry) + .contentShape(Rectangle()) + .onTapGesture { + guard !entry.detail.isEmpty else { return } + expandedEntryID = expandedEntryID == entry.id ? nil : entry.id + } + if expandedEntryID == entry.id { + Text(entry.detail) + .font(.system(size: 9, design: .monospaced)) + .foregroundColor(.white.opacity(0.85)) + .textSelection(.enabled) + .padding(6) + .frame(maxWidth: .infinity, alignment: .leading) + .background(Color.white.opacity(0.08), in: RoundedRectangle(cornerRadius: 6)) + } + } } } } diff --git a/RemoteCamTests/MessageDumpTests.swift b/RemoteCamTests/MessageDumpTests.swift new file mode 100644 index 00000000..1a54f420 --- /dev/null +++ b/RemoteCamTests/MessageDumpTests.swift @@ -0,0 +1,60 @@ +import XCTest +@testable import RemoteShutter + +/// The debug console's tap-to-inspect view of a message. Reflection-based, so +/// the assertions pin what a reader needs to see for the commands that +/// matter most (capabilities, pro-control intents), not an exact layout. +final class MessageDumpTests: XCTestCase { + + func testCapabilitiesShowProControlFieldsAndNestedState() { + let caps = RemoteCmd.CameraCapabilitiesResp( + frontCamera: nil, backCamera: nil, currentCamera: .back, + currentLens: .wideAngle, currentZoom: 1.0, + supportsManualExposure: true, + exposure: ExposureState(mode: .manual, durationSeconds: 1.0 / 125, iso: 400, + minDurationSeconds: 1.0 / 8000, maxDurationSeconds: 1, + minISO: 32, maxISO: 3200), + supportsCinematicVideo: false, cinematic: nil, error: nil) + + let dump = MessageDump.describe(caps) + XCTAssertTrue(dump.contains("supportsManualExposure: true"), dump) + XCTAssertTrue(dump.contains("supportsCinematicVideo: false"), dump) + XCTAssertTrue(dump.contains("exposure:\n"), "nested state opens its own block\n\(dump)") + XCTAssertTrue(dump.contains(" mode: manual"), dump) + XCTAssertTrue(dump.contains(" iso: 400.0"), dump) + XCTAssertTrue(dump.contains("(1/125)"), "shutter reads as a fraction\n\(dump)") + XCTAssertTrue(dump.contains("cinematic: nil"), dump) + XCTAssertTrue(dump.contains("error: nil"), dump) + XCTAssertFalse(dump.contains("sender"), "plumbing is not a field\n\(dump)") + } + + func testIntentEnumsCarryTheirValues() { + XCTAssertEqual(MessageDump.describe(RemoteCmd.SetExposure(intent: .manual(durationSeconds: 0.5, iso: 100))), + "intent: manual(durationSeconds: 0.5, iso: 100.0)") + XCTAssertEqual(MessageDump.describe(RemoteCmd.SetCinematic(intent: .on(aperture: 2.8))), + "intent: on(aperture: Optional(2.8))") + } + + func testArraysListCountThenElements() { + let caps = RemoteCmd.CameraCapabilitiesResp( + frontCamera: nil, backCamera: nil, currentCamera: .back, + currentLens: .wideAngle, currentZoom: 1.0, + cameraDevices: [RemoteCmd.CameraDeviceEntry(uniqueID: "id-1", localizedName: "Back Camera", + positionRaw: 1, isActive: true, isSuspended: false, + info: nil)], + error: nil) + let dump = MessageDump.describe(caps) + XCTAssertTrue(dump.contains("cameraDevices: [1]"), dump) + XCTAssertTrue(dump.contains(" [0]:\n"), dump) + XCTAssertTrue(dump.contains(" localizedName: Back Camera"), dump) + } + + func testErrorsShowDomainAndCode() { + let resp = RemoteCmd.SetExposureResp(state: nil, error: NSError(domain: "Unsupported", code: 7)) + XCTAssertEqual(MessageDump.describe(resp), "state: nil\nerror: error(Unsupported 7)") + } + + func testMessageWithoutFields() { + XCTAssertEqual(MessageDump.describe(RemoteCmd.ToggleTorch()), "(no fields)") + } +} diff --git a/RemoteCamTests/MultiCamChromeTests.swift b/RemoteCamTests/MultiCamChromeTests.swift index 62df9c55..51522bbe 100644 --- a/RemoteCamTests/MultiCamChromeTests.swift +++ b/RemoteCamTests/MultiCamChromeTests.swift @@ -62,6 +62,17 @@ final class MultiCamChromeTests: XCTestCase { XCTAssertFalse(RigTray.items(mode: .video, standbyAvailable: false).contains(.cameraStandby)) } + /// The PRO tile follows the focused camera's capabilities and sits in the + /// 1:1 tray's slot (after standby, before Settings) in both modes; a rig + /// whose focused camera offers nothing lists no tile. + func testRigTrayProTileFollowsFocusedCamera() { + XCTAssertEqual(RigTray.items(mode: .photo, standbyAvailable: true, showsProControls: true), + [.timer, .aspect, .format, .hdr, .cameraStandby, .proControls, .settings, .help]) + XCTAssertEqual(RigTray.items(mode: .video, standbyAvailable: false, showsProControls: true), + [.timer, .aspect, .resolution, .proControls, .settings, .help]) + XCTAssertFalse(RigTray.items(mode: .video, standbyAvailable: true).contains(.proControls)) + } + func testStreamProfilePresets() { // The focused tier reproduces today's 1:1 peer preview. XCTAssertEqual(StreamProfile.focused.maxLongEdge, 1200) diff --git a/RemoteCamTests/MulticamControllerTests.swift b/RemoteCamTests/MulticamControllerTests.swift index 041ee73b..7adf890f 100644 --- a/RemoteCamTests/MulticamControllerTests.swift +++ b/RemoteCamTests/MulticamControllerTests.swift @@ -148,6 +148,120 @@ final class MulticamControllerTests: XCTestCase { XCTAssertEqual(pings.map(\.peers), [[camA]]) } + // MARK: - Pro controls (issue #206): per-camera, capability-gated + + /// Capabilities advertising manual exposure (and optionally Cinematic), + /// with the exposure truth the panel seeds from. + private func proCaps(cinematic: Bool = false) -> RemoteCmd.CameraCapabilitiesResp { + RemoteCmd.CameraCapabilitiesResp( + frontCamera: nil, backCamera: nil, + currentCamera: .back, currentLens: .wideAngle, currentZoom: 1.0, + supportsMulticam: true, + supportsManualExposure: true, + exposure: ExposureState(mode: .auto, durationSeconds: 1.0 / 120, iso: 64, + minDurationSeconds: 1.0 / 8000, maxDurationSeconds: 1, + minISO: 32, maxISO: 3200), + supportsCinematicVideo: cinematic, + cinematic: cinematic ? CinematicState(enabled: false, simulatedAperture: 2.0, + minSimulatedAperture: 1.4, maxSimulatedAperture: 16, + defaultSimulatedAperture: 2.0, + apertureLocked: false, notEnoughLight: false) : nil, + error: nil) + } + + /// A pro-control command goes only to the camera it was rendered for, + /// and only if that camera advertised the capability — a peer that + /// would ignore (or misread) it is never sent one. + func testExposureAndCinematicAreSentOnlyToAdvertisingCamera() async { + let (controller, transport, _) = await makeController(peers: [camA, camB]) + controller.didReceiveMessage(proCaps(cinematic: true), from: camA) + controller.didReceiveMessage(multicamCaps(), from: camB) + await controller.waitForIdle() + transport.sentMessages.removeAll() + + controller.setExposure(.manual(durationSeconds: 1.0 / 250, iso: 400), on: camA) + controller.setExposure(.manual(durationSeconds: 1.0 / 250, iso: 400), on: camB) + controller.setCinematic(.on(aperture: 2.8), on: camA) + controller.setCinematic(.on(aperture: 2.8), on: camB) + await controller.waitForIdle() + + let exposures = sent(transport, RemoteCmd.SetExposure.self) + XCTAssertEqual(exposures.map(\.peers), [[camA]], "camB never advertised manual exposure") + XCTAssertEqual((exposures.first?.msg as? RemoteCmd.SetExposure)?.intent, + .manual(durationSeconds: 1.0 / 250, iso: 400)) + let cinematics = sent(transport, RemoteCmd.SetCinematic.self) + XCTAssertEqual(cinematics.map(\.peers), [[camA]], "camB never advertised Cinematic") + } + + /// The lane shows the camera's echo — seeded from capabilities, replaced + /// by each response — and a refusal is said out loud, never swallowed. + func testLaneCarriesEchoedExposureAndSurfacesRefusal() async { + let (controller, _, display) = await makeController(peers: [camA]) + controller.didReceiveMessage(proCaps(), from: camA) + await controller.waitForIdle() + + var lane = await controller.lanesForTesting().first + XCTAssertEqual(lane?.supportsManualExposure, true) + XCTAssertEqual(lane?.exposure?.mode, .auto) + XCTAssertEqual(lane?.exposure?.iso, 64) + + let applied = ExposureState(mode: .manual, durationSeconds: 1.0 / 250, iso: 400, + minDurationSeconds: 1.0 / 8000, maxDurationSeconds: 1, + minISO: 32, maxISO: 3200) + controller.didReceiveMessage(RemoteCmd.SetExposureResp(state: applied, error: nil), from: camA) + await controller.waitForIdle() + lane = await controller.lanesForTesting().first + XCTAssertEqual(lane?.exposure, applied) + + controller.didReceiveMessage( + RemoteCmd.SetCinematicResp(state: nil, error: NSError(domain: "Cinematic needs video mode", code: 0)), + from: camA) + await controller.waitForIdle() + await pumpMainUntil { !display.transientErrors.isEmpty } + XCTAssertEqual(display.transientErrors.last, "\(camA.displayName): Cinematic needs video mode") + } + + /// The rig's mode is a setting every camera is told about (like standby + /// and aspect): switching the director to video syncs the linked cameras, + /// and a camera joining a video rig is synced on arrival — the camera + /// refuses Cinematic unless it knows it is in video mode. + func testRigModeSyncsLinkedCamerasAndLateJoiners() async { + let (controller, transport, _) = await makeController(peers: [camA]) + controller.didReceiveMessage(multicamCaps(), from: camA) + await controller.waitForIdle() + transport.sentMessages.removeAll() + + controller.setRigMode(.video) + await controller.waitForIdle() + var syncs = sent(transport, RemoteCmd.SyncMonitorSettings.self) + XCTAssertEqual(syncs.map(\.peers), [[camA]]) + XCTAssertEqual((syncs.first?.msg as? RemoteCmd.SyncMonitorSettings)?.mode, .Video) + + // Same mode again is a no-op on the wire. + controller.setRigMode(.video) + await controller.waitForIdle() + XCTAssertEqual(sent(transport, RemoteCmd.SyncMonitorSettings.self).count, 1) + + // A camera whose capabilities arrive while the rig is in video mode + // is told so; one arriving in photo mode (the camera default) is not. + controller.inviteCamera(camB) + transport.connectedPeers = [camA, camB] + controller.peerDidConnect(camB) + await controller.waitForIdle() + transport.sentMessages.removeAll() + controller.didReceiveMessage(multicamCaps(), from: camB) + await controller.waitForIdle() + syncs = sent(transport, RemoteCmd.SyncMonitorSettings.self) + XCTAssertEqual(syncs.map(\.peers), [[camB]]) + + controller.setRigMode(.photo) + await controller.waitForIdle() + transport.sentMessages.removeAll() + controller.didReceiveMessage(multicamCaps(), from: camB) + await controller.waitForIdle() + XCTAssertTrue(sent(transport, RemoteCmd.SyncMonitorSettings.self).isEmpty) + } + // MARK: - Frame routing (Seam B) func testFrameRoutesToItsLaneAndAcksOnlyItsSource() async { diff --git a/RemoteCamTests/MulticamViewModelTests.swift b/RemoteCamTests/MulticamViewModelTests.swift index 67edc838..ec47ab02 100644 --- a/RemoteCamTests/MulticamViewModelTests.swift +++ b/RemoteCamTests/MulticamViewModelTests.swift @@ -18,6 +18,8 @@ final class MulticamViewModelTests: XCTestCase { private func info(_ peer: MCPeerID, status: CameraLink.Status = .linked, focused: Bool = false, canFlipCamera: Bool = false, supportsFocusPoint: Bool = false, hasTorch: Bool = false, + supportsManualExposure: Bool = false, + supportsCinematicVideo: Bool = false, zoomFactor: CGFloat = 1.0, maxZoomFactor: CGFloat = 10.0, zoomStops: [CGFloat] = [1.0], wideAngleZoomFactor: CGFloat = 1.0, torchOn: Bool = false, flashOn: Bool = false) -> MulticamLaneInfo { @@ -26,12 +28,35 @@ final class MulticamViewModelTests: XCTestCase { captureOutcome: nil, isRecording: false, recordingElapsedMillis: nil, needsQualityRematch: false, collection: .idle, canFlipCamera: canFlipCamera, - supportsFocusPoint: supportsFocusPoint, hasTorch: hasTorch, + supportsFocusPoint: supportsFocusPoint, + supportsManualExposure: supportsManualExposure, exposure: nil, + supportsCinematicVideo: supportsCinematicVideo, cinematic: nil, + hasTorch: hasTorch, zoomFactor: zoomFactor, maxZoomFactor: maxZoomFactor, zoomStops: zoomStops, wideAngleZoomFactor: wideAngleZoomFactor, torchOn: torchOn, flashOn: flashOn) } + /// The PRO tile is a property of the FOCUSED camera: refocusing from a + /// camera without pro controls to one with them makes it appear, and + /// Cinematic alone earns it only once the director is in video mode. + func testProTileFollowsFocusedCameraCapabilities() { + let vm = MulticamViewModel() + vm.apply([info(camA, focused: true), info(camB, supportsManualExposure: true)]) + XCTAssertFalse(vm.showsProControls, "focused camera offers nothing") + + vm.apply([info(camA), info(camB, focused: true, supportsManualExposure: true)]) + XCTAssertTrue(vm.showsProControls, "focused camera does manual exposure") + + vm.apply([info(camA), info(camB, focused: true, supportsCinematicVideo: true)]) + XCTAssertFalse(vm.showsProControls, "Cinematic is not a photo control") + vm.mode = .video + XCTAssertTrue(vm.showsProControls) + + vm.apply([info(camA), info(camB, status: .reconnecting, focused: true, supportsManualExposure: true)]) + XCTAssertFalse(vm.showsProControls, "a dropped camera cannot be driven") + } + /// The shutter is a broadcast: cameras present is enough — focus is /// presentation and must never gate firing. func testShutterNeedsCamerasNotFocus() { diff --git a/RemoteShutter.xcodeproj/project.pbxproj b/RemoteShutter.xcodeproj/project.pbxproj index 0ffdfce2..5d82d476 100644 --- a/RemoteShutter.xcodeproj/project.pbxproj +++ b/RemoteShutter.xcodeproj/project.pbxproj @@ -184,6 +184,7 @@ CAFEBABE00F0000000000002 /* FocusPointMappingTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = CAFEBABE00F0000000000001 /* FocusPointMappingTests.swift */; }; E0E0206A0000000000000004 /* ExposurePolicyTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = E0E0206A0000000000000003 /* ExposurePolicyTests.swift */; }; E0E0206D0000000000000002 /* CinematicPolicyTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = E0E0206D0000000000000001 /* CinematicPolicyTests.swift */; }; + E0E0206E0000000000000002 /* MessageDumpTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = E0E0206E0000000000000001 /* MessageDumpTests.swift */; }; CAFEBABE0121000000000002 /* MonitorChromeTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = CAFEBABE0121000000000001 /* MonitorChromeTests.swift */; }; CAFEBABE0100000000000002 /* PeerCompatibility.swift in Sources */ = {isa = PBXBuildFile; fileRef = CAFEBABE0100000000000001 /* PeerCompatibility.swift */; }; CB5F78DFB9D567955BC863AF /* SoundManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 99FC5340BB98B2BD307FFA1A /* SoundManager.swift */; }; @@ -456,6 +457,7 @@ CAFEBABE00F0000000000001 /* FocusPointMappingTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FocusPointMappingTests.swift; sourceTree = ""; }; E0E0206A0000000000000003 /* ExposurePolicyTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ExposurePolicyTests.swift; sourceTree = ""; }; E0E0206D0000000000000001 /* CinematicPolicyTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CinematicPolicyTests.swift; sourceTree = ""; }; + E0E0206E0000000000000001 /* MessageDumpTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MessageDumpTests.swift; sourceTree = ""; }; CAFEBABE0121000000000001 /* MonitorChromeTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MonitorChromeTests.swift; sourceTree = ""; }; CAFEBABE0100000000000001 /* PeerCompatibility.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PeerCompatibility.swift; sourceTree = ""; }; CD857DFD7882DAA5012B70C9 /* FlatBufferSchemas.fbs */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text; path = FlatBufferSchemas.fbs; sourceTree = ""; }; @@ -644,6 +646,7 @@ CAFEBABE00F0000000000001 /* FocusPointMappingTests.swift */, E0E0206A0000000000000003 /* ExposurePolicyTests.swift */, E0E0206D0000000000000001 /* CinematicPolicyTests.swift */, + E0E0206E0000000000000001 /* MessageDumpTests.swift */, CAFEBABE0121000000000001 /* MonitorChromeTests.swift */, CAFEBABE0131000000000001 /* CaptureSyncMetadataTests.swift */, CAFEBABE0133000000000001 /* ClockOffsetEstimatorTests.swift */, @@ -1344,6 +1347,7 @@ CAFEBABE00F0000000000002 /* FocusPointMappingTests.swift in Sources */, E0E0206A0000000000000004 /* ExposurePolicyTests.swift in Sources */, E0E0206D0000000000000002 /* CinematicPolicyTests.swift in Sources */, + E0E0206E0000000000000002 /* MessageDumpTests.swift in Sources */, CAFEBABE0121000000000002 /* MonitorChromeTests.swift in Sources */, CAFEBABE0131000000000002 /* CaptureSyncMetadataTests.swift in Sources */, CAFEBABE0133000000000002 /* ClockOffsetEstimatorTests.swift in Sources */, From 7167081d2dfa98a98f9ab6dbf9e9d37205978f5f Mon Sep 17 00:00:00 2001 From: Dario Lencina Date: Wed, 26 Aug 2026 22:36:33 -0700 Subject: [PATCH 06/14] Pro controls as tray tiles + zoom-style sliders; one RulerPill behind zoom and pro MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The PRO tile → panel → dials path was two taps too deep. Shutter, ISO, Cinematic and Aperture are now first-class tray tiles (with the camera's current value on each); SHUTTER / ISO / APERTURE open a slider in the zoom pill's slot, Cinematic toggles in place like HDR. The slider IS the zoom control: the ruler, relative drag, scroll wheel, pending-value echo and VoiceOver adjustable element are extracted from ZoomPill into RulerPill, and the log-track math from ZoomScale into LogTrack (ZoomScale wraps it; ZoomScaleTests unchanged and green). ZoomPill and ProSliderPill are thin configurations. Slider sends are throttled with the zoom throttle. Both the 1:1 monitor and the multicam director get the same tiles and slider. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01K5uHRuiiLu5FEBiHZkBWo3 --- Docs/pro-controls.md | 63 +-- RemoteCam/CameraViewModel.swift | 4 +- RemoteCam/CinematicPolicy.swift | 2 +- RemoteCam/MonitorChrome.swift | 43 +- RemoteCam/MonitorView.swift | 124 +++-- RemoteCam/MonitorViewController+SwiftUI.swift | 17 + RemoteCam/MonitorViewController.swift | 3 + RemoteCam/MultiCamChrome.swift | 10 +- RemoteCam/MulticamView.swift | 118 +++-- RemoteCam/MulticamViewController.swift | 24 + RemoteCam/MulticamViewModel.swift | 30 +- RemoteCam/ProControlsPanel.swift | 286 ----------- RemoteCam/ProSliderPill.swift | 160 +++++++ RemoteCam/RulerPill.swift | 444 ++++++++++++++++++ RemoteCam/ZoomPill.swift | 336 ++----------- RemoteCam/ZoomScale.swift | 32 +- RemoteCamTests/CinematicPolicyTests.swift | 24 +- RemoteCamTests/MonitorChromeTests.swift | 71 +-- .../MonitorScreenSnapshotTests.swift | 22 +- RemoteCamTests/MultiCamChromeTests.swift | 19 +- RemoteCamTests/MulticamViewModelTests.swift | 34 +- RemoteCamTests/ProSliderScaleTests.swift | 86 ++++ RemoteShutter.xcodeproj/project.pbxproj | 16 +- 23 files changed, 1132 insertions(+), 836 deletions(-) delete mode 100644 RemoteCam/ProControlsPanel.swift create mode 100644 RemoteCam/ProSliderPill.swift create mode 100644 RemoteCam/RulerPill.swift create mode 100644 RemoteCamTests/ProSliderScaleTests.swift diff --git a/Docs/pro-controls.md b/Docs/pro-controls.md index bd49f9ad..dab38aa8 100644 --- a/Docs/pro-controls.md +++ b/Docs/pro-controls.md @@ -276,40 +276,43 @@ the probe dictates: Follows the HIG for camera controls: values a photographer recognizes, direct and reversible adjustments, current state always visible on both devices. -**Remote (monitor)** -- One **PRO** tray tile (`camera.aperture`), listed only when the connected - camera advertised something for it to control (`MonitorTray.showsProControls`: - manual exposure in any mode, Cinematic in video modes). It opens the - `ProControlsPanel`, a bottom panel in the tray's presentation. -- The same tile and panel exist on the **multicam director** — the screen a - single camera lands on while `MULTICAM_FOR_SINGLE_CAMERA` is on. There the - controls drive the **focused** camera, like torch and zoom: the tile - follows that camera's flags, `CameraLink` carries its echoed - `ExposureState`/`CinematicState`, and the command carries the lane +**Remote (monitor)** — the controls live one tap deep, like every other +capture setting: tray tiles, and a slider in the zoom pill's slot. +- **Tray tiles** (`MonitorTray.proTiles`), listed only when the connected + camera advertised the capability: **SHUTTER** and **ISO** (manual exposure, + any mode), **CINEMATIC** (video modes) and **APERTURE** (once Cinematic is + on and `min_simulated_aperture > 0`). Each tile reads the camera's current + value (`1/125`, `400`, `f/2.8`); SHUTTER/ISO light up while Manual is on, + CINEMATIC while the effect is on. They sit with the capture settings, after + quality and before standby, on both the 1:1 monitor and the multicam + director. +- **Sliders** (`ProSliderPill`): tapping SHUTTER, ISO or APERTURE closes the + tray and puts that control's slider where the zoom pill sits — the same + look and gestures as zoom (`ProSliderScale` is the pro analog of + `ZoomScale`): a log-spaced ruler over the camera's range, photographic + detents (1/8000 … 1 s; ISO ⅓-stops; f/1.4 … f/16), relative drag, scroll + wheel on the Mac, VoiceOver-adjustable. Dragging SHUTTER sends + `manual(duration, iso: 0)` and ISO `manual(0, iso)` — each locks only its + own component, so the first drag engages Manual from the values auto was + using. **AUTO** on the pill hands exposure back to the camera and closes + it; **×** just closes it. The aperture slider has no AUTO. Values are + throttled like zoom (`ThrottledValueSender` over `ZoomSendThrottle`). +- **CINEMATIC** toggles in place, like HDR; the tile dims while recording + (Apple rejects enabling/disabling mid-take) and so does APERTURE. +- The pill shows the in-flight value while dragging and the camera's + **echoed** value once it confirms — the remote never claims a state the + camera did not confirm. A slider stays open only while its tile is still + offered (the camera may swap to a device without it, or leave video mode). +- **Multicam director** — the screen a single camera lands on while + `MULTICAM_FOR_SINGLE_CAMERA` is on. Same tiles and slider, driving the + **focused** camera like torch and zoom: `CameraLink` carries that camera's + echoed `ExposureState`/`CinematicState`, and the command carries the lane (`MulticamController.setExposure(_:on:)` / `setCinematic(_:on:)`, gated on that camera's capabilities). The director's photo/video mode is pushed to every camera (`SyncMonitorSettings`, including late joiners) so a camera knows it is in video mode before Cinematic is asked of it. -- **Exposure**: segmented `Auto | Manual`. Auto shows a live readout of what - the camera is choosing (`1/120 · ISO 64`). Manual shows two **detented - horizontal dials** (`ProDial`): shutter at standard stops (1/8000 … ¼, ⅓, - ½, 1 s, filtered to the device range) and ISO in ⅓-stops; bold value label; - Reset-to-Auto. -- **Cinematic**: an on/off toggle and, when `min_simulated_aperture > 0`, an - aperture dial in ⅓-stops (f/1.4, 1.6, 1.8, 2, 2.2 … 16) filtered to the - range, defaulting to `default_simulated_aperture`. While recording the dial - is disabled with the caption "Aperture is set before recording". A "Scene - too dark for Cinematic" hint appears when the camera reports it. -- Dials give haptic ticks on detents (`.sensoryFeedback` on iOS 17+, - `UISelectionFeedbackGenerator` below) and display the **echoed** value from - the camera, never the dragged one — the remote never claims a state the - camera did not confirm. -- Accessibility: dials are `.adjustable` elements reading "1/125 second", - "ISO 400", "f/2.8"; Dynamic Type labels; 44 pt targets; same dismissal - gesture as the quality panel. -- Mac Catalyst: identical SwiftUI; dials take scroll-wheel/trackpad through - the existing gesture layer. Mac cameras generally advertise neither flag, so - neither button appears. +- Mac Catalyst: identical SwiftUI. Mac cameras generally advertise neither + flag, so no tile appears. **Camera phone** - A readout chip on the preview, top edge, while a pro control is active: diff --git a/RemoteCam/CameraViewModel.swift b/RemoteCam/CameraViewModel.swift index be6ac49b..306637e3 100644 --- a/RemoteCam/CameraViewModel.swift +++ b/RemoteCam/CameraViewModel.swift @@ -189,7 +189,7 @@ class CameraViewModel: ObservableObject { DispatchQueue.main.async { [weak self] in guard let self else { return } self.exposureReadoutText = state.mode == .manual - ? "M \(ProDialStops.shutterLabel(state.durationSeconds)) · \(ProDialStops.isoLabel(state.iso))" + ? "M \(ProStops.shutterLabel(state.durationSeconds)) · \(ProStops.isoLabel(state.iso))" : nil self.recomposeProReadout() } @@ -199,7 +199,7 @@ class CameraViewModel: ObservableObject { DispatchQueue.main.async { [weak self] in guard let self else { return } self.cinematicReadoutText = state.enabled - ? "CINEMATIC \(ProDialStops.apertureLabel(state.simulatedAperture))" + ? "CINEMATIC \(ProStops.apertureLabel(state.simulatedAperture))" : nil self.recomposeProReadout() } diff --git a/RemoteCam/CinematicPolicy.swift b/RemoteCam/CinematicPolicy.swift index 8002ff8f..d16e7226 100644 --- a/RemoteCam/CinematicPolicy.swift +++ b/RemoteCam/CinematicPolicy.swift @@ -117,7 +117,7 @@ enum CinematicPolicy { /// The detents the monitor's dials snap to, in values a photographer /// recognizes, filtered to what the connected camera's format allows. -enum ProDialStops { +enum ProStops { /// Standard shutter stops from 1/8000 s up to 1 s. static let allShutterSeconds: [Double] = [ diff --git a/RemoteCam/MonitorChrome.swift b/RemoteCam/MonitorChrome.swift index 01507756..1f998a0c 100644 --- a/RemoteCam/MonitorChrome.swift +++ b/RemoteCam/MonitorChrome.swift @@ -73,8 +73,12 @@ enum MonitorTrayItem: Equatable { /// Puts the peer camera's *local* preview to sleep. It keeps capturing and /// keeps streaming here. case cameraStandby - /// Manual exposure + Cinematic video (opens the pro panel). - case proControls + /// Pro controls (issue #206). Shutter / ISO / aperture open a viewfinder + /// slider in the zoom pill's slot; Cinematic toggles in place. + case shutter + case iso + case cinematic + case aperture case settings case help } @@ -90,7 +94,7 @@ enum MonitorTray { supportsCameraStandby: Bool, resolutionCount: Int, frameRateCount: Int, - showsProControls: Bool = false) -> [MonitorTrayItem] { + proTiles: [MonitorTrayItem] = []) -> [MonitorTrayItem] { var items: [MonitorTrayItem] = [] // Shorts runs to a fixed duration, so a self-timer has nothing to delay. @@ -110,25 +114,36 @@ enum MonitorTray { break } + // Pro controls sit with the capture settings, ahead of the + // peer-device controls (standby) and the tray's floor. + items.append(contentsOf: proTiles) if supportsCameraStandby { items.append(.cameraStandby) } - // Pro controls are a capability of the connected camera: a peer that - // never advertised them would ignore the commands, so no tile. - if showsProControls { items.append(.proControls) } items.append(.settings) items.append(.help) return items } - /// The PRO tile exists only when the connected camera offers something for - /// it to control: manual exposure in any mode, Cinematic in video modes. - static func showsProControls(for state: MonitorUIState, - supportsManualExposure: Bool, - supportsCinematicVideo: Bool, - flagEnabled: Bool = FeatureFlags.ENABLE_PRO_CONTROLS) -> Bool { - guard flagEnabled else { return false } + /// The pro tiles the connected camera earns (`proTiles:` above). Each is a + /// capability of that camera: manual exposure gives SHUTTER + ISO in any + /// mode; Cinematic (a video effect) gives its toggle in video modes and, + /// once on, APERTURE when the device can adjust it. A peer that never + /// advertised a capability would ignore the command, so no tile. + static func proTiles(for state: MonitorUIState, + supportsManualExposure: Bool, + supportsCinematicVideo: Bool, + cinematicOn: Bool, + apertureAdjustable: Bool, + flagEnabled: Bool = FeatureFlags.ENABLE_PRO_CONTROLS) -> [MonitorTrayItem] { + guard flagEnabled else { return [] } + var tiles: [MonitorTrayItem] = [] + if supportsManualExposure { tiles += [.shutter, .iso] } let videoish = state == .videoMode || state == .videoRecording - return supportsManualExposure || (videoish && supportsCinematicVideo) + if videoish, supportsCinematicVideo { + tiles.append(.cinematic) + if cinematicOn, apertureAdjustable { tiles.append(.aperture) } + } + return tiles } } diff --git a/RemoteCam/MonitorView.swift b/RemoteCam/MonitorView.swift index c05d33c1..d25eccf9 100644 --- a/RemoteCam/MonitorView.swift +++ b/RemoteCam/MonitorView.swift @@ -38,11 +38,15 @@ struct MonitorView: View { let onToggleCameraStandby: () -> Void /// Pro controls (defaulted so previews/snapshots need not wire them). /// Free for every user — the only gate is the camera's capability. + /// Slider values go through `onProSliderChange` so the host can throttle + /// them like zoom; AUTO and the Cinematic toggle are single sends. var onExposureChange: (ExposureIntent) -> Void = { _ in } var onCinematicChange: (CinematicIntent) -> Void = { _ in } + var onProSliderChange: (ProSliderKind, Double) -> Void = { _, _ in } @State private var isTrayOpen = false - @State private var isProPanelOpen = false + /// The pro slider on the viewfinder, in the zoom pill's slot. + @State private var activeProSlider: ProSliderKind? var body: some View { GeometryReader { geometry in @@ -58,10 +62,6 @@ struct MonitorView: View { trayLayer } - if isProPanelOpen { - proPanelLayer - } - if viewModel.isVideoTransferring { VideoTransferProgressView( progress: viewModel.videoTransferProgress, @@ -238,9 +238,7 @@ struct MonitorView: View { /// but UIKit will not hit-test them. private var bottomCluster: some View { VStack(spacing: 14) { - ZoomPill(scale: viewModel.zoomScale, - currentZoomFactor: viewModel.currentZoomFactor, - onZoomChange: onZoomChange) + zoomOrProSlider actionCluster(axis: .horizontal) modeSelector } @@ -257,9 +255,7 @@ struct MonitorView: View { // Inboard of the rail: one control zone on the docked edge. VStack(spacing: 10) { Spacer(minLength: 0) - ZoomPill(scale: viewModel.zoomScale, - currentZoomFactor: viewModel.currentZoomFactor, - onZoomChange: onZoomChange) + zoomOrProSlider modeSelector } @@ -373,7 +369,7 @@ struct MonitorView: View { supportsCameraStandby: viewModel.supportsCameraStandby, resolutionCount: viewModel.supportedResolutions.count, frameRateCount: availableFrameRates.count, - showsProControls: showsProControls), + proTiles: proTiles), timerValue: Int(viewModel.timerSliderValue), aspectRatio: viewModel.currentAspectRatio, resolution: viewModel.currentVideoResolution, @@ -381,6 +377,8 @@ struct MonitorView: View { photoFormat: viewModel.currentPhotoFormat, hdrMode: viewModel.currentHDRMode, cameraPreviewMode: viewModel.cameraPreviewMode, + exposure: viewModel.exposure, + cinematic: viewModel.cinematic, isQualityEnabled: viewModel.isQualityControlEnabled, isTimerEnabled: viewModel.isTimerSliderEnabled, isSettingsEnabled: viewModel.isSettingsEnabled, @@ -389,26 +387,55 @@ struct MonitorView: View { } } - private var showsProControls: Bool { - MonitorTray.showsProControls(for: viewModel.uiState, - supportsManualExposure: viewModel.supportsManualExposure, - supportsCinematicVideo: viewModel.supportsCinematicVideo) + // MARK: - Pro controls (tray tiles + a viewfinder slider) + + private var proTiles: [MonitorTrayItem] { + MonitorTray.proTiles(for: viewModel.uiState, + supportsManualExposure: viewModel.supportsManualExposure, + supportsCinematicVideo: viewModel.supportsCinematicVideo, + cinematicOn: viewModel.cinematic?.enabled == true, + apertureAdjustable: (viewModel.cinematic?.minSimulatedAperture ?? 0) > 0) } - private var proPanelLayer: some View { - ZStack(alignment: .bottom) { - Color.black.opacity(0.02) - .ignoresSafeArea() - .onTapGesture { - withAnimation(.spring(response: 0.32, dampingFraction: 0.85)) { - isProPanelOpen = false - } - } + /// The open slider, as long as its tile is still offered (the camera may + /// have swapped to a device without it, or left video mode). + private var visibleProSlider: ProSliderKind? { + guard let kind = activeProSlider, proTiles.contains(kind.tile) else { return nil } + return kind + } - ProControlsPanel(viewModel: viewModel, - onExposureChange: onExposureChange, - onCinematicChange: onCinematicChange) - .transition(.move(edge: .bottom)) + /// The zoom pill's slot: the pro slider while one is open, else zoom. + @ViewBuilder + private var zoomOrProSlider: some View { + if let kind = visibleProSlider, let scale = proScale(kind) { + ProSliderPill(scale: scale, + currentValue: proValue(kind), + onChange: { onProSliderChange(kind, $0) }, + onAuto: kind == .aperture ? nil : { + onExposureChange(.auto) + activeProSlider = nil + }, + onClose: { activeProSlider = nil }) + } else { + ZoomPill(scale: viewModel.zoomScale, + currentZoomFactor: viewModel.currentZoomFactor, + onZoomChange: onZoomChange) + } + } + + private func proScale(_ kind: ProSliderKind) -> ProSliderScale? { + switch kind { + case .shutter: return viewModel.exposure.map(ProSliderScale.shutter) + case .iso: return viewModel.exposure.map(ProSliderScale.iso) + case .aperture: return viewModel.cinematic.map(ProSliderScale.aperture) + } + } + + private func proValue(_ kind: ProSliderKind) -> Double { + switch kind { + case .shutter: return viewModel.exposure?.durationSeconds ?? 0 + case .iso: return Double(viewModel.exposure?.iso ?? 0) + case .aperture: return Double(viewModel.cinematic?.simulatedAperture ?? 0) } } @@ -450,11 +477,15 @@ struct MonitorView: View { // it is worth watching settle. onToggleCameraStandby() - case .proControls: + case .shutter, .iso, .aperture: + // The slider takes the zoom pill's slot on the viewfinder, so the + // tray gets out of the way of the picture being adjusted. toggleTray() - withAnimation(.spring(response: 0.32, dampingFraction: 0.85)) { - isProPanelOpen = true - } + activeProSlider = item == .shutter ? .shutter : (item == .iso ? .iso : .aperture) + + case .cinematic: + // Toggles in place, like HDR; the glyph follows the camera's echo. + onCinematicChange(viewModel.cinematic?.enabled == true ? .off : .on(aperture: nil)) case .settings: toggleTray() @@ -715,6 +746,9 @@ struct MonitorTrayPanel: View { /// The camera's *confirmed* mode, not local intent — the tile only lights /// up once the peer has said so. var cameraPreviewMode: CameraPreviewMode = .on + /// The camera's echoed pro-control truth (nil until it advertises one). + var exposure: ExposureState? = nil + var cinematic: CinematicState? = nil let isQualityEnabled: Bool let isTimerEnabled: Bool let isSettingsEnabled: Bool @@ -741,8 +775,12 @@ struct MonitorTrayPanel: View { case .resolution: return resolution.displayName case .frameRate: return frameRate.displayName case .format: return photoFormat.displayName + // The camera's current values, so the tile reads before it is opened. + case .shutter: return exposure.map { ProStops.shutterLabel($0.durationSeconds) } + case .iso: return exposure.map { String(Int($0.iso.rounded())) } + case .aperture: return cinematic.map { ProStops.apertureLabel($0.simulatedAperture) } // Glyph-only: state is carried by the symbol. - case .hdr, .cameraStandby, .proControls, .settings, .help: return nil + case .hdr, .cameraStandby, .cinematic, .settings, .help: return nil } } @@ -751,6 +789,8 @@ struct MonitorTrayPanel: View { case .timer: return timerValue > 0 case .hdr: return hdrMode == .on case .cameraStandby: return cameraPreviewMode == .standby + case .shutter, .iso: return exposure?.mode == .manual + case .cinematic: return cinematic?.enabled == true default: return false } } @@ -761,8 +801,10 @@ struct MonitorTrayPanel: View { case .aspect, .resolution, .frameRate, .format, .hdr: return isQualityEnabled // Not a capture setting: usable mid-recording. case .cameraStandby: return true - // The panel itself explains what recording locks (aperture). - case .proControls: return true + // Exposure is live mid-take (the camera caps the shutter at one + // frame); Cinematic and its aperture are set before a take. + case .shutter, .iso: return true + case .cinematic, .aperture: return cinematic?.apertureLocked != true case .settings: return isSettingsEnabled case .help: return true } @@ -822,7 +864,10 @@ struct MonitorTrayTile: View { case .format: return "doc" case .hdr: return "camera.filters" case .cameraStandby: return isActive ? "moon.zzz.fill" : "moon.zzz" - case .proControls: return "camera.aperture" + case .shutter: return "camera.shutter.button" + case .iso: return "sun.max" + case .cinematic: return "camera.aperture" + case .aperture: return "camera.aperture" case .settings: return "gearshape.fill" case .help: return "questionmark" } @@ -837,7 +882,10 @@ struct MonitorTrayTile: View { case .format: return NSLocalizedString("FORMAT", comment: "tray tile") case .hdr: return NSLocalizedString("HDR", comment: "tray tile") case .cameraStandby: return NSLocalizedString("STANDBY", comment: "tray tile") - case .proControls: return NSLocalizedString("PRO", comment: "tray tile") + case .shutter: return NSLocalizedString("SHUTTER", comment: "tray tile") + case .iso: return "ISO" + case .cinematic: return NSLocalizedString("CINEMATIC", comment: "tray tile") + case .aperture: return NSLocalizedString("APERTURE", comment: "tray tile") case .settings: return NSLocalizedString("SETTINGS", comment: "tray tile") case .help: return NSLocalizedString("HELP", comment: "tray tile") } diff --git a/RemoteCam/MonitorViewController+SwiftUI.swift b/RemoteCam/MonitorViewController+SwiftUI.swift index e2e66c1f..ed20f5ad 100644 --- a/RemoteCam/MonitorViewController+SwiftUI.swift +++ b/RemoteCam/MonitorViewController+SwiftUI.swift @@ -67,12 +67,29 @@ extension MonitorViewController { }, onCinematicChange: { [weak self] intent in self?.session ! UICmd.SetCinematic(intent: intent) + }, + onProSliderChange: { [weak self] kind, value in + self?.proSender(for: kind).submit(value) } ) self.swiftUIHostingController = embedSwiftUIView(monitorView) } + /// One throttled sender per pro slider (the zoom pill's send pattern): + /// the value becomes the wire intent at send time. + private func proSender(for kind: ProSliderKind) -> ThrottledValueSender { + if let existing = proSenders[kind] { return existing } + let sender = ThrottledValueSender { [weak self] value in + switch kind.intent(for: value) { + case .exposure(let intent): self?.session ! UICmd.SetExposure(intent: intent) + case .cinematic(let intent): self?.session ! UICmd.SetCinematic(intent: intent) + } + } + proSenders[kind] = sender + return sender + } + // MARK: - Action Handlers private func handleTakePicture() { debugLog("🔴 DEBUG: handleTakePicture called - isRecording: \(viewModel.isRecording), uiState: \(viewModel.uiState)") diff --git a/RemoteCam/MonitorViewController.swift b/RemoteCam/MonitorViewController.swift index 668d07af..de39a358 100644 --- a/RemoteCam/MonitorViewController.swift +++ b/RemoteCam/MonitorViewController.swift @@ -63,6 +63,9 @@ public class MonitorViewController: UIViewController { /// Internal rather than private: `handleZoomChange` lives in a different file's /// extension, and `private` is file-scoped. var zoomThrottle = ZoomSendThrottle() + /// Throttled senders for the pro sliders, one per control (see + /// `proSender(for:)`). + var proSenders: [ProSliderKind: ThrottledValueSender] = [:] var trailingZoomTimer: Timer? var availableLensTypes: [CameraLensType] = [.wideAngle] var currentLensType: CameraLensType = .wideAngle diff --git a/RemoteCam/MultiCamChrome.swift b/RemoteCam/MultiCamChrome.swift index bed29e2c..8c6de216 100644 --- a/RemoteCam/MultiCamChrome.swift +++ b/RemoteCam/MultiCamChrome.swift @@ -59,7 +59,7 @@ enum RigTray { /// and names the blocking camera in the footnote instead. Aspect, like the /// 1:1 tray's, shows in both modes — every camera can crop. static func items(mode: MonitorMode, standbyAvailable: Bool, - showsProControls: Bool = false) -> [MonitorTrayItem] { + proTiles: [MonitorTrayItem] = []) -> [MonitorTrayItem] { var items: [MonitorTrayItem] = [.timer, .aspect] switch mode { @@ -69,11 +69,11 @@ enum RigTray { items.append(contentsOf: [.format, .hdr]) } - if standbyAvailable { items.append(.cameraStandby) } // Pro controls drive the FOCUSED camera (like torch and zoom), so the - // tile follows that camera's advertised capabilities — same slot as - // the 1:1 tray. - if showsProControls { items.append(.proControls) } + // tiles follow that camera's advertised capabilities — same slot as + // the 1:1 tray, ahead of standby. + items.append(contentsOf: proTiles) + if standbyAvailable { items.append(.cameraStandby) } items.append(.settings) items.append(.help) return items diff --git a/RemoteCam/MulticamView.swift b/RemoteCam/MulticamView.swift index f4e8f088..f3506db5 100644 --- a/RemoteCam/MulticamView.swift +++ b/RemoteCam/MulticamView.swift @@ -58,11 +58,14 @@ struct MulticamView: View { /// Tap-to-focus on the named camera (normalized upright coords; the host /// gates on the IAP, the controller on the peer's advertised support). let onFocusTap: (CameraLane, CGPoint) -> Void - /// Pro controls on the named camera (the focused one — the panel is + /// Pro controls on the named camera (the focused one — the slider is /// rendered for it, and the command carries it, like every per-camera - /// control here). + /// control here). Slider values go through `onProSliderChange` so the + /// host throttles them like zoom; AUTO and the Cinematic toggle are + /// single sends. let onExposureChange: (CameraLane, ExposureIntent) -> Void let onCinematicChange: (CameraLane, CinematicIntent) -> Void + let onProSliderChange: (CameraLane, ProSliderKind, Double) -> Void /// Leave the director screen, back to the scanner (links stay up; the /// scanner re-arms and re-selects the still-connected cameras). let onBack: () -> Void @@ -91,14 +94,12 @@ struct MulticamView: View { countdownOverlay transientErrorToast if viewModel.showingRigTray { rigTrayLayer } - if viewModel.showingProPanel { proPanelLayer } #if DEBUG SessionDebugOverlay() #endif } .animation(.spring(response: 0.32, dampingFraction: 0.85), value: viewModel.showingRigTray) - .animation(.spring(response: 0.32, dampingFraction: 0.85), value: viewModel.showingProPanel) .sheet(isPresented: $viewModel.showingAddCamera) { AddCameraSheet(peers: viewModel.availablePeers, onInvite: onInviteCamera) } @@ -138,10 +139,16 @@ struct MulticamView: View { onSetHDR: onSetHDR, onSetAspectRatio: onSetAspectRatio, onSetStandby: onSetStandby, - showsProControls: viewModel.showsProControls, - onOpenProControls: { + proTiles: viewModel.focusedProTiles, + exposure: viewModel.focusedLane?.exposure, + cinematic: viewModel.focusedLane?.cinematic, + onOpenProSlider: { kind in viewModel.showingRigTray = false - viewModel.showingProPanel = true + viewModel.activeProSlider = kind + }, + onToggleCinematic: { + guard let focused = viewModel.focusedLane else { return } + onCinematicChange(focused, focused.cinematic?.enabled == true ? .off : .on(aperture: nil)) }, // Close the tray first, as the 1:1 tray does — the // sheet returns to a clean viewfinder. @@ -157,27 +164,6 @@ struct MulticamView: View { } } - /// The focused camera's pro-controls panel, in the tray's presentation. - /// Values are that camera's echoed truth; intents carry the lane. - @ViewBuilder - private var proPanelLayer: some View { - if let focused = viewModel.focusedLane { - ZStack(alignment: .bottom) { - Color.black.opacity(0.02) - .ignoresSafeArea() - .onTapGesture { viewModel.showingProPanel = false } - ProControlsPanel(supportsManualExposure: focused.supportsManualExposure, - exposure: focused.exposure, - supportsCinematicVideo: focused.supportsCinematicVideo, - cinematic: focused.cinematic, - isVideoMode: viewModel.mode == .video, - onExposureChange: { onExposureChange(focused, $0) }, - onCinematicChange: { onCinematicChange(focused, $0) }) - .transition(.move(edge: .bottom)) - } - } - } - // MARK: - Chrome (mirrors MonitorView's chrome, slot for slot) /// Everything floating over the preview, arranged for the docked edge — @@ -331,11 +317,38 @@ struct MulticamView: View { /// 1:1 monitor does. @ViewBuilder private var focusedZoomPill: some View { - if viewModel.displayMode == .focus && viewModel.showsFocusedZoomPill, - let focused = viewModel.focusedLane { - ZoomPill(scale: viewModel.focusedZoomScale, - currentZoomFactor: viewModel.focusedZoomFactor, - onZoomChange: { onZoomChange(focused, $0) }) + if viewModel.displayMode == .focus, let focused = viewModel.focusedLane { + if let kind = viewModel.visibleProSlider, let scale = proScale(kind, focused) { + // The pro slider takes the zoom pill's slot, as on the 1:1 monitor. + ProSliderPill(scale: scale, + currentValue: proValue(kind, focused), + onChange: { onProSliderChange(focused, kind, $0) }, + onAuto: kind == .aperture ? nil : { + onExposureChange(focused, .auto) + viewModel.activeProSlider = nil + }, + onClose: { viewModel.activeProSlider = nil }) + } else if viewModel.showsFocusedZoomPill { + ZoomPill(scale: viewModel.focusedZoomScale, + currentZoomFactor: viewModel.focusedZoomFactor, + onZoomChange: { onZoomChange(focused, $0) }) + } + } + } + + private func proScale(_ kind: ProSliderKind, _ lane: CameraLane) -> ProSliderScale? { + switch kind { + case .shutter: return lane.exposure.map(ProSliderScale.shutter) + case .iso: return lane.exposure.map(ProSliderScale.iso) + case .aperture: return lane.cinematic.map(ProSliderScale.aperture) + } + } + + private func proValue(_ kind: ProSliderKind, _ lane: CameraLane) -> Double { + switch kind { + case .shutter: return lane.exposure?.durationSeconds ?? 0 + case .iso: return Double(lane.exposure?.iso ?? 0) + case .aperture: return Double(lane.cinematic?.simulatedAperture ?? 0) } } @@ -847,9 +860,13 @@ struct RigTrayPanel: View { let onSetAspectRatio: (AspectRatio) -> Void /// Rig standby: blank (or wake) every supporting camera's own preview. let onSetStandby: (Bool) -> Void - /// The focused camera offers pro controls; tapping the tile opens them. - var showsProControls: Bool = false - var onOpenProControls: () -> Void = {} + /// The focused camera's pro tiles (`MonitorTray.proTiles`) and its echoed + /// values; shutter/ISO/aperture open a slider, Cinematic toggles in place. + var proTiles: [MonitorTrayItem] = [] + var exposure: ExposureState? = nil + var cinematic: CinematicState? = nil + var onOpenProSlider: (ProSliderKind) -> Void = { _ in } + var onToggleCinematic: () -> Void = {} /// Open the app's Settings sheet (purchases, restore, preferences). let onOpenSettings: () -> Void /// Open the help sheet (the same one every screen presents). @@ -860,7 +877,7 @@ struct RigTrayPanel: View { var body: some View { TrayPanelShell(footnote: settings.blockerFootnote(for: mode)) { ForEach(RigTray.items(mode: mode, standbyAvailable: settings.standbyAvailable, - showsProControls: showsProControls), + proTiles: proTiles), id: \.self) { item in tile(for: item) } @@ -909,12 +926,27 @@ struct RigTrayPanel: View { MonitorTrayTile(item: .help, value: nil, isActive: false, isEnabled: true, action: onOpenHelp) - case .proControls: - // Usable mid-recording: the panel itself explains what recording - // locks (aperture), exactly as the 1:1 tray does. - MonitorTrayTile(item: .proControls, value: nil, - isActive: false, isEnabled: true, - action: onOpenProControls) + // Pro tiles: the 1:1 tray's values and rules, for the focused camera. + case .shutter: + MonitorTrayTile(item: .shutter, + value: exposure.map { ProStops.shutterLabel($0.durationSeconds) }, + isActive: exposure?.mode == .manual, isEnabled: true, + action: { onOpenProSlider(.shutter) }) + case .iso: + MonitorTrayTile(item: .iso, + value: exposure.map { String(Int($0.iso.rounded())) }, + isActive: exposure?.mode == .manual, isEnabled: true, + action: { onOpenProSlider(.iso) }) + case .cinematic: + MonitorTrayTile(item: .cinematic, value: nil, + isActive: cinematic?.enabled == true, + isEnabled: cinematic?.apertureLocked != true, + action: onToggleCinematic) + case .aperture: + MonitorTrayTile(item: .aperture, + value: cinematic.map { ProStops.apertureLabel($0.simulatedAperture) }, + isActive: false, isEnabled: cinematic?.apertureLocked != true, + action: { onOpenProSlider(.aperture) }) case .frameRate: // Not offered by `RigTray.items` — frame rate rides the single // quality tile's intersection cycle. diff --git a/RemoteCam/MulticamViewController.swift b/RemoteCam/MulticamViewController.swift index fdae1c41..2db18fa4 100644 --- a/RemoteCam/MulticamViewController.swift +++ b/RemoteCam/MulticamViewController.swift @@ -28,6 +28,10 @@ public final class MulticamViewController: UIViewController { /// value lands. private var zoomThrottle = ZoomSendThrottle() private var trailingZoomTimer: Timer? + /// Throttled senders for the pro sliders, one per control; the target + /// camera rides through with the value like zoom's does. + private var proSenders: [ProSliderKind: ThrottledValueSender] = [:] + private var proSliderTarget: MCPeerID? /// `controller` must already be `install`-ed with its transport + peers by /// the caller (the scanner handoff), so lanes light up immediately. @@ -101,6 +105,10 @@ public final class MulticamViewController: UIViewController { onFocusTap: { [weak self] lane, point in self?.handleFocusTap(point, on: lane.peerID) }, onExposureChange: { [weak self] lane, intent in self?.controller.setExposure(intent, on: lane.peerID) }, onCinematicChange: { [weak self] lane, intent in self?.controller.setCinematic(intent, on: lane.peerID) }, + onProSliderChange: { [weak self] lane, kind, value in + self?.proSliderTarget = lane.peerID + self?.proSender(for: kind).submit(value) + }, onBack: { [weak self] in logInfo("director: back → scanner") self?.navigationController?.popViewController(animated: true) @@ -156,6 +164,22 @@ public final class MulticamViewController: UIViewController { controller.focusCamera(peer, x: Float(point.x), y: Float(point.y)) } + /// One throttled sender per pro slider (the zoom pill's send pattern); + /// the value becomes the wire intent, addressed to the camera the slider + /// was rendered for, at send time. + private func proSender(for kind: ProSliderKind) -> ThrottledValueSender { + if let existing = proSenders[kind] { return existing } + let sender = ThrottledValueSender { [weak self] value in + guard let self, let target = self.proSliderTarget else { return } + switch kind.intent(for: value) { + case .exposure(let intent): self.controller.setExposure(intent, on: target) + case .cinematic(let intent): self.controller.setCinematic(intent, on: target) + } + } + proSenders[kind] = sender + return sender + } + /// Reuse the existing Settings/paywall sheet — no bespoke multicam paywall. func showPaywall() { let ctrl = UIHostingController(rootView: SettingsView()) diff --git a/RemoteCam/MulticamViewModel.swift b/RemoteCam/MulticamViewModel.swift index a8dd98d7..6d20d4b6 100644 --- a/RemoteCam/MulticamViewModel.swift +++ b/RemoteCam/MulticamViewModel.swift @@ -94,8 +94,9 @@ final class MulticamViewModel: ObservableObject { @Published var rigSettings = RigSettingsSnapshot() /// Whether the rig settings tray is showing. @Published var showingRigTray: Bool = false - /// Whether the focused camera's pro-controls panel is showing. - @Published var showingProPanel: Bool = false + /// The pro slider open on the viewfinder (the zoom pill's slot), driving + /// the focused camera. + @Published var activeProSlider: ProSliderKind? /// A brief, non-blocking error readout (a refused camera switch, e.g.). /// The toast that renders it clears it after a few seconds. Each report /// carries its own identity — same pattern as `FocusReticle` — so a @@ -137,14 +138,23 @@ final class MulticamViewModel: ObservableObject { displayMode == .focus && (focusedLane?.hasTorch ?? false) } - /// The PRO tile and panel drive the FOCUSED camera (like torch and zoom), - /// so they follow that camera's advertised capabilities, under the 1:1 - /// tray's rule: manual exposure in any mode, Cinematic in video only. - var showsProControls: Bool { - guard let focused = focusedLane, focused.status == .linked else { return false } - return MonitorTray.showsProControls(for: monitorUIState, - supportsManualExposure: focused.supportsManualExposure, - supportsCinematicVideo: focused.supportsCinematicVideo) + /// The pro tiles and slider drive the FOCUSED camera (like torch and + /// zoom), so they follow that camera's advertised capabilities, under the + /// 1:1 tray's rule (`MonitorTray.proTiles`). + var focusedProTiles: [MonitorTrayItem] { + guard let focused = focusedLane, focused.status == .linked else { return [] } + return MonitorTray.proTiles(for: monitorUIState, + supportsManualExposure: focused.supportsManualExposure, + supportsCinematicVideo: focused.supportsCinematicVideo, + cinematicOn: focused.cinematic?.enabled == true, + apertureAdjustable: (focused.cinematic?.minSimulatedAperture ?? 0) > 0) + } + + /// The open slider, as long as the focused camera still offers its tile + /// (focus moved to another camera, the mode changed, the camera dropped). + var visibleProSlider: ProSliderKind? { + guard let kind = activeProSlider, focusedProTiles.contains(kind.tile) else { return nil } + return kind } /// The director's mode in the 1:1 monitor's vocabulary, for shared rules. diff --git a/RemoteCam/ProControlsPanel.swift b/RemoteCam/ProControlsPanel.swift deleted file mode 100644 index 438d4a44..00000000 --- a/RemoteCam/ProControlsPanel.swift +++ /dev/null @@ -1,286 +0,0 @@ -// -// ProControlsPanel.swift -// RemoteShutter -// -// The pro-controls bottom panel on the monitor: manual exposure (shutter + -// ISO) and Cinematic video (iOS 26 simulated aperture). Every value shown is -// the CAMERA's echoed truth — the panel never displays a value the camera -// did not confirm. Controls are rendered only when the camera advertised the -// capability. Pure values in, intents out: the 1:1 monitor feeds it from -// `MonitorViewModel`, the multicam director from the focused lane. -// See Docs/pro-controls.md. -// - -import SwiftUI - -struct ProControlsPanel: View { - let supportsManualExposure: Bool - let exposure: ExposureState? - let supportsCinematicVideo: Bool - let cinematic: CinematicState? - /// Cinematic is a video effect: its section exists only in video modes. - let isVideoMode: Bool - let onExposureChange: (ExposureIntent) -> Void - let onCinematicChange: (CinematicIntent) -> Void - - init(supportsManualExposure: Bool, exposure: ExposureState?, - supportsCinematicVideo: Bool, cinematic: CinematicState?, - isVideoMode: Bool, - onExposureChange: @escaping (ExposureIntent) -> Void, - onCinematicChange: @escaping (CinematicIntent) -> Void) { - self.supportsManualExposure = supportsManualExposure - self.exposure = exposure - self.supportsCinematicVideo = supportsCinematicVideo - self.cinematic = cinematic - self.isVideoMode = isVideoMode - self.onExposureChange = onExposureChange - self.onCinematicChange = onCinematicChange - } - - /// The 1:1 monitor's projection. `MonitorView` observes the view model, - /// so the panel is rebuilt with fresh values on every echo. - init(viewModel: MonitorViewModel, - onExposureChange: @escaping (ExposureIntent) -> Void, - onCinematicChange: @escaping (CinematicIntent) -> Void) { - self.init(supportsManualExposure: viewModel.supportsManualExposure, - exposure: viewModel.exposure, - supportsCinematicVideo: viewModel.supportsCinematicVideo, - cinematic: viewModel.cinematic, - isVideoMode: viewModel.uiState == .videoMode || viewModel.uiState == .videoRecording, - onExposureChange: onExposureChange, - onCinematicChange: onCinematicChange) - } - - var body: some View { - VStack(spacing: 16) { - Capsule() - .fill(Color.white.opacity(0.3)) - .frame(width: 36, height: 5) - - if supportsManualExposure, let exposure { - exposureSection(exposure) - } - - if isVideoMode, supportsCinematicVideo, let cinematic { - if supportsManualExposure { - Divider().overlay(Color.white.opacity(0.15)) - } - cinematicSection(cinematic) - } - } - .padding(.top, 10) - .padding(.horizontal, 20) - .padding(.bottom, 28) - .frame(maxWidth: .infinity) - .background( - RoundedRectangle(cornerRadius: 24, style: .continuous) - .fill(.ultraThinMaterial) - .ignoresSafeArea(edges: .bottom) - ) - } - - // MARK: - Exposure - - @ViewBuilder - private func exposureSection(_ exposure: ExposureState) -> some View { - HStack { - sectionTitle(NSLocalizedString("EXPOSURE", comment: "pro panel section")) - Spacer() - Picker("", selection: Binding( - get: { exposure.mode == .manual }, - set: { manual in - // Manual with zeros = "lock what auto is doing right now", - // so the dials pick up from a correctly exposed frame. - onExposureChange(manual ? .manual(durationSeconds: 0, iso: 0) : .auto) - })) { - Text(NSLocalizedString("Auto", comment: "exposure mode")).tag(false) - Text(NSLocalizedString("Manual", comment: "exposure mode")).tag(true) - } - .pickerStyle(.segmented) - .frame(width: 170) - } - - if exposure.mode == .manual { - ProDial( - caption: NSLocalizedString("SHUTTER", comment: "pro dial"), - stops: ProDialStops.shutterStops(min: exposure.minDurationSeconds, - max: exposure.maxDurationSeconds), - value: exposure.durationSeconds, - label: { ProDialStops.shutterLabel($0) }, - accessibilityValue: { seconds in - String(format: NSLocalizedString("%@ second", comment: "shutter a11y"), - ProDialStops.shutterLabel(seconds)) - }, - onSelect: { onExposureChange(.manual(durationSeconds: $0, iso: 0)) }) - - ProDial( - caption: "ISO", - stops: ProDialStops.isoStops(min: exposure.minISO, max: exposure.maxISO) - .map { Double($0) }, - value: Double(exposure.iso), - label: { String(Int($0.rounded())) }, - accessibilityValue: { "ISO \(Int($0.rounded()))" }, - onSelect: { onExposureChange(.manual(durationSeconds: 0, iso: Float($0))) }) - } else { - // What auto is choosing right now — what Manual would take over. - Text("\(ProDialStops.shutterLabel(exposure.durationSeconds)) · \(ProDialStops.isoLabel(exposure.iso))") - .font(.system(size: 14, weight: .semibold, design: .monospaced)) - .foregroundColor(.white.opacity(0.7)) - } - } - - // MARK: - Cinematic - - @ViewBuilder - private func cinematicSection(_ cinematic: CinematicState) -> some View { - HStack { - sectionTitle(NSLocalizedString("CINEMATIC", comment: "pro panel section")) - Spacer() - Toggle("", isOn: Binding( - get: { cinematic.enabled }, - set: { isOn in onCinematicChange(isOn ? .on(aperture: nil) : .off) })) - .labelsHidden() - .tint(AppTheme.accent) - // Apple rejects enabling/disabling mid-take. - .disabled(cinematic.apertureLocked) - .accessibilityLabel(NSLocalizedString("Cinematic video", comment: "a11y")) - } - - if cinematic.enabled, cinematic.minSimulatedAperture > 0 { - ProDial( - caption: NSLocalizedString("APERTURE", comment: "pro dial"), - stops: ProDialStops.apertureStops(min: cinematic.minSimulatedAperture, - max: cinematic.maxSimulatedAperture) - .map { Double($0) }, - value: Double(cinematic.simulatedAperture), - label: { ProDialStops.apertureLabel(Float($0)) }, - accessibilityValue: { ProDialStops.apertureLabel(Float($0)) }, - onSelect: { onCinematicChange(.on(aperture: Float($0))) }) - .disabled(cinematic.apertureLocked) - .opacity(cinematic.apertureLocked ? 0.4 : 1) - - if cinematic.apertureLocked { - footnote(NSLocalizedString("Aperture is set before recording", - comment: "cinematic hint")) - } - } - - if cinematic.enabled && cinematic.notEnoughLight { - footnote(NSLocalizedString("Scene too dark for Cinematic", - comment: "cinematic hint")) - } - } - - // MARK: - Bits - - private func sectionTitle(_ text: String) -> some View { - Text(text) - .font(.system(size: 12, weight: .semibold)) - .tracking(1) - .foregroundColor(.white.opacity(0.6)) - } - - private func footnote(_ text: String) -> some View { - Text(text) - .font(.caption) - .foregroundColor(.white.opacity(0.7)) - } -} - -// MARK: - Dial - -/// A detented value dial: chevrons step one stop, dragging scrubs stops with a -/// haptic tick per detent. Shows the camera's echoed value; a step calls -/// `onSelect` with the neighboring stop and waits for the echo to move the -/// label (the remote never claims a state the camera did not confirm). -struct ProDial: View { - let caption: String - let stops: [Double] - let value: Double - let label: (Double) -> String - let accessibilityValue: (Double) -> String - let onSelect: (Double) -> Void - - /// Points of horizontal drag per detent. - private static let dragStride: CGFloat = 24 - - @State private var dragBaseIndex: Int? - @State private var lastDraggedIndex: Int? - - private var currentIndex: Int { ProDialStops.nearestIndex(of: value, in: stops) ?? 0 } - - var body: some View { - HStack(spacing: 14) { - Text(caption) - .font(.system(size: 11, weight: .semibold)) - .tracking(0.5) - .foregroundColor(.white.opacity(0.6)) - .frame(width: 64, alignment: .leading) - - stepButton(systemName: "chevron.left", step: -1) - - Text(stops.isEmpty ? "—" : label(stops[currentIndex])) - .font(.system(size: 17, weight: .bold, design: .monospaced)) - .foregroundColor(.white) - .frame(maxWidth: .infinity) - .contentShape(Rectangle()) - .gesture(dragGesture) - - stepButton(systemName: "chevron.right", step: +1) - } - .frame(minHeight: 44) - .accessibilityElement(children: .ignore) - .accessibilityLabel(caption) - .accessibilityValue(stops.isEmpty ? "" : accessibilityValue(stops[currentIndex])) - .accessibilityAdjustableAction { direction in - switch direction { - case .increment: select(currentIndex + 1) - case .decrement: select(currentIndex - 1) - @unknown default: break - } - } - } - - private func stepButton(systemName: String, step: Int) -> some View { - Button { select(currentIndex + step) } label: { - Image(systemName: systemName) - .font(.system(size: 15, weight: .semibold)) - .foregroundColor(.white.opacity(0.8)) - .frame(width: 44, height: 44) - .contentShape(Rectangle()) - } - } - - private var dragGesture: some Gesture { - DragGesture(minimumDistance: 4) - .onChanged { gesture in - let base = dragBaseIndex ?? currentIndex - dragBaseIndex = base - let offset = Int((gesture.translation.width / Self.dragStride).rounded()) - let target = max(0, min(stops.count - 1, base + offset)) - if target != (lastDraggedIndex ?? base) { - lastDraggedIndex = target - tick() - onSelect(stops[target]) - } - } - .onEnded { _ in - dragBaseIndex = nil - lastDraggedIndex = nil - } - } - - private func select(_ index: Int) { - guard !stops.isEmpty else { return } - let clamped = max(0, min(stops.count - 1, index)) - guard clamped != currentIndex else { return } - tick() - onSelect(stops[clamped]) - } - - private func tick() { - #if !targetEnvironment(macCatalyst) - UISelectionFeedbackGenerator().selectionChanged() - #endif - } -} diff --git a/RemoteCam/ProSliderPill.swift b/RemoteCam/ProSliderPill.swift new file mode 100644 index 00000000..7f0ef262 --- /dev/null +++ b/RemoteCam/ProSliderPill.swift @@ -0,0 +1,160 @@ +// +// ProSliderPill.swift +// RemoteShutter +// +// Manual exposure (shutter, ISO) and Cinematic aperture as viewfinder +// sliders in the zoom pill's slot: the same `RulerPill` zoom uses, with the +// ruler always up and AUTO / × buttons at its ends. This file adds only +// what is pro-specific — which slider, its range and labels from the +// camera's echoed state, and the command a value becomes. +// See Docs/pro-controls.md. +// + +import SwiftUI + +/// Which pro slider sits on the viewfinder (at most one at a time). +enum ProSliderKind: Equatable, CaseIterable { + case shutter + case iso + case aperture + + /// The tray tile that opens this slider. + var tile: MonitorTrayItem { + switch self { + case .shutter: return .shutter + case .iso: return .iso + case .aperture: return .aperture + } + } + + var title: String { + switch self { + case .shutter: return NSLocalizedString("SHUTTER", comment: "pro slider") + case .iso: return "ISO" + case .aperture: return NSLocalizedString("APERTURE", comment: "pro slider") + } + } + + /// The wire intent for a slider value. Shutter and ISO each lock their + /// own component and keep the other as the camera has it (`0` = keep), + /// so dragging one never disturbs the other; aperture rides Cinematic on. + func intent(for value: Double) -> ProControlIntent { + switch self { + case .shutter: return .exposure(.manual(durationSeconds: value, iso: 0)) + case .iso: return .exposure(.manual(durationSeconds: 0, iso: Float(value))) + case .aperture: return .cinematic(.on(aperture: Float(value))) + } + } +} + +/// A pro-control request, typed by the command it becomes. +enum ProControlIntent: Equatable { + case exposure(ExposureIntent) + case cinematic(CinematicIntent) +} + +/// One slider's range and labels, from the camera's echoed state — the pro +/// analog of `ZoomScale`: it owns the units, `LogTrack` owns the ruler. +struct ProSliderScale: Equatable { + let kind: ProSliderKind + let track: LogTrack + + static func shutter(_ exposure: ExposureState) -> ProSliderScale { + ProSliderScale(kind: .shutter, + track: LogTrack(min: exposure.minDurationSeconds, max: exposure.maxDurationSeconds, + stops: ProStops.allShutterSeconds)) + } + + static func iso(_ exposure: ExposureState) -> ProSliderScale { + ProSliderScale(kind: .iso, + track: LogTrack(min: Double(exposure.minISO), max: Double(exposure.maxISO), + stops: ProStops.allISO.map { Double($0) })) + } + + static func aperture(_ cinematic: CinematicState) -> ProSliderScale { + ProSliderScale(kind: .aperture, + track: LogTrack(min: Double(cinematic.minSimulatedAperture), + max: Double(cinematic.maxSimulatedAperture), + stops: ProStops.allApertures.map { Double($0) })) + } + + func label(_ value: Double) -> String { + switch kind { + case .shutter: return ProStops.shutterLabel(value) + case .iso: return ProStops.isoLabel(Float(value)) + case .aperture: return ProStops.apertureLabel(Float(value)) + } + } +} + +// MARK: - Pill + +struct ProSliderPill: View { + let scale: ProSliderScale + /// The camera's confirmed value. + let currentValue: Double + let onChange: (Double) -> Void + /// Exposure sliders offer AUTO (hands exposure back to the camera and + /// closes the pill); the aperture slider has no auto, so nil there. + let onAuto: (() -> Void)? + let onClose: () -> Void + + /// Narrower than zoom's track: the AUTO and × circles share the width. + private static let trackWidth: CGFloat = 200 + + var body: some View { + RulerPill(track: scale.track, + currentValue: currentValue, + readout: { "\(scale.kind.title) \(scale.label($0))" }, + accessibilityLabel: scale.kind.title, + trackWidth: Self.trackWidth, + onChange: onChange, + leading: { + if let onAuto { + PillCircleButton(action: onAuto) { + Text(NSLocalizedString("AUTO", comment: "exposure back to auto")) + .font(.system(size: 10, weight: .bold, design: .rounded)) + } + .accessibilityLabel(NSLocalizedString("Auto exposure", comment: "a11y")) + } + }, + trailing: { + PillCircleButton(action: onClose) { + Image(systemName: "xmark").font(.system(size: 12, weight: .bold)) + } + .accessibilityLabel(NSLocalizedString("Close", comment: "a11y")) + }) + } +} + +// MARK: - Send throttle + +/// A slider's stream of values, rate-limited the way zoom is +/// (`ZoomSendThrottle`: leading edge for responsiveness, trailing edge so the +/// final position always lands). One per slider, owned by the screen's +/// controller; the send closure turns the value into the wire command. +final class ThrottledValueSender { + private var throttle: ZoomSendThrottle + private var trailing: Timer? + private let send: (Double) -> Void + + init(interval: TimeInterval = 0.1, send: @escaping (Double) -> Void) { + throttle = ZoomSendThrottle(interval: interval) + self.send = send + } + + func submit(_ value: Double) { + switch throttle.update(value: value, now: Date()) { + case .sendNow: + send(value) + case .scheduleTrailing: + trailing?.invalidate() + trailing = Timer.scheduledTimer(withTimeInterval: throttle.interval, repeats: false) { [weak self] _ in + guard let self, let pending = self.throttle.fireTrailing(now: Date()) else { return } + self.send(pending) + } + } + } + + deinit { trailing?.invalidate() } +} diff --git a/RemoteCam/RulerPill.swift b/RemoteCam/RulerPill.swift new file mode 100644 index 00000000..835866d4 --- /dev/null +++ b/RemoteCam/RulerPill.swift @@ -0,0 +1,444 @@ +// +// RulerPill.swift +// RemoteShutter +// +// The one ruler control on the monitor. Zoom, shutter, ISO and aperture are +// all "a value on a log-spaced range with detents", so they share the math +// (`LogTrack`) and the pill (`RulerPill`): the glass capsule, the ruler with +// its ticks and thumb, relative drag, scroll wheel on the Mac, the pending +// value that keeps the thumb under the finger until the camera echoes, and +// the VoiceOver adjustable element. `ZoomPill` configures it with its lens +// stops as the collapsed state; `ProSliderPill` with AUTO / × buttons and no +// collapsed state. +// + +import SwiftUI + +// MARK: - Math + +/// A positive range on a 0…1 log2 track with detents. Pure; pinned through +/// `ZoomScaleTests` (via `ZoomScale`) and `ProSliderScaleTests`. +struct LogTrack: Equatable { + let minValue: Double + let maxValue: Double + /// Detents inside the range, ascending. + let stops: [Double] + + init(min: Double, max: Double, stops: [Double]) { + let low = (min.isFinite && min > 0) ? min : 0 + let high = (max.isFinite && max > low) ? max : low + minValue = low + maxValue = high + self.stops = stops.filter { $0.isFinite && $0 >= low && $0 <= high }.sorted() + } + + /// True when there is nothing to slide: no range yet, or a fixed value. + /// Callers must check this before drawing a track. + var isDegenerate: Bool { minValue <= 0 || maxValue <= minValue } + + private var logMin: Double { log2(minValue) } + private var logSpan: Double { log2(maxValue) - logMin } + + func clamped(_ value: Double) -> Double { + guard value.isFinite else { return minValue } + return Swift.max(minValue, Swift.min(maxValue, value)) + } + + /// Where `value` sits on the track. Log2 so equal travel is equal + /// perceived change anywhere on the range (one stop is one distance). + func position(for value: Double) -> Double { + guard !isDegenerate else { return 0 } + return (log2(clamped(value)) - logMin) / logSpan + } + + func value(atPosition position: Double) -> Double { + guard !isDegenerate, position.isFinite else { return minValue } + let clampedPosition = Swift.max(0, Swift.min(1, position)) + // Exact at the ends: round-tripping through log2/pow2 leaves a max of + // 5.0 as 4.999999999999999, so a drag to the end of the ruler would + // stop a hair short and never compare equal to `maxValue`. + if clampedPosition <= 0 { return minValue } + if clampedPosition >= 1 { return maxValue } + return clamped(pow(2, logMin + clampedPosition * logSpan)) + } + + /// Snaps to the nearest detent when within `tolerance` of it. Tolerance + /// is a fraction of the track, so the pull feels identical everywhere. + func snappedToStop(_ value: Double, tolerance: Double = 0.04) -> Double { + guard !isDegenerate else { return minValue } + let target = clamped(value) + let targetPosition = position(for: target) + let nearest = stops.min { + abs(position(for: $0) - targetPosition) < abs(position(for: $1) - targetPosition) + } + guard let stop = nearest, abs(position(for: stop) - targetPosition) <= tolerance else { return target } + return stop + } +} + +// MARK: - Pill + +/// What a pill's collapsed content can read and do: the value the pill is +/// drawing (in-flight or confirmed) and a way to jump to one. +struct RulerPillProxy { + let displayedValue: Double + let commit: (Double) -> Void +} + +struct RulerPill: View { + let track: LogTrack + /// The camera's confirmed value. + let currentValue: Double + /// The readout above the ruler, e.g. "2.4×" or "SHUTTER 1/125". + let readout: (Double) -> String + let accessibilityLabel: String + /// Collapse to `collapsed` when idle (zoom's lens stops) or stay up. + let collapsesWhenIdle: Bool + /// The collapsed content's width, so the capsule can animate between + /// its two widths; nil sizes to the content. + let collapsedWidth: CGFloat? + let trackWidth: CGFloat + let onChange: (Double) -> Void + let collapsed: (RulerPillProxy) -> Collapsed + let leading: () -> Leading + let trailing: () -> Trailing + + @State private var isExpanded: Bool + @State private var collapseWork: DispatchWorkItem? + /// What the user just asked for, shown immediately. `currentValue` only + /// catches up when the camera's response returns — a throttled send plus + /// a peer-to-peer round trip — so without this the thumb trails the cursor. + @State private var pendingValue: Double? + @State private var isAdjusting = false + /// Track position when the current drag began; movement is a delta. + @State private var dragStartPosition: Double? + + static var height: CGFloat { 46 } + private static var horizontalPadding: CGFloat { 14 } + private static var thumbWidth: CGFloat { 3 } + /// Track fraction per point of scroll: a ~10pt wheel notch moves ~3%. + private static var scrollSensitivity: Double { 0.003 } + private static var tickCount: Int { 41 } + /// How long the ruler lingers after a drag, so a repeated adjustment + /// doesn't have to re-expand each time. + private static var collapseDelay: TimeInterval { 1.2 } + + init(track: LogTrack, + currentValue: Double, + readout: @escaping (Double) -> String, + accessibilityLabel: String, + collapsesWhenIdle: Bool, + collapsedWidth: CGFloat? = nil, + trackWidth: CGFloat = 240, + onChange: @escaping (Double) -> Void, + @ViewBuilder collapsed: @escaping (RulerPillProxy) -> Collapsed, + @ViewBuilder leading: @escaping () -> Leading, + @ViewBuilder trailing: @escaping () -> Trailing) { + self.track = track + self.currentValue = currentValue + self.readout = readout + self.accessibilityLabel = accessibilityLabel + self.collapsesWhenIdle = collapsesWhenIdle + self.collapsedWidth = collapsedWidth + self.trackWidth = trackWidth + self.onChange = onChange + self.collapsed = collapsed + self.leading = leading + self.trailing = trailing + _isExpanded = State(initialValue: !collapsesWhenIdle) + } + + var body: some View { + HStack(spacing: 10) { + leading() + + ZStack { + if isExpanded { + ruler + } else { + collapsed(RulerPillProxy(displayedValue: displayedValue, commit: commit)) + } + } + // Collapsed, the pill is only as wide as its content; it grows to + // the full track while the ruler is up. + .frame(width: isExpanded ? trackWidth : collapsedWidth, height: Self.height) + .accessibilityElement(children: .ignore) + .accessibilityLabel(accessibilityLabel) + .accessibilityValue(readout(displayedValue)) + .accessibilityAdjustableAction { direction in + let step = 0.05 + let position = track.position(for: displayedValue) + switch direction { + case .increment: commit(track.value(atPosition: position + step)) + case .decrement: commit(track.value(atPosition: position - step)) + @unknown default: break + } + } + + trailing() + } + .padding(.horizontal, Self.horizontalPadding) + .background(glassBackground) + // Scrolling over the pill adjusts — reaching for the wheel is the + // reflex on a Mac. Behind the content so it never intercepts the drag. + .background( + ScrollWheelCatcher(onScroll: handleScroll, + onEnded: { + isAdjusting = false + scheduleCollapse() + }) + ) + // The whole pill is draggable, not just the track, so there is no + // thin target to hunt for with a mouse. + .contentShape(Rectangle()) + .gesture(dragGesture) + .animation(.easeOut(duration: 0.18), value: isExpanded) + .opacity(track.isDegenerate ? 0 : 1) + .allowsHitTesting(!track.isDegenerate) + // Hand control back to the camera once it confirms, but never + // mid-drag: a response for an earlier value would yank the thumb + // backwards under the cursor. + .onChange(of: currentValue) { _ in + if !isAdjusting { pendingValue = nil } + } + } + + /// The value the pill draws: the user's in-flight value if there is one, + /// otherwise whatever the camera last confirmed. + private var displayedValue: Double { pendingValue ?? currentValue } + + // MARK: Ruler + + private var ruler: some View { + VStack(spacing: 4) { + Text(readout(displayedValue)) + .font(.system(size: 12, weight: .semibold, design: .rounded)) + .foregroundColor(.white) + .monospacedDigitIfAvailable() + + ZStack(alignment: .leading) { + ticks + RoundedRectangle(cornerRadius: Self.thumbWidth / 2) + .fill(AppTheme.accent) + .frame(width: Self.thumbWidth, height: 20) + .shadow(color: AppTheme.accent.opacity(0.5), radius: 3) + .offset(x: CGFloat(track.position(for: displayedValue)) * (trackWidth - Self.thumbWidth)) + } + .frame(width: trackWidth, height: 20, alignment: .leading) + } + } + + private var ticks: some View { + HStack(spacing: 0) { + ForEach(0.. Bool { + let spacing = 1.0 / Double(Self.tickCount - 1) + let position = Double(index) * spacing + return track.stops.contains { abs(track.position(for: $0) - position) < spacing / 2 } + } + + // MARK: Interaction + + /// The value moves *relative* to where it was when the drag began, not + /// to the absolute position under the finger: the pill may be narrower + /// than the track while collapsed, and picking up from the current value + /// is what the Camera app's ruler does — a small correction stays small. + private var dragGesture: some Gesture { + DragGesture(minimumDistance: 2) + .onChanged { value in + cancelCollapse() + isAdjusting = true + let start: Double + if let existing = dragStartPosition { + start = existing + } else { + start = track.position(for: displayedValue) + dragStartPosition = start + isExpanded = true + } + let moved = start + Double(value.translation.width) / Double(trackWidth) + commit(track.snappedToStop(track.value(atPosition: moved))) + } + .onEnded { _ in + dragStartPosition = nil + isAdjusting = false + scheduleCollapse() + } + } + + /// Mouse wheel / trackpad: nudge along the track from the current value. + /// Scrolling up (negative delta) increases, matching Maps and Photos. + private func handleScroll(_ delta: CGFloat) { + guard !track.isDegenerate else { return } + cancelCollapse() + isAdjusting = true + if !isExpanded { isExpanded = true } + let position = track.position(for: displayedValue) + let moved = position - Double(delta) * Self.scrollSensitivity + commit(track.snappedToStop(track.value(atPosition: moved))) + } + + private func commit(_ value: Double) { + guard !track.isDegenerate else { return } + pendingValue = value + onChange(value) + } + + private func scheduleCollapse() { + guard collapsesWhenIdle else { return } + cancelCollapse() + let work = DispatchWorkItem { isExpanded = false } + collapseWork = work + DispatchQueue.main.asyncAfter(deadline: .now() + Self.collapseDelay, execute: work) + } + + private func cancelCollapse() { + collapseWork?.cancel() + collapseWork = nil + } + + // MARK: Chrome + + private var glassBackground: some View { + ZStack { + Color.black.opacity(0.3) + .background(.ultraThinMaterial) + .clipShape(Capsule()) + Capsule().stroke(Color.white.opacity(0.25), lineWidth: 1) + } + } +} + +extension RulerPill where Leading == EmptyView, Trailing == EmptyView { + /// A pill that collapses to its own content when idle (zoom). + init(track: LogTrack, + currentValue: Double, + readout: @escaping (Double) -> String, + accessibilityLabel: String, + collapsedWidth: CGFloat?, + onChange: @escaping (Double) -> Void, + @ViewBuilder collapsed: @escaping (RulerPillProxy) -> Collapsed) { + self.init(track: track, currentValue: currentValue, readout: readout, + accessibilityLabel: accessibilityLabel, collapsesWhenIdle: true, + collapsedWidth: collapsedWidth, onChange: onChange, + collapsed: collapsed, leading: { EmptyView() }, trailing: { EmptyView() }) + } +} + +extension RulerPill where Collapsed == EmptyView { + /// A pill whose ruler is always up, with buttons at either end. + init(track: LogTrack, + currentValue: Double, + readout: @escaping (Double) -> String, + accessibilityLabel: String, + trackWidth: CGFloat, + onChange: @escaping (Double) -> Void, + @ViewBuilder leading: @escaping () -> Leading, + @ViewBuilder trailing: @escaping () -> Trailing) { + self.init(track: track, currentValue: currentValue, readout: readout, + accessibilityLabel: accessibilityLabel, collapsesWhenIdle: false, + trackWidth: trackWidth, onChange: onChange, + collapsed: { _ in EmptyView() }, leading: leading, trailing: trailing) + } +} + +/// A round button inside a pill: zoom's lens stops, the pro pill's AUTO / ×. +/// A tap gesture rather than a `Button` so it never competes with the pill's +/// drag for the touch. +struct PillCircleButton: View { + var isActive = false + let action: () -> Void + @ViewBuilder let label: () -> Label + + static var diameter: CGFloat { 32 } + + var body: some View { + label() + .foregroundColor(isActive ? .black : .white.opacity(0.85)) + .frame(width: Self.diameter, height: Self.diameter) + .background(Circle().fill(isActive ? AppTheme.accent : Color.white.opacity(0.12))) + .contentShape(Circle()) + .onTapGesture(perform: action) + } +} + +/// Delivers mouse-wheel and trackpad scrolls to SwiftUI, which has no +/// gesture for them. A `UIPanGestureRecognizer` with `allowedScrollTypesMask` +/// is UIKit's way to receive indirect scrolls; `allowedTouchTypes = []` makes +/// it scroll-only so it cannot compete with the pill's `DragGesture`. +struct ScrollWheelCatcher: UIViewRepresentable { + /// Vertical scroll delta in points, positive when scrolling down. + let onScroll: (CGFloat) -> Void + let onEnded: () -> Void + + func makeUIView(context: Context) -> UIView { + let view = UIView() + view.backgroundColor = .clear + let pan = UIPanGestureRecognizer(target: context.coordinator, + action: #selector(Coordinator.handleScroll(_:))) + pan.allowedScrollTypesMask = .all + pan.allowedTouchTypes = [] // scroll events only — leave touches to SwiftUI + view.addGestureRecognizer(pan) + return view + } + + func updateUIView(_ uiView: UIView, context: Context) { + context.coordinator.onScroll = onScroll + context.coordinator.onEnded = onEnded + } + + func makeCoordinator() -> Coordinator { Coordinator(onScroll: onScroll, onEnded: onEnded) } + + final class Coordinator: NSObject { + var onScroll: (CGFloat) -> Void + var onEnded: () -> Void + /// `translation` is cumulative for the gesture; the pill wants deltas. + private var lastTranslation: CGFloat = 0 + + init(onScroll: @escaping (CGFloat) -> Void, onEnded: @escaping () -> Void) { + self.onScroll = onScroll + self.onEnded = onEnded + } + + @objc func handleScroll(_ pan: UIPanGestureRecognizer) { + switch pan.state { + case .began: + lastTranslation = 0 + case .changed: + let translation = pan.translation(in: pan.view).y + onScroll(translation - lastTranslation) + lastTranslation = translation + case .ended, .cancelled, .failed: + lastTranslation = 0 + onEnded() + default: + break + } + } + } +} + +extension View { + /// The ruler's readout changes every frame during a drag; monospaced + /// digits stop it jittering. `.monospacedDigit()` is iOS 16+, and the + /// deployment target is 15. + @ViewBuilder func monospacedDigitIfAvailable() -> some View { + if #available(iOS 16.0, *) { + self.monospacedDigit() + } else { + self + } + } +} diff --git a/RemoteCam/ZoomPill.swift b/RemoteCam/ZoomPill.swift index 924a5564..e314125c 100644 --- a/RemoteCam/ZoomPill.swift +++ b/RemoteCam/ZoomPill.swift @@ -7,28 +7,14 @@ import SwiftUI /// releasing collapses it again. This is the single lens/zoom affordance on every /// platform: on a mouse-only Mac it's the only way to zoom at all (`MagnificationGesture` /// fires only from a trackpad pinch), and on iPhone and iPad it sits alongside pinch. -/// All zoom math is delegated to `ZoomScale`, which the pinch gesture shares. +/// The ruler, drag, scroll wheel and pending-value handling are `RulerPill`'s (shared +/// with the pro sliders); all zoom math is `ZoomScale`'s, which the pinch gesture shares. struct ZoomPill: View { let scale: ZoomScale /// Current zoom in hardware factors, as reported by the camera. let currentZoomFactor: CGFloat let onZoomChange: (CGFloat) -> Void - @State private var isExpanded = false - @State private var collapseWork: DispatchWorkItem? - /// What the user just asked for, shown immediately. `currentZoomFactor` only catches - /// up when the camera's SetZoomResp returns — a throttled send plus a peer-to-peer - /// round trip — so without this the thumb visibly trails the cursor. - @State private var pendingZoom: CGFloat? - @State private var isAdjusting = false - /// Track position (0…1) when the current drag began, so movement is applied - /// as a delta. Nil when no drag is in flight. - @State private var dragStartPosition: Double? - - private static let trackWidth: CGFloat = 240 - private static let horizontalPadding: CGFloat = 14 - private static let height: CGFloat = 46 - private static let stopDiameter: CGFloat = 32 /// Gap between adjacent lens circles when collapsed. The stops sit in a tight /// cluster rather than spread along the track: a lens button is a *choice*, /// not a position, and spacing them by their zoom factor left ragged gaps @@ -36,112 +22,49 @@ struct ZoomPill: View { private static let stopSpacing: CGFloat = 10 /// Breathing room between the number and the circle's edge. private static let stopTextInset: CGFloat = 5 - private static let thumbWidth: CGFloat = 3 - /// Track fraction travelled per point of scroll. A wheel notch is ~10pt, so a notch - /// moves ~3% of the range — fine enough to land on a value, coarse enough to cross - /// the whole range without spinning forever. - private static let scrollSensitivity: Double = 0.003 - private static let tickCount = 41 - /// How long the ruler lingers after the drag ends, so a repeated adjustment - /// doesn't have to re-expand each time. - private static let collapseDelay: TimeInterval = 1.2 var body: some View { - ZStack { - if isExpanded { - ruler - } else { - stopRow - } - } - // Collapsed, the pill is only as wide as its lens circles; it grows to the - // full track only while the ruler is up. A fixed track-width capsule sat - // there at 268pt permanently, which is a lot of viewfinder to spend on - // three buttons. - .frame(width: isExpanded ? Self.trackWidth : collapsedWidth, height: Self.height) - .padding(.horizontal, Self.horizontalPadding) - .background(glassBackground) - // Scrolling over the pill zooms — reaching for the wheel is the reflex on a Mac. - // Behind the content so it never intercepts the drag. - .background( - ScrollWheelCatcher(onScroll: handleScroll, - onEnded: { - isAdjusting = false - scheduleCollapse() - }) - ) - // The whole pill is draggable, not just the track, so there is no thin - // target to hunt for with a mouse. - .contentShape(Rectangle()) - .gesture(dragGesture) - .animation(.easeOut(duration: 0.18), value: isExpanded) - .opacity(scale.isDegenerate ? 0 : 1) - .allowsHitTesting(!scale.isDegenerate) - // Hand control back to the camera once it confirms, but never mid-drag: a - // response for an earlier value would yank the thumb backwards under the cursor. - .onChange(of: currentZoomFactor) { _ in - if !isAdjusting { pendingZoom = nil } - } - .accessibilityElement(children: .ignore) - .accessibilityLabel("Zoom") - .accessibilityValue(scale.label(forHardware: displayedZoom)) - .accessibilityAdjustableAction { direction in - let step = 0.05 - let position = scale.position(forHardware: displayedZoom) - switch direction { - case .increment: commit(scale.hardwareFactor(atPosition: position + step)) - case .decrement: commit(scale.hardwareFactor(atPosition: position - step)) - @unknown default: break - } - } + RulerPill(track: scale.track, + currentValue: Double(currentZoomFactor), + readout: { scale.label(forHardware: CGFloat($0)) }, + accessibilityLabel: "Zoom", + collapsedWidth: collapsedWidth, + onChange: { onZoomChange(CGFloat($0)) }, + collapsed: { proxy in stopRow(proxy) }) } // MARK: - Collapsed: the lens stops - private var stopRow: some View { - HStack(spacing: Self.stopSpacing) { + private func stopRow(_ proxy: RulerPillProxy) -> some View { + let active = activeStop(displayed: CGFloat(proxy.displayedValue)) + return HStack(spacing: Self.stopSpacing) { ForEach(scale.stops, id: \.self) { stop in - stopButton(stop) + PillCircleButton(isActive: stop == active, + action: { proxy.commit(Double(scale.clamped(stop))) }) { + Text(labelText(for: stop, active: active, displayed: CGFloat(proxy.displayedValue))) + .font(.system(size: stop == active ? 11.5 : 11, weight: .semibold, design: .rounded)) + .lineLimit(1) + // The active circle reads out the live factor, so it can be as wide + // as "2.4×" where a stop's own name is just "1×". Scale the wide one + // down to fit rather than letting it spill past the circle, and keep + // an inset so glyphs never touch the edge. + .minimumScaleFactor(0.7) + .padding(.horizontal, Self.stopTextInset) + } } } } - /// The cluster's intrinsic width, which the pill collapses to. Held as a - /// number rather than left to `fit` so the capsule can animate between the - /// two widths. + /// The cluster's intrinsic width, which the pill collapses to. private var collapsedWidth: CGFloat { let count = CGFloat(scale.stops.count) - guard count > 0 else { return Self.stopDiameter } - return count * Self.stopDiameter + (count - 1) * Self.stopSpacing - } - - private func stopButton(_ stop: CGFloat) -> some View { - let isActive = stop == activeStop - return Text(labelText(for: stop)) - .font(.system(size: isActive ? 11.5 : 11, weight: .semibold, design: .rounded)) - .foregroundColor(isActive ? .black : .white.opacity(0.85)) - .lineLimit(1) - // The active circle reads out the live factor, so it can be as wide as "2.4×" - // where a stop's own name is just "1×". Scale the wide one down to fit rather - // than letting it spill past the circle, and keep an inset so glyphs never - // touch the edge. (Sizing the text before the frame is what bounds it.) - .minimumScaleFactor(0.7) - .padding(.horizontal, Self.stopTextInset) - .frame(width: Self.stopDiameter, height: Self.stopDiameter) - .background( - Circle().fill(isActive ? AppTheme.accent : Color.white.opacity(0.12)) - ) - .contentShape(Circle()) - .onTapGesture { commit(scale.clamped(stop)) } + guard count > 0 else { return PillCircleButton.diameter } + return count * PillCircleButton.diameter + (count - 1) * Self.stopSpacing } - /// The zoom the pill draws: the user's in-flight value if there is one, otherwise - /// whatever the camera last confirmed. - private var displayedZoom: CGFloat { pendingZoom ?? currentZoomFactor } - /// The stop the pill highlights: whichever is nearest on the track. - private var activeStop: CGFloat? { - let position = scale.position(forHardware: displayedZoom) + private func activeStop(displayed: CGFloat) -> CGFloat? { + let position = scale.position(forHardware: displayed) return scale.stops.min { abs(scale.position(forHardware: $0) - position) < abs(scale.position(forHardware: $1) - position) @@ -150,203 +73,8 @@ struct ZoomPill: View { /// The active stop reads out the live factor ("2.4×") when zoom sits between stops, /// and its own name ("2×") when parked on it — same as the Camera app. - private func labelText(for stop: CGFloat) -> String { - guard stop == activeStop else { return scale.label(forHardware: stop) } - return scale.label(forHardware: displayedZoom) - } - - // MARK: - Expanded: the ruler - - private var ruler: some View { - VStack(spacing: 4) { - Text(scale.label(forHardware: displayedZoom)) - .font(.system(size: 12, weight: .semibold, design: .rounded)) - .foregroundColor(.white) - .monospacedDigitIfAvailable() - - ZStack(alignment: .leading) { - ticks - RoundedRectangle(cornerRadius: Self.thumbWidth / 2) - .fill(AppTheme.accent) - .frame(width: Self.thumbWidth, height: 20) - .shadow(color: AppTheme.accent.opacity(0.5), radius: 3) - .offset(x: offset(forHardware: displayedZoom, itemWidth: Self.thumbWidth)) - } - .frame(width: Self.trackWidth, height: 20, alignment: .leading) - } - } - - private var ticks: some View { - HStack(spacing: 0) { - ForEach(0.. Bool { - let spacing = 1.0 / Double(Self.tickCount - 1) - let position = Double(index) * spacing - return scale.stops.contains { - abs(scale.position(forHardware: $0) - position) < spacing / 2 - } - } - - // MARK: - Geometry - - private func offset(forHardware hardware: CGFloat, itemWidth: CGFloat) -> CGFloat { - CGFloat(scale.position(forHardware: hardware)) * (Self.trackWidth - itemWidth) - } - - // MARK: - Interaction - - /// Zoom moves *relative* to where it was when the drag began, rather than - /// jumping to the absolute position under the finger. Two reasons: the pill - /// is narrower than the track while collapsed, so an absolute mapping would - /// read the first event in the wrong coordinate space and snap somewhere - /// unintended; and picking up from the current value is what the Camera - /// app's ruler does, so a small correction stays a small correction. - private var dragGesture: some Gesture { - DragGesture(minimumDistance: 2) - .onChanged { value in - cancelCollapse() - isAdjusting = true - let start: Double - if let existing = dragStartPosition { - start = existing - } else { - start = scale.position(forHardware: displayedZoom) - dragStartPosition = start - isExpanded = true - } - let moved = start + Double(value.translation.width) / Double(Self.trackWidth) - commit(scale.snappedToStop(scale.hardwareFactor(atPosition: moved))) - } - .onEnded { _ in - dragStartPosition = nil - isAdjusting = false - scheduleCollapse() - } - } - - /// Mouse wheel / trackpad scroll: nudge along the track from wherever zoom is now. - /// Scrolling up (negative delta) zooms in, matching the direction the content appears - /// to move in Maps and Photos. - private func handleScroll(_ delta: CGFloat) { - guard !scale.isDegenerate else { return } - cancelCollapse() - // Same as a drag: hold off the camera's confirmations until the user stops, or a - // response for an earlier value resets pendingZoom and the next scroll steps from - // a stale position. - isAdjusting = true - if !isExpanded { isExpanded = true } - let position = scale.position(forHardware: displayedZoom) - let moved = position - Double(delta) * Self.scrollSensitivity - commit(scale.snappedToStop(scale.hardwareFactor(atPosition: moved))) - } - - private func commit(_ hardware: CGFloat) { - guard !scale.isDegenerate else { return } - pendingZoom = hardware - onZoomChange(hardware) - } - - private func scheduleCollapse() { - cancelCollapse() - let work = DispatchWorkItem { isExpanded = false } - collapseWork = work - DispatchQueue.main.asyncAfter(deadline: .now() + Self.collapseDelay, execute: work) - } - - private func cancelCollapse() { - collapseWork?.cancel() - collapseWork = nil - } - - // MARK: - Chrome - - private var glassBackground: some View { - ZStack { - Color.black.opacity(0.3) - .background(.ultraThinMaterial) - .clipShape(Capsule()) - Capsule().stroke(Color.white.opacity(0.25), lineWidth: 1) - } - } -} - -/// Delivers mouse-wheel and trackpad scrolls to SwiftUI, which has no gesture for them. -/// -/// A `UIPanGestureRecognizer` with `allowedScrollTypesMask` is UIKit's way to receive -/// indirect scrolls. `allowedTouchTypes = []` makes it a scroll-only recognizer, so it -/// cannot compete with the pill's `DragGesture` for click-drags. -private struct ScrollWheelCatcher: UIViewRepresentable { - /// Vertical scroll delta in points, positive when scrolling down. - let onScroll: (CGFloat) -> Void - let onEnded: () -> Void - - func makeUIView(context: Context) -> UIView { - let view = UIView() - view.backgroundColor = .clear - let pan = UIPanGestureRecognizer(target: context.coordinator, - action: #selector(Coordinator.handleScroll(_:))) - pan.allowedScrollTypesMask = .all - pan.allowedTouchTypes = [] // scroll events only — leave touches to SwiftUI - view.addGestureRecognizer(pan) - return view - } - - func updateUIView(_ uiView: UIView, context: Context) { - context.coordinator.onScroll = onScroll - context.coordinator.onEnded = onEnded - } - - func makeCoordinator() -> Coordinator { Coordinator(onScroll: onScroll, onEnded: onEnded) } - - final class Coordinator: NSObject { - var onScroll: (CGFloat) -> Void - var onEnded: () -> Void - /// `translation` is cumulative for the gesture; the pill wants per-event deltas. - private var lastTranslation: CGFloat = 0 - - init(onScroll: @escaping (CGFloat) -> Void, onEnded: @escaping () -> Void) { - self.onScroll = onScroll - self.onEnded = onEnded - } - - @objc func handleScroll(_ pan: UIPanGestureRecognizer) { - switch pan.state { - case .began: - lastTranslation = 0 - case .changed: - let translation = pan.translation(in: pan.view).y - onScroll(translation - lastTranslation) - lastTranslation = translation - case .ended, .cancelled, .failed: - lastTranslation = 0 - onEnded() - default: - break - } - } - } -} - -private extension View { - /// The ruler's readout changes every frame during a drag; monospaced digits stop it - /// jittering. `.monospacedDigit()` is iOS 16+, and the deployment target is 15. - @ViewBuilder func monospacedDigitIfAvailable() -> some View { - if #available(iOS 16.0, *) { - self.monospacedDigit() - } else { - self - } + private func labelText(for stop: CGFloat, active: CGFloat?, displayed: CGFloat) -> String { + guard stop == active else { return scale.label(forHardware: stop) } + return scale.label(forHardware: displayed) } } diff --git a/RemoteCam/ZoomScale.swift b/RemoteCam/ZoomScale.swift index c8a1bde4..965b19d6 100644 --- a/RemoteCam/ZoomScale.swift +++ b/RemoteCam/ZoomScale.swift @@ -40,12 +40,13 @@ struct ZoomScale: Equatable { /// check this before drawing a track — a SwiftUI `Slider` traps on an empty range. var isDegenerate: Bool { maxZoom <= minZoom } - private var logMin: Double { Double(log2(minZoom)) } - private var logSpan: Double { Double(log2(maxZoom)) - logMin } + /// The ruler math in hardware factors — what the pill draws and drags. + var track: LogTrack { + LogTrack(min: Double(minZoom), max: Double(maxZoom), stops: stops.map { Double($0) }) + } func clamped(_ hardware: CGFloat) -> CGFloat { - guard hardware.isFinite else { return minZoom } - return max(minZoom, min(maxZoom, hardware)) + CGFloat(track.clamped(Double(hardware))) } // MARK: - Display units @@ -71,19 +72,11 @@ struct ZoomScale: Equatable { /// Where `hardware` sits on a 0…1 track. Log2 so equal travel is equal perceived /// change at 1× and at 5×, matching the pinch curve. func position(forHardware hardware: CGFloat) -> Double { - guard !isDegenerate else { return 0 } - return (Double(log2(clamped(hardware))) - logMin) / logSpan + track.position(for: Double(hardware)) } func hardwareFactor(atPosition position: Double) -> CGFloat { - guard !isDegenerate, position.isFinite else { return minZoom } - let clampedPosition = max(0, min(1, position)) - // Exact at the ends. Round-tripping through log2/pow2 leaves a max of 5.0 as - // 4.999999999999999, so a drag to the end of the ruler would stop a hair short - // of the ceiling and never compare equal to `maxZoom`. - if clampedPosition <= 0 { return minZoom } - if clampedPosition >= 1 { return maxZoom } - return clamped(CGFloat(pow(2, logMin + clampedPosition * logSpan))) + CGFloat(track.value(atPosition: position)) } // MARK: - Detents @@ -91,16 +84,7 @@ struct ZoomScale: Equatable { /// Snaps to the nearest stop when within `tolerance` of it. Tolerance is a fraction of /// the whole track, not a zoom delta, so the pull feels identical at 1× and at 5×. func snappedToStop(_ hardware: CGFloat, tolerance: Double = 0.04) -> CGFloat { - guard !isDegenerate else { return minZoom } - let target = clamped(hardware) - let targetPosition = position(forHardware: target) - let nearest = stops.min { - abs(position(forHardware: $0) - targetPosition) - < abs(position(forHardware: $1) - targetPosition) - } - guard let stop = nearest, - abs(position(forHardware: stop) - targetPosition) <= tolerance else { return target } - return stop + CGFloat(track.snappedToStop(Double(hardware), tolerance: tolerance)) } // MARK: - Pinch diff --git a/RemoteCamTests/CinematicPolicyTests.swift b/RemoteCamTests/CinematicPolicyTests.swift index 82439e85..4460eb2d 100644 --- a/RemoteCamTests/CinematicPolicyTests.swift +++ b/RemoteCamTests/CinematicPolicyTests.swift @@ -70,35 +70,35 @@ final class CinematicPolicyTests: XCTestCase { // MARK: - Dial stops func testShutterStopsFilterToRange() { - let stops = ProDialStops.shutterStops(min: 1.0 / 10_000, max: 1.0 / 3) + let stops = ProStops.shutterStops(min: 1.0 / 10_000, max: 1.0 / 3) XCTAssertEqual(stops.first, 1.0 / 8000) XCTAssertEqual(stops.last, 1.0 / 3) XCTAssertFalse(stops.contains(0.5)) } func testISOStopsFilterToRange() { - let stops = ProDialStops.isoStops(min: 32, max: 3200) + let stops = ProStops.isoStops(min: 32, max: 3200) XCTAssertEqual(stops.first, 32) XCTAssertEqual(stops.last, 3200) } func testApertureStopsEmptyForFixedAperture() { - XCTAssertTrue(ProDialStops.apertureStops(min: 0, max: 0).isEmpty) - XCTAssertEqual(ProDialStops.apertureStops(min: 1.4, max: 16).first, 1.4) + XCTAssertTrue(ProStops.apertureStops(min: 0, max: 0).isEmpty) + XCTAssertEqual(ProStops.apertureStops(min: 1.4, max: 16).first, 1.4) } func testNearestIndexSnapsToClosestDetent() { let stops: [Double] = [1.0 / 250, 1.0 / 125, 1.0 / 60] - XCTAssertEqual(ProDialStops.nearestIndex(of: 1.0 / 120, in: stops), 1) - XCTAssertNil(ProDialStops.nearestIndex(of: 1.0, in: [Double]())) + XCTAssertEqual(ProStops.nearestIndex(of: 1.0 / 120, in: stops), 1) + XCTAssertNil(ProStops.nearestIndex(of: 1.0, in: [Double]())) } func testLabels() { - XCTAssertEqual(ProDialStops.shutterLabel(1.0 / 125), "1/125") - XCTAssertEqual(ProDialStops.shutterLabel(0.5), "0.5s") - XCTAssertEqual(ProDialStops.shutterLabel(1.0), "1s") - XCTAssertEqual(ProDialStops.isoLabel(400), "ISO 400") - XCTAssertEqual(ProDialStops.apertureLabel(2.8), "f/2.8") - XCTAssertEqual(ProDialStops.apertureLabel(16), "f/16") + XCTAssertEqual(ProStops.shutterLabel(1.0 / 125), "1/125") + XCTAssertEqual(ProStops.shutterLabel(0.5), "0.5s") + XCTAssertEqual(ProStops.shutterLabel(1.0), "1s") + XCTAssertEqual(ProStops.isoLabel(400), "ISO 400") + XCTAssertEqual(ProStops.apertureLabel(2.8), "f/2.8") + XCTAssertEqual(ProStops.apertureLabel(16), "f/16") } } diff --git a/RemoteCamTests/MonitorChromeTests.swift b/RemoteCamTests/MonitorChromeTests.swift index 8b9364ee..851cd9c8 100644 --- a/RemoteCamTests/MonitorChromeTests.swift +++ b/RemoteCamTests/MonitorChromeTests.swift @@ -160,14 +160,14 @@ final class MonitorChromeTests: XCTestCase { supportsCameraStandby: Bool = false, resolutionCount: Int = 1, frameRateCount: Int = 1, - showsProControls: Bool = false) -> [MonitorTrayItem] { + proTiles: [MonitorTrayItem] = []) -> [MonitorTrayItem] { MonitorTray.items(for: state, supportsHEIF: supportsHEIF, supportsHDR: supportsHDR, supportsCameraStandby: supportsCameraStandby, resolutionCount: resolutionCount, frameRateCount: frameRateCount, - showsProControls: showsProControls) + proTiles: proTiles) } /// A camera with no optional capabilities gets the irreducible tray. @@ -233,42 +233,55 @@ final class MonitorChromeTests: XCTestCase { // MARK: - Pro controls + private let proTileStates: [MonitorUIState] = [.photoMode, .videoMode, .videoRecording, .shortsMode] + /// Same rule as standby: a peer that never advertised the capability - /// would ignore SetExposure/SetCinematic, so the tile is not offered. - func testProTileIsHiddenWhenPeerDoesNotSupportIt() { - for state in [MonitorUIState.photoMode, .videoMode, .videoRecording, .shortsMode] { - XCTAssertFalse(items(state).contains(.proControls), - "\(state) offered pro controls to a peer that can't do them") + /// would ignore SetExposure/SetCinematic, so no tile is offered. + func testProTilesAreHiddenWhenPeerDoesNotSupportThem() { + for state in proTileStates { + XCTAssertTrue(MonitorTray.proTiles(for: state, supportsManualExposure: false, + supportsCinematicVideo: false, + cinematicOn: false, apertureAdjustable: true).isEmpty, + "\(state) offered pro controls to a peer that can't do them") + XCTAssertTrue(items(state).allSatisfy { ![.shutter, .iso, .cinematic, .aperture].contains($0) }) } } - /// The tile sits between standby and Settings, in every mode, and stays - /// composed while recording (the panel explains what recording locks). - func testProTileSitsBetweenStandbyAndSettings() { - XCTAssertEqual(items(.photoMode, supportsCameraStandby: true, showsProControls: true), - [.timer, .aspect, .cameraStandby, .proControls, .settings, .help]) - XCTAssertEqual(items(.videoRecording, showsProControls: true), - [.timer, .aspect, .proControls, .settings, .help]) - XCTAssertEqual(items(.shortsMode, showsProControls: true), - [.aspect, .proControls, .settings, .help]) + /// Pro tiles sit with the capture settings — after quality, before + /// standby — in every mode, and stay composed while recording (the + /// camera caps the shutter at one frame; Cinematic dims itself). + func testProTilesSitBetweenQualityAndStandby() { + XCTAssertEqual(items(.photoMode, supportsHDR: true, supportsCameraStandby: true, proTiles: [.shutter, .iso]), + [.timer, .aspect, .hdr, .shutter, .iso, .cameraStandby, .settings, .help]) + XCTAssertEqual(items(.videoRecording, resolutionCount: 2, proTiles: [.shutter, .iso, .cinematic, .aperture]), + [.timer, .aspect, .resolution, .shutter, .iso, .cinematic, .aperture, .settings, .help]) + XCTAssertEqual(items(.shortsMode, proTiles: [.shutter, .iso]), + [.aspect, .shutter, .iso, .settings, .help]) } - /// Manual exposure earns the tile in every mode; Cinematic is a video - /// effect and earns it only in video modes; the flag gates everything. + /// Manual exposure earns SHUTTER + ISO in every mode; Cinematic is a video + /// effect and earns its toggle only in video modes, plus APERTURE once it + /// is on and the device can adjust it; the flag gates everything. func testProTileDerivation() { - func shows(_ state: MonitorUIState, manual: Bool, cinematic: Bool, flag: Bool = true) -> Bool { - MonitorTray.showsProControls(for: state, supportsManualExposure: manual, - supportsCinematicVideo: cinematic, flagEnabled: flag) + func tiles(_ state: MonitorUIState, manual: Bool, cinematic: Bool, + on: Bool = false, adjustable: Bool = true, flag: Bool = true) -> [MonitorTrayItem] { + MonitorTray.proTiles(for: state, supportsManualExposure: manual, + supportsCinematicVideo: cinematic, cinematicOn: on, + apertureAdjustable: adjustable, flagEnabled: flag) } - for state in [MonitorUIState.photoMode, .videoMode, .videoRecording, .shortsMode] { - XCTAssertTrue(shows(state, manual: true, cinematic: false), "\(state) hid manual exposure") - XCTAssertFalse(shows(state, manual: false, cinematic: false), "\(state) showed a tile with nothing to control") - XCTAssertFalse(shows(state, manual: true, cinematic: true, flag: false), "\(state) ignored the feature flag") + for state in proTileStates { + XCTAssertEqual(tiles(state, manual: true, cinematic: false), [.shutter, .iso], "\(state) hid manual exposure") + XCTAssertTrue(tiles(state, manual: true, cinematic: true, flag: false).isEmpty, "\(state) ignored the feature flag") + } + for state in [MonitorUIState.videoMode, .videoRecording] { + XCTAssertEqual(tiles(state, manual: false, cinematic: true), [.cinematic]) + XCTAssertEqual(tiles(state, manual: false, cinematic: true, on: true), [.cinematic, .aperture]) + XCTAssertEqual(tiles(state, manual: false, cinematic: true, on: true, adjustable: false), [.cinematic], + "a fixed aperture has no slider to open") + XCTAssertEqual(tiles(state, manual: true, cinematic: true, on: true), [.shutter, .iso, .cinematic, .aperture]) } - XCTAssertTrue(shows(.videoMode, manual: false, cinematic: true)) - XCTAssertTrue(shows(.videoRecording, manual: false, cinematic: true)) - XCTAssertFalse(shows(.photoMode, manual: false, cinematic: true), "Cinematic is not a photo control") - XCTAssertFalse(shows(.shortsMode, manual: false, cinematic: true)) + XCTAssertTrue(tiles(.photoMode, manual: false, cinematic: true, on: true).isEmpty, "Cinematic is not a photo control") + XCTAssertTrue(tiles(.shortsMode, manual: false, cinematic: true, on: true).isEmpty) } /// Settings and Help are the tray's floor — they are how the viewfinder diff --git a/RemoteCamTests/MonitorScreenSnapshotTests.swift b/RemoteCamTests/MonitorScreenSnapshotTests.swift index 51a62956..09865369 100644 --- a/RemoteCamTests/MonitorScreenSnapshotTests.swift +++ b/RemoteCamTests/MonitorScreenSnapshotTests.swift @@ -65,24 +65,14 @@ final class MonitorScreenSnapshotTests: SnapshotTestCase { /// The pro panel in its richest state: Manual exposure dials + Cinematic /// on with the aperture dial locked by a recording. - func testProControlsPanelRenders() { - let model = makeConnectedModel() - model.currentMode = .Video - model.uiState = .videoMode - model.supportsManualExposure = true - model.exposure = ExposureState( + func testProSliderPillRenders() { + let exposure = ExposureState( mode: .manual, durationSeconds: 1.0 / 125, iso: 400, minDurationSeconds: 1.0 / 8000, maxDurationSeconds: 0.5, minISO: 32, maxISO: 3200) - model.supportsCinematicVideo = true - model.cinematic = CinematicState( - enabled: true, simulatedAperture: 2.8, - minSimulatedAperture: 1.4, maxSimulatedAperture: 16, - defaultSimulatedAperture: 2.0, apertureLocked: true, notEnoughLight: true) - - let panel = ProControlsPanel(viewModel: model, - onExposureChange: { _ in }, - onCinematicChange: { _ in }) - let image = renderScreen(named: "monitor-pro-panel", panel) + let pill = ProSliderPill(scale: .shutter(exposure), + currentValue: exposure.durationSeconds, + onChange: { _ in }, onAuto: {}, onClose: {}) + let image = renderScreen(named: "monitor-pro-slider", pill) assertRendered(image) } diff --git a/RemoteCamTests/MultiCamChromeTests.swift b/RemoteCamTests/MultiCamChromeTests.swift index 51522bbe..becfa5d8 100644 --- a/RemoteCamTests/MultiCamChromeTests.swift +++ b/RemoteCamTests/MultiCamChromeTests.swift @@ -62,15 +62,16 @@ final class MultiCamChromeTests: XCTestCase { XCTAssertFalse(RigTray.items(mode: .video, standbyAvailable: false).contains(.cameraStandby)) } - /// The PRO tile follows the focused camera's capabilities and sits in the - /// 1:1 tray's slot (after standby, before Settings) in both modes; a rig - /// whose focused camera offers nothing lists no tile. - func testRigTrayProTileFollowsFocusedCamera() { - XCTAssertEqual(RigTray.items(mode: .photo, standbyAvailable: true, showsProControls: true), - [.timer, .aspect, .format, .hdr, .cameraStandby, .proControls, .settings, .help]) - XCTAssertEqual(RigTray.items(mode: .video, standbyAvailable: false, showsProControls: true), - [.timer, .aspect, .resolution, .proControls, .settings, .help]) - XCTAssertFalse(RigTray.items(mode: .video, standbyAvailable: true).contains(.proControls)) + /// The pro tiles follow the focused camera's capabilities and sit in the + /// 1:1 tray's slot (after quality, before standby) in both modes; a rig + /// whose focused camera offers nothing lists none. + func testRigTrayProTilesFollowFocusedCamera() { + XCTAssertEqual(RigTray.items(mode: .photo, standbyAvailable: true, proTiles: [.shutter, .iso]), + [.timer, .aspect, .format, .hdr, .shutter, .iso, .cameraStandby, .settings, .help]) + XCTAssertEqual(RigTray.items(mode: .video, standbyAvailable: false, proTiles: [.shutter, .iso, .cinematic]), + [.timer, .aspect, .resolution, .shutter, .iso, .cinematic, .settings, .help]) + XCTAssertTrue(RigTray.items(mode: .video, standbyAvailable: true) + .allSatisfy { ![.shutter, .iso, .cinematic, .aperture].contains($0) }) } func testStreamProfilePresets() { diff --git a/RemoteCamTests/MulticamViewModelTests.swift b/RemoteCamTests/MulticamViewModelTests.swift index ec47ab02..82ef5391 100644 --- a/RemoteCamTests/MulticamViewModelTests.swift +++ b/RemoteCamTests/MulticamViewModelTests.swift @@ -37,24 +37,40 @@ final class MulticamViewModelTests: XCTestCase { torchOn: torchOn, flashOn: flashOn) } - /// The PRO tile is a property of the FOCUSED camera: refocusing from a - /// camera without pro controls to one with them makes it appear, and - /// Cinematic alone earns it only once the director is in video mode. - func testProTileFollowsFocusedCameraCapabilities() { + /// The pro tiles are a property of the FOCUSED camera: refocusing from a + /// camera without pro controls to one with them makes them appear, and + /// Cinematic alone earns its tile only once the director is in video mode. + func testProTilesFollowFocusedCameraCapabilities() { let vm = MulticamViewModel() vm.apply([info(camA, focused: true), info(camB, supportsManualExposure: true)]) - XCTAssertFalse(vm.showsProControls, "focused camera offers nothing") + XCTAssertTrue(vm.focusedProTiles.isEmpty, "focused camera offers nothing") vm.apply([info(camA), info(camB, focused: true, supportsManualExposure: true)]) - XCTAssertTrue(vm.showsProControls, "focused camera does manual exposure") + XCTAssertEqual(vm.focusedProTiles, [.shutter, .iso], "focused camera does manual exposure") vm.apply([info(camA), info(camB, focused: true, supportsCinematicVideo: true)]) - XCTAssertFalse(vm.showsProControls, "Cinematic is not a photo control") + XCTAssertTrue(vm.focusedProTiles.isEmpty, "Cinematic is not a photo control") vm.mode = .video - XCTAssertTrue(vm.showsProControls) + XCTAssertEqual(vm.focusedProTiles, [.cinematic]) vm.apply([info(camA), info(camB, status: .reconnecting, focused: true, supportsManualExposure: true)]) - XCTAssertFalse(vm.showsProControls, "a dropped camera cannot be driven") + XCTAssertTrue(vm.focusedProTiles.isEmpty, "a dropped camera cannot be driven") + } + + /// An open slider stays only while the focused camera still offers its + /// tile: refocusing onto a camera without manual exposure hides it (the + /// choice is remembered, so focusing back restores it). + func testOpenSliderFollowsTheFocusedCamera() { + let vm = MulticamViewModel() + vm.apply([info(camA, focused: true, supportsManualExposure: true), info(camB)]) + vm.activeProSlider = .shutter + XCTAssertEqual(vm.visibleProSlider, .shutter) + + vm.apply([info(camA, supportsManualExposure: true), info(camB, focused: true)]) + XCTAssertNil(vm.visibleProSlider, "camB has no shutter to slide") + + vm.apply([info(camA, focused: true, supportsManualExposure: true), info(camB)]) + XCTAssertEqual(vm.visibleProSlider, .shutter) } /// The shutter is a broadcast: cameras present is enough — focus is diff --git a/RemoteCamTests/ProSliderScaleTests.swift b/RemoteCamTests/ProSliderScaleTests.swift new file mode 100644 index 00000000..02a7314a --- /dev/null +++ b/RemoteCamTests/ProSliderScaleTests.swift @@ -0,0 +1,86 @@ +import XCTest +@testable import RemoteShutter + +/// The pro sliders: their ranges and labels (`ProSliderScale`), the shared +/// ruler math they ride on (`LogTrack`, which `ZoomScale` also wraps), and +/// the value → command mapping (`ProSliderKind.intent`). +final class ProSliderScaleTests: XCTestCase { + + private let exposure = ExposureState( + mode: .manual, durationSeconds: 1.0 / 125, iso: 400, + minDurationSeconds: 1.0 / 8000, maxDurationSeconds: 1, minISO: 32, maxISO: 3200) + + func testRangeAndDetentsComeFromTheCamera() { + let shutter = ProSliderScale.shutter(exposure).track + XCTAssertEqual(shutter.minValue, 1.0 / 8000) + XCTAssertEqual(shutter.maxValue, 1) + XCTAssertEqual(shutter.stops.first, 1.0 / 8000) + XCTAssertEqual(shutter.stops.last, 1) + XCTAssertFalse(shutter.isDegenerate) + + let iso = ProSliderScale.iso(exposure).track + XCTAssertEqual(iso.stops.first, 32) + XCTAssertEqual(iso.stops.last, 3200) + } + + /// Log track: one stop is the same travel anywhere, ends are exact. + func testTrackIsLogarithmicWithExactEnds() { + let iso = LogTrack(min: 32, max: 3200, stops: []) + XCTAssertEqual(iso.position(for: 32), 0) + XCTAssertEqual(iso.position(for: 3200), 1) + // 32 → 320 is the same log distance as 320 → 3200. + XCTAssertEqual(iso.position(for: 320), 0.5, accuracy: 1e-9) + XCTAssertEqual(iso.value(atPosition: 0), 32) + XCTAssertEqual(iso.value(atPosition: 1), 3200) + XCTAssertEqual(iso.value(atPosition: 2), 3200, "past the end clamps") + XCTAssertEqual(iso.value(atPosition: 0.5), 320, accuracy: 1e-6) + } + + func testSnapsToNearbyDetentOnly() { + let shutter = ProSliderScale.shutter(exposure).track + XCTAssertEqual(shutter.snappedToStop(1.0 / 124), 1.0 / 125) + let midway = shutter.value(atPosition: (shutter.position(for: 1.0 / 125) + shutter.position(for: 1.0 / 60)) / 2) + XCTAssertEqual(shutter.snappedToStop(midway), midway, "midway between stops stays free") + } + + /// No range (before the first echo) or a fixed value draws nothing — + /// the rule `ZoomScale.isDegenerate` already applies to zoom. + func testDegenerateRanges() { + let fixed = CinematicState(enabled: true, simulatedAperture: 0, minSimulatedAperture: 0, + maxSimulatedAperture: 0, defaultSimulatedAperture: 0, + apertureLocked: false, notEnoughLight: false) + XCTAssertTrue(ProSliderScale.aperture(fixed).track.isDegenerate) + XCTAssertEqual(ProSliderScale.aperture(fixed).track.position(for: 2.8), 0) + XCTAssertTrue(LogTrack(min: 100, max: 100, stops: []).isDegenerate) + XCTAssertTrue(LogTrack(min: .nan, max: 100, stops: []).isDegenerate) + XCTAssertTrue(LogTrack(min: 0, max: 100, stops: []).isDegenerate, "log of zero is not a position") + } + + /// Detents outside the camera's range are not offered. + func testStopsOutsideTheRangeAreDropped() { + let track = LogTrack(min: 1.0 / 500, max: 1.0 / 30, stops: ProStops.allShutterSeconds) + XCTAssertEqual(track.stops.first, 1.0 / 500) + XCTAssertEqual(track.stops.last, 1.0 / 30) + } + + func testLabelsSpeakPhotography() { + XCTAssertEqual(ProSliderScale.shutter(exposure).label(1.0 / 125), "1/125") + XCTAssertEqual(ProSliderScale.iso(exposure).label(400), "ISO 400") + let phone = CinematicState(enabled: true, simulatedAperture: 2.8, minSimulatedAperture: 1.4, + maxSimulatedAperture: 16, defaultSimulatedAperture: 2, + apertureLocked: false, notEnoughLight: false) + XCTAssertEqual(ProSliderScale.aperture(phone).label(2.8), "f/2.8") + } + + /// Dragging one dial locks only that component (0 = keep the other as + /// the camera has it); the aperture slider rides Cinematic on. + func testSliderValuesBecomeSingleComponentIntents() { + XCTAssertEqual(ProSliderKind.shutter.intent(for: 0.5), .exposure(.manual(durationSeconds: 0.5, iso: 0))) + XCTAssertEqual(ProSliderKind.iso.intent(for: 800), .exposure(.manual(durationSeconds: 0, iso: 800))) + XCTAssertEqual(ProSliderKind.aperture.intent(for: 4), .cinematic(.on(aperture: 4))) + } + + func testEveryKindHasATile() { + XCTAssertEqual(ProSliderKind.allCases.map(\.tile), [.shutter, .iso, .aperture]) + } +} diff --git a/RemoteShutter.xcodeproj/project.pbxproj b/RemoteShutter.xcodeproj/project.pbxproj index 5d82d476..eab5673a 100644 --- a/RemoteShutter.xcodeproj/project.pbxproj +++ b/RemoteShutter.xcodeproj/project.pbxproj @@ -76,6 +76,7 @@ 06E965402535754800E5A8B3 /* Data+MD5.swift in Sources */ = {isa = PBXBuildFile; fileRef = 06E9653D2535754800E5A8B3 /* Data+MD5.swift */; }; 0A11B22C33D44E55F6070002 /* ZoomScale.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0A11B22C33D44E55F6070001 /* ZoomScale.swift */; }; 0A11B22C33D44E55F6070004 /* ZoomPill.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0A11B22C33D44E55F6070003 /* ZoomPill.swift */; }; + E0E020700000000000000002 /* RulerPill.swift in Sources */ = {isa = PBXBuildFile; fileRef = E0E020700000000000000001 /* RulerPill.swift */; }; 0A11B22C33D44E55F6071004 /* ViewfinderGestureLayer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0A11B22C33D44E55F6071003 /* ViewfinderGestureLayer.swift */; }; 0A11B22C33D44E55F6070006 /* ZoomScaleTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0A11B22C33D44E55F6070005 /* ZoomScaleTests.swift */; }; 1ECFC14E17A9A47D5951E80B /* RemoteCam/WatchSessionManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 148A312CF1B445C71094EF0E /* RemoteCam/WatchSessionManager.swift */; }; @@ -185,6 +186,7 @@ E0E0206A0000000000000004 /* ExposurePolicyTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = E0E0206A0000000000000003 /* ExposurePolicyTests.swift */; }; E0E0206D0000000000000002 /* CinematicPolicyTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = E0E0206D0000000000000001 /* CinematicPolicyTests.swift */; }; E0E0206E0000000000000002 /* MessageDumpTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = E0E0206E0000000000000001 /* MessageDumpTests.swift */; }; + E0E0206F0000000000000002 /* ProSliderScaleTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = E0E0206F0000000000000001 /* ProSliderScaleTests.swift */; }; CAFEBABE0121000000000002 /* MonitorChromeTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = CAFEBABE0121000000000001 /* MonitorChromeTests.swift */; }; CAFEBABE0100000000000002 /* PeerCompatibility.swift in Sources */ = {isa = PBXBuildFile; fileRef = CAFEBABE0100000000000001 /* PeerCompatibility.swift */; }; CB5F78DFB9D567955BC863AF /* SoundManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 99FC5340BB98B2BD307FFA1A /* SoundManager.swift */; }; @@ -206,7 +208,7 @@ FC0CF5A101FE65A9800F0B238 /* FocusPointMapping.swift in Sources */ = {isa = PBXBuildFile; fileRef = FC0CF5A201FE65A9800F0B238 /* FocusPointMapping.swift */; }; E0E0206A0000000000000002 /* ExposurePolicy.swift in Sources */ = {isa = PBXBuildFile; fileRef = E0E0206A0000000000000001 /* ExposurePolicy.swift */; }; E0E0206B0000000000000002 /* CinematicPolicy.swift in Sources */ = {isa = PBXBuildFile; fileRef = E0E0206B0000000000000001 /* CinematicPolicy.swift */; }; - E0E0206C0000000000000002 /* ProControlsPanel.swift in Sources */ = {isa = PBXBuildFile; fileRef = E0E0206C0000000000000001 /* ProControlsPanel.swift */; }; + E0E0206C0000000000000002 /* ProSliderPill.swift in Sources */ = {isa = PBXBuildFile; fileRef = E0E0206C0000000000000001 /* ProSliderPill.swift */; }; CAFEBABE0120000000000001 /* MonitorChrome.swift in Sources */ = {isa = PBXBuildFile; fileRef = CAFEBABE0120000000000002 /* MonitorChrome.swift */; }; CAFEBABE0177000000000001 /* SessionDebugConsole.swift in Sources */ = {isa = PBXBuildFile; fileRef = CAFEBABE0177000000000002 /* SessionDebugConsole.swift */; }; CAFEBABE0130000000000001 /* CaptureSyncMetadata.swift in Sources */ = {isa = PBXBuildFile; fileRef = CAFEBABE0130000000000002 /* CaptureSyncMetadata.swift */; }; @@ -353,6 +355,7 @@ 06FA4AD71BC8B8E9005608E6 /* CocoaLumberjack.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = CocoaLumberjack.framework; path = "Pods/../build/Debug-iphoneos/CocoaLumberjack.framework"; sourceTree = ""; }; 0A11B22C33D44E55F6070001 /* ZoomScale.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ZoomScale.swift; sourceTree = ""; }; 0A11B22C33D44E55F6070003 /* ZoomPill.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ZoomPill.swift; sourceTree = ""; }; + E0E020700000000000000001 /* RulerPill.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RulerPill.swift; sourceTree = ""; }; 0A11B22C33D44E55F6071003 /* ViewfinderGestureLayer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ViewfinderGestureLayer.swift; sourceTree = ""; }; 0A11B22C33D44E55F6070005 /* ZoomScaleTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = ZoomScaleTests.swift; sourceTree = ""; }; 0ACB2DA94752BB4E9C4CE461 /* CountdownTimer.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = CountdownTimer.swift; sourceTree = ""; }; @@ -458,6 +461,7 @@ E0E0206A0000000000000003 /* ExposurePolicyTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ExposurePolicyTests.swift; sourceTree = ""; }; E0E0206D0000000000000001 /* CinematicPolicyTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CinematicPolicyTests.swift; sourceTree = ""; }; E0E0206E0000000000000001 /* MessageDumpTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MessageDumpTests.swift; sourceTree = ""; }; + E0E0206F0000000000000001 /* ProSliderScaleTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProSliderScaleTests.swift; sourceTree = ""; }; CAFEBABE0121000000000001 /* MonitorChromeTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MonitorChromeTests.swift; sourceTree = ""; }; CAFEBABE0100000000000001 /* PeerCompatibility.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PeerCompatibility.swift; sourceTree = ""; }; CD857DFD7882DAA5012B70C9 /* FlatBufferSchemas.fbs */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text; path = FlatBufferSchemas.fbs; sourceTree = ""; }; @@ -474,7 +478,7 @@ FC0CF5A201FE65A9800F0B238 /* FocusPointMapping.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = FocusPointMapping.swift; sourceTree = ""; }; E0E0206A0000000000000001 /* ExposurePolicy.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ExposurePolicy.swift; sourceTree = ""; }; E0E0206B0000000000000001 /* CinematicPolicy.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = CinematicPolicy.swift; sourceTree = ""; }; - E0E0206C0000000000000001 /* ProControlsPanel.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ProControlsPanel.swift; sourceTree = ""; }; + E0E0206C0000000000000001 /* ProSliderPill.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ProSliderPill.swift; sourceTree = ""; }; CAFEBABE0120000000000002 /* MonitorChrome.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = MonitorChrome.swift; sourceTree = ""; }; CAFEBABE0177000000000002 /* SessionDebugConsole.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SessionDebugConsole.swift; sourceTree = ""; }; CAFEBABE0130000000000002 /* CaptureSyncMetadata.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = CaptureSyncMetadata.swift; sourceTree = ""; }; @@ -647,6 +651,7 @@ E0E0206A0000000000000003 /* ExposurePolicyTests.swift */, E0E0206D0000000000000001 /* CinematicPolicyTests.swift */, E0E0206E0000000000000001 /* MessageDumpTests.swift */, + E0E0206F0000000000000001 /* ProSliderScaleTests.swift */, CAFEBABE0121000000000001 /* MonitorChromeTests.swift */, CAFEBABE0131000000000001 /* CaptureSyncMetadataTests.swift */, CAFEBABE0133000000000001 /* ClockOffsetEstimatorTests.swift */, @@ -690,6 +695,7 @@ 068DF59A2E3544AD00A49279 /* MonitorView.swift */, 0A11B22C33D44E55F6070001 /* ZoomScale.swift */, 0A11B22C33D44E55F6070003 /* ZoomPill.swift */, + E0E020700000000000000001 /* RulerPill.swift */, 0A11B22C33D44E55F6071003 /* ViewfinderGestureLayer.swift */, 06BB79BB2E3884F00094E085 /* CameraProgressOverlayView.swift */, 06BB79BE2E3884FA0094E085 /* CameraViewModel.swift */, @@ -748,7 +754,7 @@ FC0CF5A201FE65A9800F0B238 /* FocusPointMapping.swift */, E0E0206A0000000000000001 /* ExposurePolicy.swift */, E0E0206B0000000000000001 /* CinematicPolicy.swift */, - E0E0206C0000000000000001 /* ProControlsPanel.swift */, + E0E0206C0000000000000001 /* ProSliderPill.swift */, CAFEBABE0120000000000002 /* MonitorChrome.swift */, CAFEBABE0177000000000002 /* SessionDebugConsole.swift */, CAFEBABE0130000000000002 /* CaptureSyncMetadata.swift */, @@ -1229,6 +1235,7 @@ 068DF59D2E3544AD00A49279 /* MonitorView.swift in Sources */, 0A11B22C33D44E55F6070002 /* ZoomScale.swift in Sources */, 0A11B22C33D44E55F6070004 /* ZoomPill.swift in Sources */, + E0E020700000000000000002 /* RulerPill.swift in Sources */, 0A11B22C33D44E55F6071004 /* ViewfinderGestureLayer.swift in Sources */, 068DF59E2E3544AD00A49279 /* MonitorViewController+SwiftUI.swift in Sources */, 068DF59F2E3544AD00A49279 /* MonitorViewModel.swift in Sources */, @@ -1258,7 +1265,7 @@ FC0CF5A101FE65A9800F0B238 /* FocusPointMapping.swift in Sources */, E0E0206A0000000000000002 /* ExposurePolicy.swift in Sources */, E0E0206B0000000000000002 /* CinematicPolicy.swift in Sources */, - E0E0206C0000000000000002 /* ProControlsPanel.swift in Sources */, + E0E0206C0000000000000002 /* ProSliderPill.swift in Sources */, CAFEBABE0120000000000001 /* MonitorChrome.swift in Sources */, CAFEBABE0177000000000001 /* SessionDebugConsole.swift in Sources */, CAFEBABE0130000000000001 /* CaptureSyncMetadata.swift in Sources */, @@ -1348,6 +1355,7 @@ E0E0206A0000000000000004 /* ExposurePolicyTests.swift in Sources */, E0E0206D0000000000000002 /* CinematicPolicyTests.swift in Sources */, E0E0206E0000000000000002 /* MessageDumpTests.swift in Sources */, + E0E0206F0000000000000002 /* ProSliderScaleTests.swift in Sources */, CAFEBABE0121000000000002 /* MonitorChromeTests.swift in Sources */, CAFEBABE0131000000000002 /* CaptureSyncMetadataTests.swift in Sources */, CAFEBABE0133000000000002 /* ClockOffsetEstimatorTests.swift in Sources */, From b0f291e2f7fb9fb9562e59e7e3fda1849a0ea40d Mon Sep 17 00:00:00 2001 From: Dario Lencina Date: Wed, 26 Aug 2026 22:55:33 -0700 Subject: [PATCH 07/14] Cinematic: commit the format before asking the session; refusals are errors the remote shows MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit AVCaptureDeviceInput.isCinematicVideoCaptureSupported reflects the session's COMMITTED configuration, so checking it inside the same begin/commit as the format switch read the old answer and the enable was skipped — and the code path returned the unchanged state with no error, so the tile looked like it did nothing. The format now commits on its own, then the session is asked and the effect enabled in its own begin/commit. Every refusal (photo mode, recording, no capable format, session refusal) is a CinematicRefusal carried in SetCinematicResp.error; the director toasts it, the 1:1 monitor alerts. A 🎬 CINEMATIC PROBE log line reports the three conditions on the camera phone. Flip while Manual: the Manual lens hop left the session on a physical lens that is not in the selectable list, so the flip could not find the current camera ("Unable to find camera position") and Auto would have restored the pre-flip camera. The chosen device is now the LOGICAL camera (logicalDeviceIDLocked) for the flip, the picker's active ID and Auto's return; a user device change re-bases the hop through one decision function (manualExposureHopTargetLocked). Hardware test added. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01K5uHRuiiLu5FEBiHZkBWo3 --- RemoteCam/CaptureEngine.swift | 170 ++++++++++++++++--- RemoteCam/SessionCoordinator.swift | 13 ++ RemoteCamTests/CaptureIntegrationTests.swift | 27 +++ 3 files changed, 182 insertions(+), 28 deletions(-) diff --git a/RemoteCam/CaptureEngine.swift b/RemoteCam/CaptureEngine.swift index 9a9f326e..05089bd6 100644 --- a/RemoteCam/CaptureEngine.swift +++ b/RemoteCam/CaptureEngine.swift @@ -290,7 +290,7 @@ final class CaptureEngine: NSObject, AVCapturePhotoCaptureDelegate { self.manualExposureRestoreDeviceID = nil _ = self.applyExposureIntentLocked() self.cinematicIntent = .off - _ = self.applyCinematicIntentLocked() + _ = try? self.applyCinematicIntentLocked() if self.captureSession.isRunning { self.captureSession.stopRunning() } @@ -302,12 +302,44 @@ final class CaptureEngine: NSObject, AVCapturePhotoCaptureDelegate { guard let newDevice = self.nextToggleDeviceLocked() else { throw NSError(domain: "Unable to find camera position", code: 0, userInfo: nil) } - let result = try self.swapToDeviceLocked(newDevice, orientation: orientation) + let result = try self.selectLogicalDeviceLocked(newDevice, orientation: orientation) // Camera capabilities are sent via RemoteCmd.ToggleCameraResp in the camera state. return (result.flashMode, result.device.position) } } + /// The camera the user chose, as opposed to the one the session is + /// running: while Manual exposure has hopped a virtual device to one of + /// its physical lenses, the virtual device stays the LOGICAL camera — + /// the flip decides from it, the picker highlights it, and Auto returns + /// to it. Every identity read goes through here so the hop can never + /// leak into a "which camera am I on" answer. + private func logicalDeviceIDLocked() -> String? { + dispatchPrecondition(condition: .onQueue(sessionQueue)) + return manualExposureRestoreDeviceID ?? videoDeviceInput?.device.uniqueID + } + + private func logicalDeviceLocked() -> AVCaptureDevice? { + guard let id = logicalDeviceIDLocked() else { return nil } + return selectableDevicesLocked().first { $0.uniqueID == id } ?? videoDeviceInput?.device + } + + /// A user-chosen device change (flip, picker): re-bases the Manual hop + /// on the new device — the chosen device becomes the logical camera, and + /// the session runs its physical lens if Manual needs one. + private func selectLogicalDeviceLocked(_ device: AVCaptureDevice, + orientation: UIInterfaceOrientation) throws -> CameraSelectionResult { + dispatchPrecondition(condition: .onQueue(sessionQueue)) + manualExposureRestoreDeviceID = nil + var target = device + if let physical = manualExposureHopTargetLocked(for: device) { + manualExposureRestoreDeviceID = device.uniqueID + debugLog("🌗 EXPOSURE: manual stays on — \(device.localizedName) runs as \(physical.localizedName)") + target = physical + } + return try swapToDeviceLocked(target, orientation: orientation) + } + /// The camera a fresh session starts on. iOS: the preferred (virtual) /// back device. Mac: the system's preferred camera when healthy — it /// tracks the user's choice across apps — else the first non-suspended @@ -344,7 +376,7 @@ final class CaptureEngine: NSObject, AVCapturePhotoCaptureDelegate { #endif let available = selectableDevicesLocked() guard let next = CameraDeviceDescriptor.nextToggleSelection( - currentID: videoDeviceInput?.device.uniqueID, + currentID: logicalDeviceIDLocked(), available: available.map { self.descriptorLocked($0) }, flipPosition: flipPosition) else { return nil } return available.first { $0.uniqueID == next.uniqueID } @@ -396,7 +428,7 @@ final class CaptureEngine: NSObject, AVCapturePhotoCaptureDelegate { applyDesiredTorchLocked() // restore torch onto the new camera (no-op if it has none) resetFocusExposureToAutoLocked() // a stale focus point must not carry across a device change _ = applyExposureIntentLocked() // the new device must match the monitor's exposure intent - _ = applyCinematicIntentLocked() // support flips with the device; re-enable or fall off honestly + _ = try? applyCinematicIntentLocked() // support flips with the device; re-enable or fall off honestly // Swapping away from a dead device must also revive a session that a // runtime error stopped — otherwise the new camera never delivers. if isExpectedToRun && !captureSession.isRunning { @@ -653,7 +685,7 @@ final class CaptureEngine: NSObject, AVCapturePhotoCaptureDelegate { func currentCameraDevice() async -> CameraDeviceDescriptor? { await onSessionQueue { - (self.videoDeviceInput?.device).map { self.descriptorLocked($0) } + self.logicalDeviceLocked().map { self.descriptorLocked($0) } } } @@ -676,7 +708,7 @@ final class CaptureEngine: NSObject, AVCapturePhotoCaptureDelegate { let device = available.first(where: { $0.uniqueID == resolved.uniqueID }) else { throw NSError(domain: "No camera device available", code: 0, userInfo: nil) } - let result = try self.swapToDeviceLocked(device, orientation: orientation) + let result = try self.selectLogicalDeviceLocked(device, orientation: orientation) #if targetEnvironment(macCatalyst) if #available(macCatalyst 17.0, *) { // Apple's "manual mode": feed the system-wide preference so @@ -1041,10 +1073,24 @@ final class CaptureEngine: NSObject, AVCapturePhotoCaptureDelegate { /// use #available inline in an argument list). private func supportsCinematicVideoLocked() -> Bool { guard #available(iOS 26.0, macCatalyst 26.0, *) else { return false } - guard let device = videoDeviceInput?.device else { return false } + guard let input = videoDeviceInput, let device = videoDeviceInput?.device else { return false } + let cinematicFormats = device.formats.filter { $0.isCinematicVideoCaptureSupported } + // Hardware probe (Docs/pro-controls.md): which of Apple's three + // conditions hold on this device — a capable format, the active + // format, and the session's agreement. + debugLog("🎬 CINEMATIC PROBE: \(device.localizedName) cinematicFormats=\(cinematicFormats.count)/\(device.formats.count) " + + "active=\(formatSummary(device.activeFormat)) activeSupports=\(device.activeFormat.isCinematicVideoCaptureSupported) " + + "inputSupports=\(input.isCinematicVideoCaptureSupported) enabled=\(input.isCinematicVideoCaptureEnabled)") return cinematicRangeFormatLocked(device) != nil } + /// "1920x1080 @30" — for probe logs and refusal messages. + private func formatSummary(_ format: AVCaptureDevice.Format) -> String { + let dims = CMVideoFormatDescriptionGetDimensions(format.formatDescription) + let fps = format.videoSupportedFrameRateRanges.map { Int($0.maxFrameRate) }.max() ?? 0 + return "\(dims.width)x\(dims.height) @\(fps)" + } + private func currentCinematicStateLocked() -> CinematicState? { guard #available(iOS 26.0, macCatalyst 26.0, *) else { return nil } guard let input = videoDeviceInput, let device = videoDeviceInput?.device else { return nil } @@ -1113,7 +1159,7 @@ final class CaptureEngine: NSObject, AVCapturePhotoCaptureDelegate { /// gate that lets a monitor remote-select this device's cameras. private func cameraDeviceEntriesLocked() -> ([RemoteCmd.CameraDeviceEntry], String?) { dispatchPrecondition(condition: .onQueue(sessionQueue)) - let activeID = videoDeviceInput?.device.uniqueID + let activeID = logicalDeviceIDLocked() let entries = selectableDevicesLocked().map { device in RemoteCmd.CameraDeviceEntry( uniqueID: device.uniqueID, @@ -1340,17 +1386,26 @@ final class CaptureEngine: NSObject, AVCapturePhotoCaptureDelegate { return candidates.first { $0.deviceType == .builtInWideAngleCamera } ?? candidates.first } - /// The ONLY place a manual-exposure lens swap happens: entering Manual on - /// a virtual device hops to its active physical lens; returning to Auto - /// hops back. Re-apply sites never swap (swapToDeviceLocked calls - /// applyExposureIntentLocked, so a swap from there would recurse). + /// The one decision behind the Manual lens hop: the physical lens + /// `device` must run on for Manual, or nil when it can run Manual itself + /// (or Manual is off). Two entry points act on it — a change of intent + /// (`reconcileExposureDeviceLocked`) and a change of device + /// (`selectLogicalDeviceLocked`). Re-apply sites never swap + /// (`swapToDeviceLocked` calls `applyExposureIntentLocked`, so a swap + /// from there would recurse). + private func manualExposureHopTargetLocked(for device: AVCaptureDevice) -> AVCaptureDevice? { + guard case .manual = exposureIntent, !device.isExposureModeSupported(.custom) else { return nil } + return manualExposureLensLocked(for: device) + } + + /// Entering Manual on a virtual device hops to a physical lens; returning + /// to Auto hops back to the logical (virtual) device. private func reconcileExposureDeviceLocked() { dispatchPrecondition(condition: .onQueue(sessionQueue)) guard let device = videoDeviceInput?.device else { return } switch exposureIntent { case .manual: - guard !device.isExposureModeSupported(.custom), - let physical = manualExposureLensLocked(for: device) else { return } + guard let physical = manualExposureHopTargetLocked(for: device) else { return } manualExposureRestoreDeviceID = device.uniqueID debugLog("🌗 EXPOSURE: manual on virtual \(device.localizedName) — hopping to \(physical.localizedName)") _ = try? swapToDeviceLocked(physical, orientation: orientation) @@ -1373,27 +1428,60 @@ final class CaptureEngine: NSObject, AVCapturePhotoCaptureDelegate { func setCinematic(_ intent: CinematicIntent) async throws -> CinematicState { try await onSessionQueueThrowing { self.cinematicIntent = intent - guard let state = self.applyCinematicIntentLocked() else { + guard let state = try self.applyCinematicIntentLocked() else { throw NSError(domain: "No camera device available", code: 0, userInfo: nil) } return state } } + /// Why a Cinematic request did not take. Thrown by `applyCinematicIntentLocked` + /// so the response carries it and the remote SAYS it (toast / alert) — + /// a refused toggle must never look like a toggle that did nothing. + /// The message rides in the NSError domain, the convention every + /// monitor's error display reads. + enum CinematicRefusal: Error { + case photoMode + case recording + case unsupported(device: String) + /// The device has a Cinematic format but the session's configuration + /// still refuses (`AVCaptureDeviceInput.isCinematicVideoCaptureSupported` + /// is a property of the whole session, not just the format). + case sessionRefused(device: String, format: String, outputs: String) + + var message: String { + switch self { + case .photoMode: + return NSLocalizedString("Switch to video mode for Cinematic", comment: "cinematic refusal") + case .recording: + return NSLocalizedString("Cinematic can't change while recording", comment: "cinematic refusal") + case let .unsupported(device): + return String(format: NSLocalizedString("%@ can't record Cinematic video", comment: "cinematic refusal"), device) + case let .sessionRefused(device, format, outputs): + return String(format: NSLocalizedString("Cinematic refused on %@ (%@; outputs: %@)", comment: "cinematic refusal"), + device, format, outputs) + } + } + + var asNSError: NSError { NSError(domain: message, code: 0, userInfo: nil) } + } + /// Rig hook for mode changes: leaving video mode switches the effect off /// (it only applies to recording). Fire-and-forget onto the sessionQueue. func disableCinematicIfActive() { sessionQueue.async { guard case .on = self.cinematicIntent else { return } self.cinematicIntent = .off - _ = self.applyCinematicIntentLocked() + _ = try? self.applyCinematicIntentLocked() } } /// The ONE place that touches `isCinematicVideoCaptureEnabled` and - /// `simulatedAperture`. Returns nil only when there is no device. + /// `simulatedAperture`. Returns nil only when there is no device; throws + /// a `CinematicRefusal` when the request cannot be honored (the intent is + /// reset to the truth first, so a later re-apply never springs it back). @discardableResult - private func applyCinematicIntentLocked() -> CinematicState? { + private func applyCinematicIntentLocked() throws -> CinematicState? { dispatchPrecondition(condition: .onQueue(sessionQueue)) guard let input = videoDeviceInput, let device = videoDeviceInput?.device else { return nil } guard #available(iOS 26.0, macCatalyst 26.0, *) else { @@ -1412,30 +1500,56 @@ final class CaptureEngine: NSObject, AVCapturePhotoCaptureDelegate { break case let .rejected(reason): - // The response carries the unchanged truth; the intent must not - // outlive a rejection or a later re-apply would spring it back. debugLog("🎬 CINEMATIC: rejected (\(reason))") cinematicIntent = facts.enabled ? .on(aperture: nil) : .off + switch reason { + case .photoMode: throw CinematicRefusal.photoMode + case .recording: throw CinematicRefusal.recording + case .unsupported: throw CinematicRefusal.unsupported(device: device.localizedName) + } case let .enable(aperture): - captureSession.beginConfiguration() - if !device.activeFormat.isCinematicVideoCaptureSupported, - let format = findCinematicFormatLocked(device) { + // Step 1: a Cinematic-capable format, COMMITTED on its own. The + // input's `isCinematicVideoCaptureSupported` reflects the session's + // committed configuration, so checking it inside the same + // begin/commit as the format switch reads the OLD answer (false) + // and the toggle silently does nothing. + if !device.activeFormat.isCinematicVideoCaptureSupported { + guard let format = findCinematicFormatLocked(device) else { + cinematicIntent = .off + throw CinematicRefusal.unsupported(device: device.localizedName) + } + captureSession.beginConfiguration() captureSession.sessionPreset = .inputPriority if (try? device.lockForConfiguration()) != nil { device.activeFormat = format device.unlockForConfiguration() } + captureSession.commitConfiguration() } - if input.isCinematicVideoCaptureSupported { - input.isCinematicVideoCaptureEnabled = true + + // Step 2: the session must agree, then the effect goes on inside + // its own begin/commit (a lengthy pipeline rebuild, per Apple). + guard input.isCinematicVideoCaptureSupported else { + cinematicIntent = .off + let refusal = CinematicRefusal.sessionRefused( + device: device.localizedName, + format: formatSummary(device.activeFormat), + outputs: captureSession.outputs.map { String(describing: type(of: $0)) }.joined(separator: ", ")) + debugLog("🎬 CINEMATIC: \(refusal.message)") + throw refusal } + captureSession.beginConfiguration() + input.isCinematicVideoCaptureEnabled = true captureSession.commitConfiguration() guard input.isCinematicVideoCaptureEnabled else { - debugLog("🎬 CINEMATIC: session refused to enable — reporting off") cinematicIntent = .off - break + let refusal = CinematicRefusal.sessionRefused( + device: device.localizedName, format: formatSummary(device.activeFormat), + outputs: "enable reverted") + debugLog("🎬 CINEMATIC: \(refusal.message)") + throw refusal } // Cinematic narrows the legal frame rates; clamp into the range's // OWN CMTimes (never rebuild from integers). @@ -1840,7 +1954,7 @@ final class CaptureEngine: NSObject, AVCapturePhotoCaptureDelegate { applyDesiredTorchLocked() // changing activeFormat/preset also resets the torch _ = applyExposureIntentLocked() // ranges and the frame-rate cap changed with the format - _ = applyCinematicIntentLocked() // a format change silently reverts Cinematic; re-assert the intent + _ = try? applyCinematicIntentLocked() // a format change silently reverts Cinematic; re-assert the intent currentVideoResolution = resolution currentVideoFrameRate = appliedFrameRate diff --git a/RemoteCam/SessionCoordinator.swift b/RemoteCam/SessionCoordinator.swift index 1649cdda..6fa9bd5d 100644 --- a/RemoteCam/SessionCoordinator.swift +++ b/RemoteCam/SessionCoordinator.swift @@ -1434,6 +1434,9 @@ public actor SessionCoordinator { do { let state = try await ctrl.setCinematic(cmd.intent) await sendOrGoToScanning(RemoteCmd.SetCinematicResp(state: state, error: nil)) + } catch let refusal as CaptureEngine.CinematicRefusal { + // A refusal is a message for the person at the remote, not a fault. + await sendOrGoToScanning(RemoteCmd.SetCinematicResp(state: nil, error: refusal.asNSError)) } catch { await sendOrGoToScanning(RemoteCmd.SetCinematicResp(state: nil, error: error as NSError)) } @@ -2457,6 +2460,7 @@ public actor SessionCoordinator { case let exposureResp as RemoteCmd.SetExposureResp: monitor?.updateExposure(exposureResp.state) + if let error = exposureResp.error { showErrorAlert(error._domain) } case let cinematic as UICmd.SetCinematic: guard peerSupportsCinematicVideo else { @@ -2467,6 +2471,10 @@ public actor SessionCoordinator { case let cinematicResp as RemoteCmd.SetCinematicResp: monitor?.updateCinematic(cinematicResp.state) + // A refused toggle is said out loud (the camera-switch rule): the + // tile already shows the unchanged truth, so silence would read + // as "the button does nothing". + if let error = cinematicResp.error { showErrorAlert(error._domain) } case let preview as UICmd.SetCameraPreviewMode: // Wire-safety gate mirroring FocusAtPoint: never send action 24 to a @@ -2825,6 +2833,7 @@ public actor SessionCoordinator { case let exposureResp as RemoteCmd.SetExposureResp: monitor?.updateExposure(exposureResp.state) + if let error = exposureResp.error { showErrorAlert(error._domain) } case let cinematic as UICmd.SetCinematic: guard peerSupportsCinematicVideo else { @@ -2835,6 +2844,10 @@ public actor SessionCoordinator { case let cinematicResp as RemoteCmd.SetCinematicResp: monitor?.updateCinematic(cinematicResp.state) + // A refused toggle is said out loud (the camera-switch rule): the + // tile already shows the unchanged truth, so silence would read + // as "the button does nothing". + if let error = cinematicResp.error { showErrorAlert(error._domain) } case let preview as UICmd.SetCameraPreviewMode: guard peerSupportsPreviewMode else { diff --git a/RemoteCamTests/CaptureIntegrationTests.swift b/RemoteCamTests/CaptureIntegrationTests.swift index d5f06722..697df576 100644 --- a/RemoteCamTests/CaptureIntegrationTests.swift +++ b/RemoteCamTests/CaptureIntegrationTests.swift @@ -427,6 +427,33 @@ final class CaptureIntegrationTests: XCTestCase { } } + /// With Manual on, the session may be running a physical lens of the + /// chosen virtual camera. The flip and the advertised active device must + /// still speak in terms of the chosen camera: a flip goes to the other + /// position (this pinned a field bug — "Unable to find camera position"), + /// and Auto afterwards stays on the camera the flip landed on. + func testFlipKeepsWorkingWhileManualExposureHasHopped() async throws { + try await startRealRig() + guard await waitForFrames(since: 0) != nil else { + return XCTFail("startup never delivered frames — \(await diagnostics())") + } + let devices = await rig.availableCameraDevices() + guard devices.count >= 2 else { throw XCTSkip("needs two selectable cameras to flip between") } + let before = await rig.currentCameraDevice() + let state = try await rig.setExposure(ExposureIntent.manual(durationSeconds: 1.0 / 250, iso: 0)) + guard state.mode == .manual else { throw XCTSkip("no device here accepts custom exposure") } + + _ = try await rig.toggleCamera() + let after = await rig.currentCameraDevice() + XCTAssertNotEqual(after?.uniqueID, before?.uniqueID, "the flip must land on the other camera") + XCTAssertTrue(devices.contains { $0.uniqueID == after?.uniqueID }, + "the reported camera is one the user can choose, never a hopped physical lens") + + _ = try await rig.setExposure(ExposureIntent.auto) + let restored = await rig.currentCameraDevice() + XCTAssertEqual(restored?.uniqueID, after?.uniqueID, "Auto stays on the camera the flip chose") + } + /// Manual exposure applied to the active device reads back within /// tolerance, and Auto restores continuous AE and the frame rate. func testManualExposureAppliesAndAutoRestores() async throws { From cd3a14571bfae226b24161aa5c403873dea3481a Mon Sep 17 00:00:00 2001 From: Dario Lencina Date: Wed, 26 Aug 2026 23:06:42 -0700 Subject: [PATCH 08/14] Cinematic tracking focus: take the device lock first setCinematicVideoTrackingFocus(at:focusMode:) requires exclusive ownership of the device; calling it unlocked is an uncaught NSGenericException that took the camera down on the first tap-to-focus with Cinematic on. The lock now precedes every focus write, and the device-swap focus reset skips focusMode while Cinematic owns focus (writing it then throws too). Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01K5uHRuiiLu5FEBiHZkBWo3 --- RemoteCam/CaptureEngine.swift | 17 +++++++++++++---- 1 file changed, 13 insertions(+), 4 deletions(-) diff --git a/RemoteCam/CaptureEngine.swift b/RemoteCam/CaptureEngine.swift index 05089bd6..8fc8308a 100644 --- a/RemoteCam/CaptureEngine.swift +++ b/RemoteCam/CaptureEngine.swift @@ -1222,6 +1222,12 @@ final class CaptureEngine: NSObject, AVCapturePhotoCaptureDelegate { videoOrientation: videoOrientation, mirrored: device.position == .front) + // Every focus write below — the Cinematic tracking focus included — + // needs exclusive ownership of the device (calling it unlocked is an + // uncaught NSGenericException, not an error). + try device.lockForConfiguration() + defer { device.unlockForConfiguration() } + // While Cinematic video is on, focusMode is pinned (setting it throws) // and taps become Cinematic tracking focus: lock onto the subject at // the tapped point until it leaves the scene. @@ -1232,9 +1238,6 @@ final class CaptureEngine: NSObject, AVCapturePhotoCaptureDelegate { return } - try device.lockForConfiguration() - defer { device.unlockForConfiguration() } - if device.isFocusPointOfInterestSupported { device.focusPointOfInterest = poi if device.isFocusModeSupported(.continuousAutoFocus) { @@ -1265,7 +1268,13 @@ final class CaptureEngine: NSObject, AVCapturePhotoCaptureDelegate { (try? device.lockForConfiguration()) != nil else { return } defer { device.unlockForConfiguration() } let center = CGPoint(x: 0.5, y: 0.5) - if device.isFocusPointOfInterestSupported { + // focusMode is pinned while Cinematic is on (writing it throws an + // NSInvalidArgumentException); the effect owns focus then. + var cinematicOwnsFocus = false + if #available(iOS 26.0, macCatalyst 26.0, *) { + cinematicOwnsFocus = videoDeviceInput?.isCinematicVideoCaptureEnabled == true + } + if device.isFocusPointOfInterestSupported, !cinematicOwnsFocus { device.focusPointOfInterest = center if device.isFocusModeSupported(.continuousAutoFocus) { device.focusMode = .continuousAutoFocus } } From 16703e6fbd1271b53a3f615a596f8ec21f94d361 Mon Sep 17 00:00:00 2001 From: Dario Lencina Date: Wed, 26 Aug 2026 23:21:22 -0700 Subject: [PATCH 09/14] Fix ISO readout doubling; make zoom work under Cinematic MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ISO slider read 'ISO ISO 400': the pill's readout prefixes the control name, and the ISO value label also carried 'ISO'. The value label is now bare ('400'); the camera chip keeps ProStops.isoLabel. Zoom did nothing with Cinematic on because Cinematic restricts zoom to a narrower band (videoMin/MaxZoomFactorForCinematicVideo) but the camera clamped and advertised the device's full range — the monitor's pill asked for factors Cinematic would not honor. One effectiveZoomBoundsLocked helper now backs the clamp, the advertised range and the getters; and enabling or disabling Cinematic republishes the zoom range (SetZoomResp) so the pill re-scales at once, on the 1:1 monitor and the director alike. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01K5uHRuiiLu5FEBiHZkBWo3 --- RemoteCam/CaptureEngine.swift | 39 +++++++++++++++++------ RemoteCam/ProSliderPill.swift | 5 ++- RemoteCam/SessionCoordinator.swift | 9 ++++++ RemoteCamTests/LoopbackSessionTests.swift | 4 +++ RemoteCamTests/ProSliderScaleTests.swift | 2 +- 5 files changed, 48 insertions(+), 11 deletions(-) diff --git a/RemoteCam/CaptureEngine.swift b/RemoteCam/CaptureEngine.swift index 8fc8308a..09f61f56 100644 --- a/RemoteCam/CaptureEngine.swift +++ b/RemoteCam/CaptureEngine.swift @@ -1651,6 +1651,24 @@ final class CaptureEngine: NSObject, AVCapturePhotoCaptureDelegate { try await onSessionQueueThrowing { try self.setZoomLocked(zoomFactor: zoomFactor) } } + /// The zoom range the camera can honor right now. Cinematic Video capture + /// restricts zoom to its own narrower band (`videoMin/MaxZoomFactorForCinematicVideo`); + /// outside Cinematic it is the device's available range. Every zoom clamp, + /// the range advertised to the monitor, and the getters read this — so the + /// remote's pill can never ask for a factor Cinematic will reject. + private func effectiveZoomBoundsLocked(_ device: AVCaptureDevice) -> (min: CGFloat, max: CGFloat) { + let deviceMin = device.minAvailableVideoZoomFactor + let deviceMax = device.maxAvailableVideoZoomFactor + if #available(iOS 26.0, macCatalyst 26.0, *), + videoDeviceInput?.isCinematicVideoCaptureEnabled == true { + let format = device.activeFormat + let cineMin = max(deviceMin, format.videoMinZoomFactorForCinematicVideo) + let cineMax = min(deviceMax, format.videoMaxZoomFactorForCinematicVideo) + if cineMax > cineMin { return (cineMin, cineMax) } + } + return (deviceMin, deviceMax) + } + private func setZoomLocked(zoomFactor: CGFloat) throws -> (CGFloat, CameraLensType, RemoteCmd.ZoomRange) { dispatchPrecondition(condition: .onQueue(sessionQueue)) debugLog("🔍 DEBUG: setZoom called with factor: \(zoomFactor)") @@ -1660,15 +1678,15 @@ final class CaptureEngine: NSObject, AVCapturePhotoCaptureDelegate { throw NSError(domain: "No camera device available", code: 0, userInfo: nil) } + let bounds = effectiveZoomBoundsLocked(device) debugLog("🔍 DEBUG: Current device: \(device.localizedName), position: \(device.position.rawValue)") - debugLog("🔍 DEBUG: Zoom range: \(device.minAvailableVideoZoomFactor) - \(device.maxAvailableVideoZoomFactor)") + debugLog("🔍 DEBUG: Zoom range: \(bounds.min) - \(bounds.max) (cinematic-aware)") debugLog("🔍 DEBUG: Current zoom: \(device.videoZoomFactor)") do { try device.lockForConfiguration() - let clampedZoom = max(device.minAvailableVideoZoomFactor, - min(zoomFactor, device.maxAvailableVideoZoomFactor)) + let clampedZoom = max(bounds.min, min(zoomFactor, bounds.max)) debugLog("🔍 DEBUG: Setting zoom from \(device.videoZoomFactor) to \(clampedZoom)") device.videoZoomFactor = clampedZoom @@ -1681,10 +1699,7 @@ final class CaptureEngine: NSObject, AVCapturePhotoCaptureDelegate { debugLog("✅ DEBUG: Zoom set successfully to \(device.videoZoomFactor), lens: \(currentLensType.displayName)") - let zoomRange = RemoteCmd.ZoomRange( - minZoom: device.minAvailableVideoZoomFactor, - maxZoom: device.maxAvailableVideoZoomFactor - ) + let zoomRange = RemoteCmd.ZoomRange(minZoom: bounds.min, maxZoom: bounds.max) return (clampedZoom, currentLensType, zoomRange) } catch let error as NSError { @@ -1698,11 +1713,17 @@ final class CaptureEngine: NSObject, AVCapturePhotoCaptureDelegate { } func getMaxZoomFactor() async -> CGFloat { - await onSessionQueue { self.videoDeviceInput?.device.maxAvailableVideoZoomFactor ?? 1.0 } + await onSessionQueue { + guard let device = self.videoDeviceInput?.device else { return 1.0 } + return self.effectiveZoomBoundsLocked(device).max + } } func getMinZoomFactor() async -> CGFloat { - await onSessionQueue { self.videoDeviceInput?.device.minAvailableVideoZoomFactor ?? 1.0 } + await onSessionQueue { + guard let device = self.videoDeviceInput?.device else { return 1.0 } + return self.effectiveZoomBoundsLocked(device).min + } } // MARK: - Enhanced Lens Switching Methods diff --git a/RemoteCam/ProSliderPill.swift b/RemoteCam/ProSliderPill.swift index 7f0ef262..12dd682d 100644 --- a/RemoteCam/ProSliderPill.swift +++ b/RemoteCam/ProSliderPill.swift @@ -78,10 +78,13 @@ struct ProSliderScale: Equatable { stops: ProStops.allApertures.map { Double($0) })) } + /// The value only — the pill's readout prefixes the control name (the + /// `kind.title`), so ISO must not repeat it. (`ProStops.isoLabel`, which + /// includes "ISO", is for the camera chip that shows the value alone.) func label(_ value: Double) -> String { switch kind { case .shutter: return ProStops.shutterLabel(value) - case .iso: return ProStops.isoLabel(Float(value)) + case .iso: return String(Int(value.rounded())) case .aperture: return ProStops.apertureLabel(Float(value)) } } diff --git a/RemoteCam/SessionCoordinator.swift b/RemoteCam/SessionCoordinator.swift index 6fa9bd5d..5f3e7e85 100644 --- a/RemoteCam/SessionCoordinator.swift +++ b/RemoteCam/SessionCoordinator.swift @@ -1434,6 +1434,15 @@ public actor SessionCoordinator { do { let state = try await ctrl.setCinematic(cmd.intent) await sendOrGoToScanning(RemoteCmd.SetCinematicResp(state: state, error: nil)) + // Cinematic narrows (or, on disable, restores) the zoom range and + // may have clamped the current factor — tell the monitor so its + // zoom pill re-scales to what the camera can now honor. + await sendOrGoToScanning(RemoteCmd.SetZoomResp( + zoomFactor: await ctrl.getCurrentZoomFactor(), + currentLens: nil, + zoomRange: RemoteCmd.ZoomRange(minZoom: await ctrl.getMinZoomFactor(), + maxZoom: await ctrl.getMaxZoomFactor()), + error: nil)) } catch let refusal as CaptureEngine.CinematicRefusal { // A refusal is a message for the person at the remote, not a fault. await sendOrGoToScanning(RemoteCmd.SetCinematicResp(state: nil, error: refusal.asNSError)) diff --git a/RemoteCamTests/LoopbackSessionTests.swift b/RemoteCamTests/LoopbackSessionTests.swift index 1370dbd3..d9a73f7a 100644 --- a/RemoteCamTests/LoopbackSessionTests.swift +++ b/RemoteCamTests/LoopbackSessionTests.swift @@ -773,6 +773,10 @@ class LoopbackSessionTests: XCTestCase { let resp = cameraTransport.sentMessages.compactMap { $0 as? RemoteCmd.SetCinematicResp }.last XCTAssertEqual(resp?.state?.enabled, true) XCTAssertEqual(resp?.state?.simulatedAperture ?? 0, 2.8) + // Cinematic changes the zoom range: the camera re-advertises it so the + // monitor's pill re-scales (Cinematic narrows zoom; disabling widens it). + XCTAssertNotNil(cameraTransport.sentMessages.compactMap { $0 as? RemoteCmd.SetZoomResp }.last, + "enabling Cinematic must republish the zoom range") monitorCoordinator.tell(UICmd.SetCinematic(intent: .off)) await drainBothSessions() diff --git a/RemoteCamTests/ProSliderScaleTests.swift b/RemoteCamTests/ProSliderScaleTests.swift index 02a7314a..11e7aa3e 100644 --- a/RemoteCamTests/ProSliderScaleTests.swift +++ b/RemoteCamTests/ProSliderScaleTests.swift @@ -65,7 +65,7 @@ final class ProSliderScaleTests: XCTestCase { func testLabelsSpeakPhotography() { XCTAssertEqual(ProSliderScale.shutter(exposure).label(1.0 / 125), "1/125") - XCTAssertEqual(ProSliderScale.iso(exposure).label(400), "ISO 400") + XCTAssertEqual(ProSliderScale.iso(exposure).label(400), "400", "the pill's title already says ISO") let phone = CinematicState(enabled: true, simulatedAperture: 2.8, minSimulatedAperture: 1.4, maxSimulatedAperture: 16, defaultSimulatedAperture: 2, apertureLocked: false, notEnoughLight: false) From 33355070bfef94aa78b4a0f34b518b3fc48d1172 Mon Sep 17 00:00:00 2001 From: Dario Lencina Date: Thu, 27 Aug 2026 00:56:30 -0700 Subject: [PATCH 10/14] v11 control plane: one ControlState snapshot replaces per-command control responses Breaking wire change (app major 10 -> 11; the PeerBecameCamera/Monitor handshake bytes are untouched, so 10.x peers complete the version exchange and get the update prompt). Design: Docs/control-plane.md. - ControlState: the camera's complete control truth (logical device, mode, lens, zoom factor + EFFECTIVE range + stops, exposure?, cinematic?, focus), produced by ONE engine function; capability is presence. - ControlStateChanged (35) answers every control mutation (SetZoom, SwitchLens, SetExposure, SetCinematic) and is pushed unsolicited when a constraint moves (device swap, quality change, mode change). Refusals are typed (ControlRefusal) and always rendered. SetZoomResp / SwitchLensResp / SetExposureResp / SetCinematicResp and the ZoomRange tables are deleted; capabilities carry the snapshot as the seed. - Remotes are pure: one stored snapshot per camera (MonitorViewModel .controlState / CameraLink.control), updated only by the absorb seq-fold; every zoom/lens/exposure/cinematic read derives from it, so a stale range is unrepresentable (zoom-under-Cinematic by construction). - Coordinator: one respondWithControlState on the camera, one peerControl + absorbControlState on the monitor; gates are presence reads. Executed as four parallel work packages (coordinator, monitor, director, tests) against a frozen contract; suite: 923 tests, failures = the known machine-local three (local purchase state, keychain TLS, suspended webcam). Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01K5uHRuiiLu5FEBiHZkBWo3 --- Docs/control-plane.md | 136 ++++++ Docs/pro-controls.md | 90 ++-- RemoteCam/CameraControlling.swift | 19 +- RemoteCam/CameraDeviceDescriptor.swift | 3 +- RemoteCam/CameraLink.swift | 30 +- RemoteCam/CameraRig.swift | 30 +- RemoteCam/CameraViewModel.swift | 12 +- RemoteCam/CaptureEngine.swift | 174 +++++--- RemoteCam/ControlState.swift | 124 ++++++ RemoteCam/FlatBufferSchemas.fbs | 120 +++-- RemoteCam/FlatBufferSchemas_generated.swift | 301 ++++++------- RemoteCam/MonitorDisplay.swift | 6 +- RemoteCam/MonitorPresenter.swift | 76 +--- RemoteCam/MonitorViewController+SwiftUI.swift | 15 +- RemoteCam/MonitorViewController.swift | 7 +- RemoteCam/MonitorViewModel.swift | 80 ++-- RemoteCam/MulticamController.swift | 97 ++--- RemoteCam/MulticamViewModel.swift | 19 +- RemoteCam/RemoteCmdFlatBuffers.swift | 286 +++++------- RemoteCam/RemoteCmds.swift | 150 ++----- RemoteCam/SessionCoordinator.swift | 239 ++++------ RemoteCam/UICmds.swift | 37 +- RemoteCam/ZoomScale.swift | 28 +- RemoteCam/ZoomScaleSeed.swift | 49 --- RemoteCamTests/CaptureIntegrationTests.swift | 8 +- RemoteCamTests/CaptureSyncMetadataTests.swift | 3 +- RemoteCamTests/ControlStateTests.swift | 113 +++++ RemoteCamTests/LoopbackSessionTests.swift | 62 +-- RemoteCamTests/MessageDumpTests.swift | 30 +- RemoteCamTests/MonitorPresenterTests.swift | 102 ++--- .../MonitorScreenSnapshotTests.swift | 11 +- RemoteCamTests/MulticamControllerTests.swift | 95 ++-- RemoteCamTests/MulticamViewModelTests.swift | 86 ++-- RemoteCamTests/RemoteCamSessionTests.swift | 19 +- .../RemoteCmdSerializationTests.swift | 409 +++++------------- RemoteCamTests/RigQualityMenuTests.swift | 1 - RemoteCamTests/SessionTestSupport.swift | 128 ++++-- RemoteCamTests/ZoomScaleTests.swift | 2 +- RemoteShutter.xcodeproj/project.pbxproj | 16 +- 39 files changed, 1562 insertions(+), 1651 deletions(-) create mode 100644 Docs/control-plane.md create mode 100644 RemoteCam/ControlState.swift delete mode 100644 RemoteCam/ZoomScaleSeed.swift create mode 100644 RemoteCamTests/ControlStateTests.swift diff --git a/Docs/control-plane.md b/Docs/control-plane.md new file mode 100644 index 00000000..d5c6212d --- /dev/null +++ b/Docs/control-plane.md @@ -0,0 +1,136 @@ +# The Camera Control Plane + +Design for consolidating the pro-controls POC (branch `issue-206`, PR #223) +into one coherent API. Companion to `Docs/pro-controls.md`, which covers the +individual controls; this document covers how they compose. + +## Why (the lessons the POC taught) + +Every field bug on this branch was the same bug wearing different clothes: +**camera control state is one coupled system, but the code treated it as +independent fragments.** + +1. **Constraints couple across controls, and the coupling was hand-wired.** + Cinematic narrows the zoom range and pins focus; a quality change moves the + exposure ranges; a device swap changes everything. Each coupling today is a + manually-placed patch (a `SetZoomResp` republished after `SetCinematic`; + re-apply calls sprinkled through `swapToDeviceLocked` and + `setVideoQualityLocked`). Forgetting one is invisible until hardware finds + it — we shipped three of these in one week (zoom dead under Cinematic, + flip dead under Manual, Cinematic silently refusing). +2. **Truth is scattered on the wire.** The zoom range alone is constructed in + five engine sites and carried by four message types (`SetZoomResp`, + `SwitchLensResp`, `ToggleCameraResp`→capabilities, `CameraInfo.zoom_capabilities`). + Exposure and Cinematic each add a response type plus capabilities fields. + The 1:1 monitor and the multicam director stitch these partial updates + together with two different sets of glue. +3. **A refusal must be a message, not a no-op.** Every silently-absorbed + failure read as "the button is broken". +4. **Device identity needs one answer.** The Manual lens hop split "the camera + the user chose" from "the device the session runs" — every identity read + must go through the logical-device function or a control breaks. + +## The design + +### One truth type: `CameraControlState` + +The camera's complete control-plane truth, produced in exactly one place and +consumed everywhere — app objects and wire table have the same shape: + +``` +CameraControlState { + seq // monotonic, same stale-drop rule as CameraStateReport + activeDeviceID // LOGICAL identity (the Manual hop never leaks) + mode // photo | video + zoom { + factor + min, max // EFFECTIVE range — already Cinematic-aware + stops[], wideAngleZoomFactor + } + exposure? { mode, durationSeconds, iso, min/max duration, min/max ISO } // nil = unsupported + cinematic? { enabled, aperture, min/max/default aperture, + apertureLocked, notEnoughLight } // nil = unsupported + focus { supportsPoint, cinematicTracking } +} +``` + +The example that motivates the shape: *zoom shows the right range under +Cinematic because there is no separate zoom range to go stale.* A monitor +holding the latest snapshot cannot disagree with the camera about any +constraint, because constraints travel together. + +### Engine: declarative intents, one reconcile, one snapshot producer + +``` +CaptureEngine (sessionQueue-confined) + intents: ExposureIntent, CinematicIntent // declarative, survive re-entry + entry points: setExposure / setCinematic / setZoom / setMode / + device swap / quality change + → mutateControlsLocked { } // the ONLY mutation wrapper + 1. run the change + 2. reconcileLocked() // fixed order: + device identity (Manual hop) → format/Cinematic → + exposure → zoom clamp → focus + 3. return controlSnapshotLocked() // the ONLY snapshot producer +``` + +Pure, table-tested policies decide; the engine only executes: +- `ExposurePolicy`, `CinematicPolicy` (exist today); +- **`ZoomPolicy.effectiveRange(deviceRange:cinematicRange:cinematicOn:)`** + (new — the pure core of `effectiveZoomBoundsLocked`). + +A refusal is a typed `ControlRefusal` (photo-mode, recording, unsupported, +session-refused) thrown by the reconcile, carried on the wire, and *always* +rendered by the remote (toast on the director, alert on the 1:1 monitor). + +### Wire: intents in, snapshots out (append-only; 33/34 are unreleased) + +Commands stay small intents — `SetExposure = 33`, `SetCinematic = 34`, and the +long-released `SetZoom`. What changes is the answer: + +- **`ControlStateChanged = 35`** (camera → remote) carries the full + `CameraControlState` plus an optional refusal. It is sent: + - as the response to 33/34 (replacing `SetExposureResp`/`SetCinematicResp`), + - unsolicited after any internal event that moves a constraint: device swap, + quality change, mode change, Cinematic toggle (this deletes the ad-hoc + zoom republish), recording start/stop (aperture locks). +- Capabilities keep carrying the snapshot so the first exchange seeds it. +- Released peers know nothing of 33–35 (capability-gated), and `SetZoomResp` + stays for their zoom — no compat cost. + +### Remote: one absorb per surface + +- 1:1 monitor: `MonitorPresenter.applyControlState(_)` → + `MonitorViewModel.controlState` (one `@Published`); the per-field fragments + (`exposure`, `cinematic`, `zoomStops`, `maxZoomFactor`, …) become derived + reads of it. +- Director: `CameraLink.controlState`, surfaced through `MulticamLaneInfo`. +- All UI derivations are already pure and stay: `MonitorTray.proTiles`, + `ProSliderScale`, `ZoomScale` — they just read one input. A feature wired + into the snapshot is automatically on *both* remote screens. + +### What this deletes + +- 5 `ZoomRange` construction sites → 1 (`controlSnapshotLocked`). +- `SetExposureResp`, `SetCinematicResp`, and the post-Cinematic `SetZoomResp` + echo. +- The presenter's `updateZoom` / `updateExposure` / `updateCinematic` trio + (legacy `SetZoomResp` handling stays for released peers). +- Per-field seeding in `seedZoom` / `updateCapabilities` for new peers. + +## Tests that pin it + +- `ZoomPolicy` table tests (device range × cinematic range × on/off). +- Snapshot FlatBuffers round-trip (+ absent-fields legacy decode). +- Loopback, wire-level: *enable Cinematic → the monitor's zoom scale narrows* + (the exact field bug, as a regression test); *quality change → exposure + ranges move on the monitor*; stale `seq` dropped. +- Director: lane absorbs a snapshot; tiles/sliders follow the focused lane. +- Existing policy, tray, and scale tests unchanged. + +## Non-goals (tracked, not in this consolidation) + +- Torch/flash/timer/aspect migration into the snapshot (settled flows; move + only when next touched). +- Multicam broadcast of one setting to N cameras. +- Watch surface for pro controls. diff --git a/Docs/pro-controls.md b/Docs/pro-controls.md index dab38aa8..77d8dbc1 100644 --- a/Docs/pro-controls.md +++ b/Docs/pro-controls.md @@ -15,7 +15,7 @@ capability-gated wire command, one `CameraControlling` method, one usable by a person standing across the room from the phone. > Each control is gated on its own capability flag -> (`supports_manual_exposure`, `supports_cinematic_video`), so a 10.0.x camera +> (the `ControlState` snapshot's `exposure`/`cinematic` presence), so a 10.0.x camera > pairs exactly as before and **a button only appears when the connected camera > offers that feature**. The UI ships behind > `FeatureFlags.ENABLE_PRO_CONTROLS` and is free for every user — no IAP. @@ -94,62 +94,50 @@ flowchart LR SC2 == "…Resp · ExposureState / CinematicState" ==> SC1 ``` -## Wire protocol (`FlatBufferSchemas.fbs`, append-only) +## Wire protocol (v11 — see Docs/control-plane.md for the full design) + +Commands are small intents; every answer is the whole `ControlState` snapshot: ``` -enum ExposureMode : byte { Unknown = 0, Auto = 1, Manual = 2 } +enum ExposureMode : byte { Unknown = 0, Auto = 1, Manual = 2 } +enum ControlRefusal : byte { Unknown, None, PhotoMode, Recording, Unsupported, SessionRefused } // CommandAction -SetExposure = 33 -SetCinematic = 34 - -// CommandParameters — appended -exposure_mode: ExposureMode; -exposure_duration_seconds: double; // 0 = keep current -exposure_iso: float; // 0 = keep current -cinematic_enabled: bool; -simulated_aperture: float; // 0 = keep current - -table ExposureState { - mode: ExposureMode; - duration_seconds: double; // currently applied - iso: float; - min_duration_seconds: double; // activeFormat range - max_duration_seconds: double; - min_iso: float; - max_iso: float; -} - -table CinematicState { - enabled: bool; - simulated_aperture: float; // currently applied - min_simulated_aperture: float; // 0 = fixed aperture, hide the dial - max_simulated_aperture: float; - default_simulated_aperture: float; - aperture_locked: bool; // true while recording — dial disabled - not_enough_light: bool; // from cinematicVideoCaptureSceneMonitoringStatuses +SetExposure = 33 // intent in CommandParameters (exposure_*) +SetCinematic = 34 // intent in CommandParameters (cinematic_*) +ControlStateChanged = 35 // camera -> remote: THE control-truth channel + +table ControlState { + seq: uint64; // monotonic; stale snapshots dropped + mode: RecordingModeEnum; + active_device_id: string; // the LOGICAL device (Manual hop never leaks) + current_lens: CameraLensType; + available_lenses: [CameraLensType]; + zoom_factor: double; + min_zoom: double; // EFFECTIVE range — already narrowed + max_zoom: double; // by Cinematic when it is on + zoom_stops: [double]; + wide_angle_zoom_factor: double; + supports_focus_point: bool; + exposure: ExposureState; // ABSENT = no manual exposure (no tiles) + cinematic: CinematicState; // ABSENT = no Cinematic (no tile) } - -// CameraCapabilities — appended -supports_manual_exposure: bool; // active device supports .custom -exposure: ExposureState; // so the panel opens populated -supports_cinematic_video: bool; // iOS 26+ and active format supports it -cinematic: CinematicState; - -// CameraStateResponse — appended -exposure: ExposureState; // echoed on SetExposureResp -cinematic: CinematicState; // echoed on SetCinematicResp ``` +`ExposureState`/`CinematicState` keep their shapes (mode + applied values + +active-format ranges; enabled + aperture + range + `aperture_locked` + +`not_enough_light`). `ControlStateChanged` answers every control mutation +(`SetZoom`, `SwitchLens`, `SetExposure`, `SetCinematic`) and is pushed +unsolicited whenever a constraint moves (device swap, quality change, mode +change, Cinematic toggling the zoom range). A refused mutation carries a +typed `ControlRefusal` (+ diagnostic detail) NEXT TO the unchanged snapshot. +`CameraCapabilities` carries `control` as the seed; per-command response +shapes (`SetExposureResp` etc.) do not exist. + Durations travel as seconds (`double`) and are clamped back into the device's own `CMTime` range on the camera — the wire never carries a timescale. - -`RemoteCmd.SetExposure/Resp`, `RemoteCmd.SetCinematic/Resp` in -`RemoteCmds.swift`; encode/decode in `RemoteCmdFlatBuffers.swift` next to -`SetZoom`. `CameraCapabilitiesResp` gains the two flags and two state tables. -`not_enough_light` is sampled from the device's scene-monitoring statuses -whenever a Cinematic response or capabilities refresh is built — the hint -updates with the next echo rather than by push. +`not_enough_light` is sampled whenever a snapshot is built — the hint updates +with the next echo rather than by push. ## Monitor → camera path (both controls) @@ -163,7 +151,7 @@ updates with the next echo rather than by push. slow or lost response can never wedge the screen. 3. **Camera handler** (root camera state and video-mode state, next to `SetZoom`): `let state = try await ctrl.setExposure(intent)` → - `sendOrGoToScanning(RemoteCmd.SetExposureResp(state))`; same for + `respondWithControlState { try await ctrl.setExposure(intent) }`; same for cinematic. 4. **Rig** forwards to the engine and updates `cameraViewModel.proReadout`. 5. **Engine** (`sessionQueue`, `lockForConfiguration`) — below. @@ -198,8 +186,8 @@ the input to a physical lens that accepts `.custom`: the one currently in use (`device.activePrimaryConstituent`, iOS 15+) when the session is running, else the wide lens from `constituentDevices`. Returning to Auto swaps back to the virtual device. While Manual is on, zoom is the physical lens's own range (no -auto lens switching); the existing `SetZoomResp` range echo already informs -the monitor's zoom slider. +auto lens switching); the snapshot's effective zoom range keeps the +monitor's zoom pill honest. `supports_manual_exposure` is decided from `constituentDevices`, never from `activePrimaryConstituent` alone: Apple documents that property as nil until diff --git a/RemoteCam/CameraControlling.swift b/RemoteCam/CameraControlling.swift index ce659c01..baa1ecca 100644 --- a/RemoteCam/CameraControlling.swift +++ b/RemoteCam/CameraControlling.swift @@ -41,18 +41,23 @@ protocol CameraControlling: AnyObject, Sendable { /// for tiered director previews. Never called in a single-camera session. func applyStreamProfile(_ profile: StreamProfile) - func setZoom(zoomFactor: CGFloat) async throws -> (CGFloat, CameraLensType, RemoteCmd.ZoomRange) + /// Every control mutation answers with the full snapshot — the payload + /// of `ControlStateChanged`. There are no per-control response shapes. + func setZoom(zoomFactor: CGFloat) async throws -> ControlState /// Sets the focus/exposure point of interest from a monitor tap. `x`/`y` are /// normalized (0..1) in the upright display image, origin top-left. /// Fire-and-forget: a no-op if the active device has no point of interest. func focusAtPoint(x: Float, y: Float) async throws /// Auto or manual (shutter + ISO) exposure. The device clamps into its - /// active format's range; the returned state is the truth to echo. - func setExposure(_ intent: ExposureIntent) async throws -> ExposureState - /// Cinematic video (iOS 26+) on/off + simulated aperture; the returned - /// truth is what the monitor renders. - func setCinematic(_ intent: CinematicIntent) async throws -> CinematicState - func switchLens(to lensType: CameraLensType) async throws -> (CameraLensType, [CameraLensType], CGFloat, RemoteCmd.ZoomRange) + /// active format's range. + func setExposure(_ intent: ExposureIntent) async throws -> ControlState + /// Cinematic video (iOS 26+) on/off + simulated aperture. Refusals throw + /// `CaptureEngine.CinematicRefusal`. + func setCinematic(_ intent: CinematicIntent) async throws -> ControlState + func switchLens(to lensType: CameraLensType) async throws -> ControlState + /// The current snapshot, for pushes and capability seeds. Nil before the + /// capture device exists. + func controlState() async -> ControlState? func toggleFlash() async throws -> AVCaptureDevice.FlashMode func toggleTorch() async throws -> AVCaptureDevice.TorchMode func toggleCamera() async throws -> (AVCaptureDevice.FlashMode?, AVCaptureDevice.Position) diff --git a/RemoteCam/CameraDeviceDescriptor.swift b/RemoteCam/CameraDeviceDescriptor.swift index 0eb31d68..8a88d7c3 100644 --- a/RemoteCam/CameraDeviceDescriptor.swift +++ b/RemoteCam/CameraDeviceDescriptor.swift @@ -85,6 +85,7 @@ struct CameraSelectionResult { /// nil when the device has no flash (every Mac camera). let flashMode: AVCaptureDevice.FlashMode? let availableLensTypes: [CameraLensType] - let zoomRange: RemoteCmd.ZoomRange + /// Zoom truth lives in the `ControlState` snapshot the swap pushes; + /// this result only identifies the device the swap landed on. let currentZoom: CGFloat } diff --git a/RemoteCam/CameraLink.swift b/RemoteCam/CameraLink.swift index 6c809dc0..781e84cc 100644 --- a/RemoteCam/CameraLink.swift +++ b/RemoteCam/CameraLink.swift @@ -91,20 +91,12 @@ final class CameraLink { var torchOn = false var flashOn = false - /// Zoom state for the focused zoom pill, seeded from the capabilities - /// exchange and refined by each `SetZoomResp` — the same values the 1:1 - /// monitor tracks (`zoomStops`/`wideAngleZoomFactor`/`maxZoomFactor` build - /// the `ZoomScale`; `zoomFactor` is the live hardware factor). - var zoomFactor: CGFloat = 1.0 - var maxZoomFactor: CGFloat = 10.0 - var zoomStops: [CGFloat] = [1.0] - var wideAngleZoomFactor: CGFloat = 1.0 - - /// This camera's exposure / Cinematic truth (issue #206): seeded from its - /// capabilities, replaced by every `SetExposureResp` / `SetCinematicResp` - /// echo. The panel shows these, never the value that was requested. - var exposure: ExposureState? - var cinematic: CinematicState? + /// This camera's complete control-plane truth — zoom range, lens, manual + /// exposure and Cinematic — as ONE value (v11). Seeded from the + /// capabilities exchange and folded forward by `ControlState.absorb` on + /// every `ControlStateChanged`; the lane renders `f(control)` with no + /// stored derivations to drift. Nil until the first snapshot lands. + var control: ControlState? init(peerID: MCPeerID) { self.peerID = peerID @@ -130,16 +122,8 @@ final class CameraLink { canFlipCamera: capabilities.map { $0.frontCamera != nil && $0.backCamera != nil } ?? false, - supportsFocusPoint: capabilities?.supportsFocusPoint ?? false, - supportsManualExposure: capabilities?.supportsManualExposure ?? false, - exposure: exposure, - supportsCinematicVideo: capabilities?.supportsCinematicVideo ?? false, - cinematic: cinematic, + control: control, hasTorch: capabilities?.getCurrentCameraInfo()?.hasTorch ?? false, - zoomFactor: zoomFactor, - maxZoomFactor: maxZoomFactor, - zoomStops: zoomStops, - wideAngleZoomFactor: wideAngleZoomFactor, torchOn: torchOn, flashOn: flashOn) } diff --git a/RemoteCam/CameraRig.swift b/RemoteCam/CameraRig.swift index 03204588..6a07049b 100644 --- a/RemoteCam/CameraRig.swift +++ b/RemoteCam/CameraRig.swift @@ -90,6 +90,12 @@ final class CameraRig: @unchecked Sendable { // Cinematic only applies to video: leaving the mode switches the // effect off (the engine is a no-op when it wasn't on). if leftVideoMode { engine.disableCinematicIfActive() } + // Mode is part of the control snapshot; the remote learns the + // change (and any Cinematic fallout) without asking for it. + Task { [weak self] in + guard let self, let state = await self.engine.controlState() else { return } + self.session ! UICmd.PushControlState(state: state) + } } } @@ -158,6 +164,14 @@ final class CameraRig: @unchecked Sendable { engine.isVideoModeProvider = { [currentCameraModeShared] in currentCameraModeShared.value == .Video } + engine.recordingModeProvider = { [currentCameraModeShared] in + currentCameraModeShared.value + } + // Unsolicited constraint moves (device swap, quality change) go to + // the session, which pushes them to the remote as ControlStateChanged. + engine.onControlStateChanged = { [session] state in + session ! UICmd.PushControlState(state: state) + } // Captures the session ref (not self) so recording acks/responses still // reach the actor if the rig deallocates mid-recording. pipeline.sendMessage = { [session] msg in @@ -525,22 +539,26 @@ extension CameraRig: CameraControlling { try await engine.setTorchMode(mode: mode) } - func setZoom(zoomFactor: CGFloat) async throws -> (CGFloat, CameraLensType, RemoteCmd.ZoomRange) { + func setZoom(zoomFactor: CGFloat) async throws -> ControlState { try await engine.setZoom(zoomFactor: zoomFactor) } - func setExposure(_ intent: ExposureIntent) async throws -> ExposureState { + func setExposure(_ intent: ExposureIntent) async throws -> ControlState { let state = try await engine.setExposure(intent) - cameraViewModel.updateExposureReadout(state) + cameraViewModel.updateExposureReadout(state.exposure) return state } - func setCinematic(_ intent: CinematicIntent) async throws -> CinematicState { + func setCinematic(_ intent: CinematicIntent) async throws -> ControlState { let state = try await engine.setCinematic(intent) - cameraViewModel.updateCinematicReadout(state) + cameraViewModel.updateCinematicReadout(state.cinematic) return state } + func controlState() async -> ControlState? { + await engine.controlState() + } + func focusAtPoint(x: Float, y: Float) async throws { // Show the same reticle the monitor draws, so the person holding the // camera sees the tap land — on every command, even where the device @@ -549,7 +567,7 @@ extension CameraRig: CameraControlling { try await engine.setFocusExposurePoint(displayNormalized: CGPoint(x: CGFloat(x), y: CGFloat(y))) } - func switchLens(to lensType: CameraLensType) async throws -> (CameraLensType, [CameraLensType], CGFloat, RemoteCmd.ZoomRange) { + func switchLens(to lensType: CameraLensType) async throws -> ControlState { try await engine.switchLens(to: lensType) } diff --git a/RemoteCam/CameraViewModel.swift b/RemoteCam/CameraViewModel.swift index 306637e3..79ab9b24 100644 --- a/RemoteCam/CameraViewModel.swift +++ b/RemoteCam/CameraViewModel.swift @@ -185,21 +185,21 @@ class CameraViewModel: ObservableObject { private var exposureReadoutText: String? private var cinematicReadoutText: String? - func updateExposureReadout(_ state: ExposureState) { + func updateExposureReadout(_ state: ExposureState?) { DispatchQueue.main.async { [weak self] in guard let self else { return } - self.exposureReadoutText = state.mode == .manual - ? "M \(ProStops.shutterLabel(state.durationSeconds)) · \(ProStops.isoLabel(state.iso))" + self.exposureReadoutText = (state?.mode == .manual) + ? state.map { "M \(ProStops.shutterLabel($0.durationSeconds)) · \(ProStops.isoLabel($0.iso))" } : nil self.recomposeProReadout() } } - func updateCinematicReadout(_ state: CinematicState) { + func updateCinematicReadout(_ state: CinematicState?) { DispatchQueue.main.async { [weak self] in guard let self else { return } - self.cinematicReadoutText = state.enabled - ? "CINEMATIC \(ProStops.apertureLabel(state.simulatedAperture))" + self.cinematicReadoutText = (state?.enabled == true) + ? state.map { "CINEMATIC \(ProStops.apertureLabel($0.simulatedAperture))" } : nil self.recomposeProReadout() } diff --git a/RemoteCam/CaptureEngine.swift b/RemoteCam/CaptureEngine.swift index 09f61f56..26222697 100644 --- a/RemoteCam/CaptureEngine.swift +++ b/RemoteCam/CaptureEngine.swift @@ -147,6 +147,16 @@ final class CaptureEngine: NSObject, AVCapturePhotoCaptureDelegate { /// Fired whenever camera status (resolution/frame rate/format/HDR) changes so /// the VC can refresh its status overlay. var onStatusChanged: (() -> Void)? + /// Fired whenever the control snapshot changes WITHOUT the remote asking + /// (device swap, quality change) — the coordinator turns it into an + /// unsolicited `ControlStateChanged` push. Requested mutations return + /// their snapshot instead. + var onControlStateChanged: ((ControlState) -> Void)? + /// The camera's photo/video mode, owned by the rig; part of the snapshot. + var recordingModeProvider: () -> RecordingMode = { .Photo } + /// Monotonic snapshot counter, epoch-seeded so it survives engine + /// restarts (the remote's `absorb` drops anything older). + private var controlSeq = UInt64(Date().timeIntervalSince1970 * 1000) /// The capture device was swapped underneath the running outputs — a flip, a /// device pick, or a lens switch. The scene cuts completely, but the preview /// encoder is not recreated unless the *scaled* dimensions happen to change @@ -437,14 +447,15 @@ final class CaptureEngine: NSObject, AVCapturePhotoCaptureDelegate { // Every device swap funnels through here — toggle, device pick and lens // switch alike — so this is the one place that has to announce the cut. onDeviceSwapped?() + // A swap moves every constraint at once; the remote is told without + // asking (requested mutations return their own snapshot as well — + // `absorb` collapses the duplicate). + pushControlStateLocked() return CameraSelectionResult( device: descriptorLocked(newDevice), flashMode: newFlashMode, availableLensTypes: availableLensTypes, - zoomRange: RemoteCmd.ZoomRange( - minZoom: newDevice.minAvailableVideoZoomFactor, - maxZoom: newDevice.maxAvailableVideoZoomFactor), currentZoom: currentZoomFactor) } @@ -1014,19 +1025,6 @@ final class CaptureEngine: NSObject, AVCapturePhotoCaptureDelegate { // Check if any camera on this position has torch let hasTorch = videoDevices.contains { $0.hasTorch } - // Gather zoom capabilities for each lens type - var zoomCapabilities: [CameraLensType: RemoteCmd.ZoomRange] = [:] - - for lensType in availableLenses { - if let device = videoDevices.first(where: { $0.deviceType == lensType.deviceType }) { - let zoomRange = RemoteCmd.ZoomRange( - minZoom: device.minAvailableVideoZoomFactor, - maxZoom: device.maxAvailableVideoZoomFactor - ) - zoomCapabilities[lensType] = zoomRange - } - } - // Gather quality capabilities — probe each resolution's actual FPS limits let supportedResolutions = VideoResolution.selectableCases.filter { r in captureSession.canSetSessionPreset(r.sessionPreset) @@ -1049,23 +1047,15 @@ final class CaptureEngine: NSObject, AVCapturePhotoCaptureDelegate { let supportsHEIF = photoOutput.availablePhotoCodecTypes.contains(.hevc) let supportsHDR = true // All iOS 15+ devices support .quality prioritization (HDR) - // Discover zoom stops from the preferred (virtual) device - let preferredDevice = preferredCamera(for: position) - let discoveredZoomStops = preferredDevice.map { discoverZoomStops(for: $0) } ?? [1.0] - let wideAngle = preferredDevice.map { wideAngleZoomFactor(for: $0) } ?? 1.0 - return RemoteCmd.CameraInfo( availableLenses: availableLenses, hasFlash: hasFlash, hasTorch: hasTorch, - zoomCapabilities: zoomCapabilities, supportedResolutions: supportedResolutions, supportedFrameRates: supportedFrameRates, resolutionFrameRates: resolutionFrameRates, supportsHEIF: supportsHEIF, - supportsHDR: supportsHDR, - zoomStops: discoveredZoomStops, - wideAngleZoomFactor: wideAngle + supportsHDR: supportsHDR ) } @@ -1116,23 +1106,16 @@ final class CaptureEngine: NSObject, AVCapturePhotoCaptureDelegate { debugLog("🔍 DEBUG: frontCameraInfo: \(frontCameraInfo != nil ? "available" : "nil")") debugLog("🔍 DEBUG: backCameraInfo: \(backCameraInfo != nil ? "available" : "nil")") - let (deviceEntries, activeDeviceID) = cameraDeviceEntriesLocked() + let (deviceEntries, _) = cameraDeviceEntriesLocked() let capabilities = RemoteCmd.CameraCapabilitiesResp( frontCamera: frontCameraInfo, backCamera: backCameraInfo, currentCamera: currentDevice.position, - currentLens: currentLensType, - currentZoom: currentZoomFactor, currentVideoResolution: currentVideoResolution, currentVideoFrameRate: currentVideoFrameRate, currentPhotoFormat: currentPhotoFormat, currentHDRMode: currentHDRMode, cameraDevices: deviceEntries, - activeDeviceID: activeDeviceID, - // Matches setFocusExposurePointLocked's apply predicate: a device that - // supports only exposure POI still benefits from a tap. - supportsFocusPoint: currentDevice.isFocusPointOfInterestSupported - || currentDevice.isExposurePointOfInterestSupported, // This build understands SetCameraPreviewMode; advertise the current // persisted mode so the monitor reflects it from the first exchange. supportsPreviewMode: true, @@ -1140,14 +1123,9 @@ final class CaptureEngine: NSObject, AVCapturePhotoCaptureDelegate { // release the director UI ships. supportsMulticam: FeatureFlags.ENABLE_MULTICAM, previewMode: CameraPreviewModeStore().load(), - // A property of the ACTIVE device, re-advertised on every - // capabilities refresh after a swap. Virtual devices qualify when - // their active physical lens accepts .custom (the engine hops to - // it when Manual is engaged). - supportsManualExposure: deviceSupportsManualExposureLocked(currentDevice), - exposure: exposureStateLocked(currentDevice), - supportsCinematicVideo: supportsCinematicVideoLocked(), - cinematic: currentCinematicStateLocked(), + // The control-plane seed: the same snapshot ControlStateChanged + // pushes, so the first exchange configures the remote completely. + control: controlStateLocked(), error: nil ) @@ -1179,10 +1157,7 @@ final class CaptureEngine: NSObject, AVCapturePhotoCaptureDelegate { return RemoteCmd.CameraInfo( availableLenses: [.wideAngle], hasFlash: device.hasFlash, - hasTorch: device.hasTorch, - zoomCapabilities: [.wideAngle: RemoteCmd.ZoomRange( - minZoom: device.minAvailableVideoZoomFactor, - maxZoom: device.maxAvailableVideoZoomFactor)]) + hasTorch: device.hasTorch) #else switch device.position { case .front: return frontCameraInfo @@ -1192,6 +1167,47 @@ final class CaptureEngine: NSObject, AVCapturePhotoCaptureDelegate { #endif } + // MARK: - Control snapshot (the ONE producer) + + /// The camera's complete control-plane truth. This is the only function + /// that assembles a `ControlState`, so every range in it is effective by + /// construction: zoom bounds come from `effectiveZoomBoundsLocked` + /// (Cinematic-aware), identity from `logicalDeviceIDLocked` (the Manual + /// hop never leaks), capability from presence. + private func controlStateLocked() -> ControlState? { + dispatchPrecondition(condition: .onQueue(sessionQueue)) + guard let device = videoDeviceInput?.device else { return nil } + controlSeq += 1 + let bounds = effectiveZoomBoundsLocked(device) + return ControlState( + seq: controlSeq, + mode: recordingModeProvider(), + activeDeviceID: logicalDeviceIDLocked(), + currentLens: currentLensType, + availableLenses: availableLensTypes.isEmpty ? [.wideAngle] : availableLensTypes, + zoomFactor: currentZoomFactor, + minZoom: bounds.min, + maxZoom: bounds.max, + zoomStops: zoomStops, + wideAngleZoomFactor: wideAngleZoomFactor(for: device), + // Matches setFocusExposurePointLocked's apply predicate: a device + // that supports only exposure POI still benefits from a tap. + supportsFocusPoint: device.isFocusPointOfInterestSupported + || device.isExposurePointOfInterestSupported, + exposure: deviceSupportsManualExposureLocked(device) ? exposureStateLocked(device) : nil, + cinematic: supportsCinematicVideoLocked() ? currentCinematicStateLocked() : nil) + } + + func controlState() async -> ControlState? { + await onSessionQueue { self.controlStateLocked() } + } + + /// Announce a constraint move the remote did not ask about. + private func pushControlStateLocked() { + guard let state = controlStateLocked() else { return } + onControlStateChanged?(state) + } + // MARK: - Focus / Exposure Point /// Sets the focus and exposure point of interest from a monitor tap. `point` @@ -1288,11 +1304,12 @@ final class CaptureEngine: NSObject, AVCapturePhotoCaptureDelegate { /// Stores the monitor's intent and makes the device match it. Returns the /// device's exposure truth afterwards (the response payload). - func setExposure(_ intent: ExposureIntent) async throws -> ExposureState { + func setExposure(_ intent: ExposureIntent) async throws -> ControlState { try await onSessionQueueThrowing { self.exposureIntent = intent self.reconcileExposureDeviceLocked() - guard let state = self.applyExposureIntentLocked() else { + guard self.applyExposureIntentLocked() != nil, + let state = self.controlStateLocked() else { throw NSError(domain: "No camera device available", code: 0, userInfo: nil) } return state @@ -1434,10 +1451,11 @@ final class CaptureEngine: NSObject, AVCapturePhotoCaptureDelegate { /// Stores the monitor's intent and makes the session match it. Returns the /// camera's Cinematic truth afterwards (the response payload). - func setCinematic(_ intent: CinematicIntent) async throws -> CinematicState { + func setCinematic(_ intent: CinematicIntent) async throws -> ControlState { try await onSessionQueueThrowing { self.cinematicIntent = intent - guard let state = try self.applyCinematicIntentLocked() else { + guard try self.applyCinematicIntentLocked() != nil, + let state = self.controlStateLocked() else { throw NSError(domain: "No camera device available", code: 0, userInfo: nil) } return state @@ -1472,7 +1490,27 @@ final class CaptureEngine: NSObject, AVCapturePhotoCaptureDelegate { } } - var asNSError: NSError { NSError(domain: message, code: 0, userInfo: nil) } + /// The wire-level refusal reason carried in `ControlStateChanged`, so + /// the remote renders one typed message (`ControlRefusalReason`) rather + /// than parsing this string. + var reason: ControlRefusalReason { + switch self { + case .photoMode: return .photoMode + case .recording: return .recording + case .unsupported: return .unsupported + case .sessionRefused: return .sessionRefused + } + } + + /// The diagnostic suffix the remote appends to the reason's base + /// message. Nil where the reason alone says everything. + var detail: String? { + switch self { + case .photoMode, .recording: return nil + case let .unsupported(device): return device + case let .sessionRefused(device, format, outputs): return "\(device) (\(format); outputs: \(outputs))" + } + } } /// Rig hook for mode changes: leaving video mode switches the effect off @@ -1647,8 +1685,14 @@ final class CaptureEngine: NSObject, AVCapturePhotoCaptureDelegate { } // MARK: - Enhanced Zoom Control Methods - func setZoom(zoomFactor: CGFloat) async throws -> (CGFloat, CameraLensType, RemoteCmd.ZoomRange) { - try await onSessionQueueThrowing { try self.setZoomLocked(zoomFactor: zoomFactor) } + func setZoom(zoomFactor: CGFloat) async throws -> ControlState { + try await onSessionQueueThrowing { + try self.setZoomLocked(zoomFactor: zoomFactor) + guard let state = self.controlStateLocked() else { + throw NSError(domain: "No camera device available", code: 0, userInfo: nil) + } + return state + } } /// The zoom range the camera can honor right now. Cinematic Video capture @@ -1669,7 +1713,7 @@ final class CaptureEngine: NSObject, AVCapturePhotoCaptureDelegate { return (deviceMin, deviceMax) } - private func setZoomLocked(zoomFactor: CGFloat) throws -> (CGFloat, CameraLensType, RemoteCmd.ZoomRange) { + private func setZoomLocked(zoomFactor: CGFloat) throws { dispatchPrecondition(condition: .onQueue(sessionQueue)) debugLog("🔍 DEBUG: setZoom called with factor: \(zoomFactor)") @@ -1698,10 +1742,6 @@ final class CaptureEngine: NSObject, AVCapturePhotoCaptureDelegate { device.unlockForConfiguration() debugLog("✅ DEBUG: Zoom set successfully to \(device.videoZoomFactor), lens: \(currentLensType.displayName)") - - let zoomRange = RemoteCmd.ZoomRange(minZoom: bounds.min, maxZoom: bounds.max) - - return (clampedZoom, currentLensType, zoomRange) } catch let error as NSError { debugLog("❌ DEBUG: Error setting zoom: \(error.localizedDescription)") throw error @@ -1766,11 +1806,17 @@ final class CaptureEngine: NSObject, AVCapturePhotoCaptureDelegate { } } - func switchLens(to lensType: CameraLensType) async throws -> (CameraLensType, [CameraLensType], CGFloat, RemoteCmd.ZoomRange) { - try await onSessionQueueThrowing { try self.switchLensLocked(to: lensType) } + func switchLens(to lensType: CameraLensType) async throws -> ControlState { + try await onSessionQueueThrowing { + try self.switchLensLocked(to: lensType) + guard let state = self.controlStateLocked() else { + throw NSError(domain: "No camera device available", code: 0, userInfo: nil) + } + return state + } } - private func switchLensLocked(to lensType: CameraLensType) throws -> (CameraLensType, [CameraLensType], CGFloat, RemoteCmd.ZoomRange) { + private func switchLensLocked(to lensType: CameraLensType) throws { dispatchPrecondition(condition: .onQueue(sessionQueue)) guard let device = self.videoDeviceInput?.device else { throw NSError(domain: "No camera device available", code: 0, userInfo: nil) @@ -1784,12 +1830,6 @@ final class CaptureEngine: NSObject, AVCapturePhotoCaptureDelegate { currentZoomFactor = clampedZoom currentLensType = lensType device.unlockForConfiguration() - - let zoomRange = RemoteCmd.ZoomRange( - minZoom: device.minAvailableVideoZoomFactor, - maxZoom: device.maxAvailableVideoZoomFactor - ) - return (lensType, availableLensTypes, currentZoomFactor, zoomRange) } func getAvailableLensTypes() async -> [CameraLensType] { @@ -1990,6 +2030,8 @@ final class CaptureEngine: NSObject, AVCapturePhotoCaptureDelegate { currentVideoFrameRate = appliedFrameRate fpsSetting.value = appliedFrameRate.value onStatusChanged?() + // A format change moves the exposure ranges and frame-rate cap. + pushControlStateLocked() return (resolution, appliedFrameRate) } diff --git a/RemoteCam/ControlState.swift b/RemoteCam/ControlState.swift new file mode 100644 index 00000000..6e13d9ce --- /dev/null +++ b/RemoteCam/ControlState.swift @@ -0,0 +1,124 @@ +// +// ControlState.swift +// RemoteShutter +// +// The camera's complete control-plane truth — the pure core of the v11 +// control plane (Docs/control-plane.md). One value, produced by exactly one +// engine function, carried whole on the wire (`ControlStateChanged`), and +// absorbed by one fold. Every range in it is EFFECTIVE (already narrowed by +// whatever is active — e.g. zoom under Cinematic), so no consumer ever +// combines constraints itself, and remotes render `f(snapshot)` with no +// stored derivations to go stale. +// + +import CoreGraphics +import Foundation + +/// A snapshot of everything the remote can control, as the camera has it now. +/// Capability IS presence: `exposure == nil` means the active device cannot do +/// manual exposure (no tiles, no `SetExposure`); same for `cinematic`. +public struct ControlState: Equatable, Sendable { + /// Monotonic across camera restarts; `absorb` drops anything older. + public var seq: UInt64 + public var mode: RecordingMode + /// The LOGICAL device — the camera the user chose. The Manual-exposure + /// lens hop is an implementation detail that never reaches this value. + public var activeDeviceID: String? + public var currentLens: CameraLensType + public var availableLenses: [CameraLensType] + /// Zoom: the factor plus the range the camera can honor RIGHT NOW. + public var zoomFactor: CGFloat + public var minZoom: CGFloat + public var maxZoom: CGFloat + public var zoomStops: [CGFloat] + public var wideAngleZoomFactor: CGFloat + /// Tap-to-focus is a property of the active device. + public var supportsFocusPoint: Bool + public var exposure: ExposureState? + public var cinematic: CinematicState? + + public init(seq: UInt64, + mode: RecordingMode = .Photo, + activeDeviceID: String? = nil, + currentLens: CameraLensType = .wideAngle, + availableLenses: [CameraLensType] = [.wideAngle], + zoomFactor: CGFloat = 1.0, + minZoom: CGFloat = 1.0, + maxZoom: CGFloat = 1.0, + zoomStops: [CGFloat] = [1.0], + wideAngleZoomFactor: CGFloat = 1.0, + supportsFocusPoint: Bool = false, + exposure: ExposureState? = nil, + cinematic: CinematicState? = nil) { + self.seq = seq + self.mode = mode + self.activeDeviceID = activeDeviceID + self.currentLens = currentLens + self.availableLenses = availableLenses + self.zoomFactor = zoomFactor + self.minZoom = minZoom + self.maxZoom = maxZoom + self.zoomStops = zoomStops + self.wideAngleZoomFactor = wideAngleZoomFactor + self.supportsFocusPoint = supportsFocusPoint + self.exposure = exposure + self.cinematic = cinematic + } + + // MARK: - The one write + + /// The ONLY way a remote updates its stored snapshot: newer wins, stale + /// drops. Delivery order, duplicates, and races between a push and a + /// requested refresh all collapse into this comparison. + public static func absorb(_ current: ControlState?, _ incoming: ControlState) -> ControlState { + guard let current else { return incoming } + return incoming.seq >= current.seq ? incoming : current + } + + // MARK: - Pure derivations + + /// The zoom pill's math, derived — never stored — so it can't disagree + /// with the snapshot it came from. The display ceiling caps runaway + /// digital-zoom maxima exactly as the old seed path did. + var zoomScale: ZoomScale { + ZoomScale(stops: zoomStops, + maxZoomFactor: ZoomScale.displayCapped(maxZoom, wideAngle: wideAngleZoomFactor), + wideAngleZoomFactor: wideAngleZoomFactor, + minZoomFactor: minZoom) + } + + public var supportsManualExposure: Bool { exposure != nil } + public var supportsCinematicVideo: Bool { cinematic != nil } +} + +/// Why a control mutation did not take (`ControlStateChanged.refusal`). +/// A refusal always reaches the user's eyes — a refused control must never +/// look like a control that did nothing. +public enum ControlRefusalReason: Equatable, Sendable { + /// Cinematic is a video effect; the camera is in photo mode. + case photoMode + /// This control is locked while a take is rolling. + case recording + /// The active device/OS cannot do this at all. + case unsupported + /// The device supports it, but the session configuration refuses. + case sessionRefused + + /// What the remote shows. `detail` is the camera's diagnostic suffix + /// (device, format, outputs), appended when present. + public func message(detail: String?) -> String { + let base: String + switch self { + case .photoMode: + base = NSLocalizedString("Switch to video mode for Cinematic", comment: "control refusal") + case .recording: + base = NSLocalizedString("That control is locked while recording", comment: "control refusal") + case .unsupported: + base = NSLocalizedString("This camera can't do that", comment: "control refusal") + case .sessionRefused: + base = NSLocalizedString("The camera refused that setting", comment: "control refusal") + } + guard let detail, !detail.isEmpty else { return base } + return "\(base) (\(detail))" + } +} diff --git a/RemoteCam/FlatBufferSchemas.fbs b/RemoteCam/FlatBufferSchemas.fbs index b17d6581..09e142af 100644 --- a/RemoteCam/FlatBufferSchemas.fbs +++ b/RemoteCam/FlatBufferSchemas.fbs @@ -69,15 +69,24 @@ enum CommandAction : byte { RequestCameraStateReport = 32, // monitor/director -> camera: re-push the // current CameraStateReport (e.g. on connection). SetExposure = 33, // monitor -> camera: auto / manual (shutter + ISO). - // Payload in CommandParameters (exposure_*); the - // camera answers with a CameraStateResponse carrying - // ExposureState. Only sent to peers advertising - // supports_manual_exposure. - SetCinematic = 34 // monitor -> camera: Cinematic video on/off + simulated + // Payload in CommandParameters (exposure_*). + // Answered with ControlStateChanged. Only sent when + // the peer's ControlState carries an ExposureState. + SetCinematic = 34, // monitor -> camera: Cinematic video on/off + simulated // aperture (iOS 26+). Payload in CommandParameters - // (cinematic_*); answered with a CameraStateResponse - // carrying CinematicState. Only sent to peers - // advertising supports_cinematic_video. + // (cinematic_*). Answered with ControlStateChanged. + // Only sent when the peer's ControlState carries a + // CinematicState. + ControlStateChanged = 35 // camera -> monitor/director: THE control-plane truth + // channel (v11). One full ControlState snapshot, + // sent as the answer to every control mutation + // (SetZoom, SwitchLens, SetExposure, SetCinematic) + // AND pushed unsolicited whenever a constraint moves + // without the remote asking — device swap, quality + // change, mode change, Cinematic toggling the zoom + // range, recording locking the aperture. Constraints + // travel together, so a remote can never hold a + // stale range for one control while another changed. } // Auto vs. manual exposure. Unknown = legacy peer / field absent. @@ -87,6 +96,18 @@ enum ExposureMode : byte { Manual = 2 } +// Why a control mutation did not take (ControlStateChanged.refusal). A refusal +// always reaches the user's eyes — a refused control must never look like a +// control that did nothing. None = the mutation was applied. +enum ControlRefusal : byte { + Unknown = 0, + None = 1, + PhotoMode = 2, // Cinematic is a video effect; switch modes first + Recording = 3, // this control is locked while a take is rolling + Unsupported = 4, // the active device/OS cannot do this at all + SessionRefused = 5 // device supports it, the session configuration refuses +} + // Whether the camera device drives its own on-screen live preview. On is the // shipping default and preserves existing behavior; Standby stops LOCAL preview @@ -285,14 +306,35 @@ table CinematicState { not_enough_light: bool; } -table ZoomRange { +// The camera's complete control-plane truth — the payload of +// ControlStateChanged, and the seed carried inside CameraCapabilities. +// Produced by exactly ONE engine function; every range in it is EFFECTIVE +// (already narrowed by whatever is active — e.g. the zoom range under +// Cinematic), so consumers never combine constraints themselves. +table ControlState { + // Monotonic across camera restarts; a receiver drops any snapshot older + // than the last it absorbed (CameraStateReport's rule). + seq: uint64; + mode: RecordingModeEnum; + // The LOGICAL device — the camera the user chose. The Manual-exposure + // lens hop is an implementation detail that never appears on the wire. + active_device_id: string; + current_lens: CameraLensType; + available_lenses: [CameraLensType]; + // Zoom: the factor plus the range the camera can honor RIGHT NOW. + zoom_factor: double; min_zoom: double; max_zoom: double; -} - -table ZoomCapability { - lens_type: CameraLensType; - zoom_range: ZoomRange; + zoom_stops: [double]; + wide_angle_zoom_factor: double; + // Tap-to-focus support is a property of the active device. + supports_focus_point: bool; + // Absent = the active device cannot do manual exposure: no SHUTTER/ISO + // tiles, and SetExposure must not be sent. + exposure: ExposureState; + // Absent = the active device/OS cannot record Cinematic video: no + // CINEMATIC tile, and SetCinematic must not be sent. + cinematic: CinematicState; } table ResolutionFrameRates { @@ -311,15 +353,15 @@ table PhotoQualityCapabilities { supports_hdr: bool; } +// Static per-position facts (quality menus, flash/torch presence). Anything +// that changes with the session — zoom, lenses in use, exposure — lives in +// ControlState, never here. table CameraInfo { available_lenses: [CameraLensType]; has_flash: bool; has_torch: bool; - zoom_capabilities: [ZoomCapability]; video_quality: VideoQualityCapabilities; photo_quality: PhotoQualityCapabilities; - zoom_stops: [double]; - wide_angle_zoom_factor: double; } // One selectable physical camera on the camera peer. `position` is Back when @@ -358,35 +400,21 @@ table CameraState { preview_mode: CameraPreviewModeEnum; } +// What the camera peer HAS: static device facts and session-level features. +// What the camera is DOING — and every live range — is `control`, the same +// ControlState that ControlStateChanged pushes, carried here so the very +// first exchange seeds the remote completely. table CameraCapabilities { front_camera: CameraInfo; back_camera: CameraInfo; - // Appended fields only below this line (FlatBuffers schema evolution). - // Empty/absent = peer predates camera-device selection; a monitor must - // not send SelectCameraDevice to such a peer. + // Empty/absent = no camera-device selection on this peer; a remote must + // not send SelectCameraDevice. camera_devices: [CameraDeviceInfo]; - active_device_id: string; - // False/absent = peer predates tap-to-focus; a monitor must not send - // FocusAtPoint to such a peer. - supports_focus_point: bool; - // False/absent = peer predates camera preview-mode control; a monitor must - // not send SetCameraPreviewMode to such a peer (old decoders read the - // unknown action as its enum default). + // False = peer has no local preview-mode control (SetCameraPreviewMode). supports_preview_mode: bool; - // False/absent = peer cannot join a multicam director session; a director - // must not send scheduled-capture or stream-profile commands to such a - // peer (they would be decoded as Unknown and dropped, silently desyncing - // the rig). + // False = peer cannot join a multicam director session. supports_multicam: bool; - // False/absent = the active device cannot do custom exposure (legacy peer, - // virtual multi-lens device, most Mac cameras); a monitor must not send - // SetExposure to such a peer and shows no exposure control. - supports_manual_exposure: bool; - exposure: ExposureState; - // False/absent = the active device/OS cannot record Cinematic video; a - // monitor must not send SetCinematic and shows no Cinematic control. - supports_cinematic_video: bool; - cinematic: CinematicState; + control: ControlState; } // MARK: - Response Structure @@ -399,15 +427,15 @@ table CameraStateResponse { capabilities: CameraCapabilities; media_data: [ubyte]; recording_start_time: uint64; - available_lenses: [CameraLensType]; - zoom_range: ZoomRange; - current_zoom: double; - // Appended fields only below this line (FlatBuffers schema evolution). clock_sync_echo_t0_ms: uint64; // ClockSyncPing response: echoed director t0 clock_sync_camera_clock_ms: uint64; // ClockSyncPing response: camera clock at receipt capture_id_echo: string; // ScheduledCapture ack: the accepted capture id - exposure: ExposureState; // SetExposure response: applied values + ranges - cinematic: CinematicState; // SetCinematic response: applied truth + range + // ControlStateChanged payload: the full snapshot, plus why a requested + // mutation was refused (None = applied). The snapshot is present even on + // refusal — it is the unchanged truth the remote should show. + control: ControlState; + control_refusal: ControlRefusal; + control_refusal_detail: string; // diagnostic suffix (device/format/outputs) } // MARK: - Frame Data diff --git a/RemoteCam/FlatBufferSchemas_generated.swift b/RemoteCam/FlatBufferSchemas_generated.swift index 5d6b5c20..d060e111 100644 --- a/RemoteCam/FlatBufferSchemas_generated.swift +++ b/RemoteCam/FlatBufferSchemas_generated.swift @@ -43,8 +43,9 @@ public enum RemoteShutter_CommandAction: Int8, Enum, Verifiable { case requestcamerastatereport = 32 case setexposure = 33 case setcinematic = 34 + case controlstatechanged = 35 - public static var max: RemoteShutter_CommandAction { return .setcinematic } + public static var max: RemoteShutter_CommandAction { return .controlstatechanged } public static var min: RemoteShutter_CommandAction { return .unknown } } @@ -62,6 +63,22 @@ public enum RemoteShutter_ExposureMode: Int8, Enum, Verifiable { } +public enum RemoteShutter_ControlRefusal: Int8, Enum, Verifiable { + public typealias T = Int8 + public static var byteSize: Int { return MemoryLayout.size } + public var value: Int8 { return self.rawValue } + case unknown = 0 + case none_ = 1 + case photomode = 2 + case recording = 3 + case unsupported = 4 + case sessionrefused = 5 + + public static var max: RemoteShutter_ControlRefusal { return .sessionrefused } + public static var min: RemoteShutter_ControlRefusal { return .unknown } +} + + public enum RemoteShutter_CameraPreviewModeEnum: Int8, Enum, Verifiable { public typealias T = Int8 public static var byteSize: Int { return MemoryLayout.size } @@ -782,88 +799,118 @@ public struct RemoteShutter_CinematicState: FlatBufferObject, Verifiable { } } -public struct RemoteShutter_ZoomRange: FlatBufferObject, Verifiable { +public struct RemoteShutter_ControlState: FlatBufferObject, Verifiable { static func validateVersion() { FlatBuffersVersion_25_2_10() } public var __buffer: ByteBuffer! { return _accessor.bb } private var _accessor: Table public static var id: String { "RCAM" } - public static func finish(_ fbb: inout FlatBufferBuilder, end: Offset, prefix: Bool = false) { fbb.finish(offset: end, fileId: RemoteShutter_ZoomRange.id, addPrefix: prefix) } + public static func finish(_ fbb: inout FlatBufferBuilder, end: Offset, prefix: Bool = false) { fbb.finish(offset: end, fileId: RemoteShutter_ControlState.id, addPrefix: prefix) } private init(_ t: Table) { _accessor = t } public init(_ bb: ByteBuffer, o: Int32) { _accessor = Table(bb: bb, position: o) } private enum VTOFFSET: VOffset { - case minZoom = 4 - case maxZoom = 6 + case seq = 4 + case mode = 6 + case activeDeviceId = 8 + case currentLens = 10 + case availableLenses = 12 + case zoomFactor = 14 + case minZoom = 16 + case maxZoom = 18 + case zoomStops = 20 + case wideAngleZoomFactor = 22 + case supportsFocusPoint = 24 + case exposure = 26 + case cinematic = 28 var v: Int32 { Int32(self.rawValue) } var p: VOffset { self.rawValue } } + public var seq: UInt64 { let o = _accessor.offset(VTOFFSET.seq.v); return o == 0 ? 0 : _accessor.readBuffer(of: UInt64.self, at: o) } + public var mode: RemoteShutter_RecordingModeEnum { let o = _accessor.offset(VTOFFSET.mode.v); return o == 0 ? .unknown : RemoteShutter_RecordingModeEnum(rawValue: _accessor.readBuffer(of: Int8.self, at: o)) ?? .unknown } + public var activeDeviceId: String? { let o = _accessor.offset(VTOFFSET.activeDeviceId.v); return o == 0 ? nil : _accessor.string(at: o) } + public var activeDeviceIdSegmentArray: [UInt8]? { return _accessor.getVector(at: VTOFFSET.activeDeviceId.v) } + public var currentLens: RemoteShutter_CameraLensType { let o = _accessor.offset(VTOFFSET.currentLens.v); return o == 0 ? .wideangle : RemoteShutter_CameraLensType(rawValue: _accessor.readBuffer(of: Int8.self, at: o)) ?? .wideangle } + public var hasAvailableLenses: Bool { let o = _accessor.offset(VTOFFSET.availableLenses.v); return o == 0 ? false : true } + public var availableLensesCount: Int32 { let o = _accessor.offset(VTOFFSET.availableLenses.v); return o == 0 ? 0 : _accessor.vector(count: o) } + public func availableLenses(at index: Int32) -> RemoteShutter_CameraLensType? { let o = _accessor.offset(VTOFFSET.availableLenses.v); return o == 0 ? RemoteShutter_CameraLensType.wideangle : RemoteShutter_CameraLensType(rawValue: _accessor.directRead(of: Int8.self, offset: _accessor.vector(at: o) + index * 1)) } + public var zoomFactor: Double { let o = _accessor.offset(VTOFFSET.zoomFactor.v); return o == 0 ? 0.0 : _accessor.readBuffer(of: Double.self, at: o) } public var minZoom: Double { let o = _accessor.offset(VTOFFSET.minZoom.v); return o == 0 ? 0.0 : _accessor.readBuffer(of: Double.self, at: o) } public var maxZoom: Double { let o = _accessor.offset(VTOFFSET.maxZoom.v); return o == 0 ? 0.0 : _accessor.readBuffer(of: Double.self, at: o) } - public static func startZoomRange(_ fbb: inout FlatBufferBuilder) -> UOffset { fbb.startTable(with: 2) } + public var hasZoomStops: Bool { let o = _accessor.offset(VTOFFSET.zoomStops.v); return o == 0 ? false : true } + public var zoomStopsCount: Int32 { let o = _accessor.offset(VTOFFSET.zoomStops.v); return o == 0 ? 0 : _accessor.vector(count: o) } + public func zoomStops(at index: Int32) -> Double { let o = _accessor.offset(VTOFFSET.zoomStops.v); return o == 0 ? 0 : _accessor.directRead(of: Double.self, offset: _accessor.vector(at: o) + index * 8) } + public var zoomStops: [Double] { return _accessor.getVector(at: VTOFFSET.zoomStops.v) ?? [] } + public var wideAngleZoomFactor: Double { let o = _accessor.offset(VTOFFSET.wideAngleZoomFactor.v); return o == 0 ? 0.0 : _accessor.readBuffer(of: Double.self, at: o) } + public var supportsFocusPoint: Bool { let o = _accessor.offset(VTOFFSET.supportsFocusPoint.v); return o == 0 ? false : _accessor.readBuffer(of: Bool.self, at: o) } + public var exposure: RemoteShutter_ExposureState? { let o = _accessor.offset(VTOFFSET.exposure.v); return o == 0 ? nil : RemoteShutter_ExposureState(_accessor.bb, o: _accessor.indirect(o + _accessor.position)) } + public var cinematic: RemoteShutter_CinematicState? { let o = _accessor.offset(VTOFFSET.cinematic.v); return o == 0 ? nil : RemoteShutter_CinematicState(_accessor.bb, o: _accessor.indirect(o + _accessor.position)) } + public static func startControlState(_ fbb: inout FlatBufferBuilder) -> UOffset { fbb.startTable(with: 13) } + public static func add(seq: UInt64, _ fbb: inout FlatBufferBuilder) { fbb.add(element: seq, def: 0, at: VTOFFSET.seq.p) } + public static func add(mode: RemoteShutter_RecordingModeEnum, _ fbb: inout FlatBufferBuilder) { fbb.add(element: mode.rawValue, def: 0, at: VTOFFSET.mode.p) } + public static func add(activeDeviceId: Offset, _ fbb: inout FlatBufferBuilder) { fbb.add(offset: activeDeviceId, at: VTOFFSET.activeDeviceId.p) } + public static func add(currentLens: RemoteShutter_CameraLensType, _ fbb: inout FlatBufferBuilder) { fbb.add(element: currentLens.rawValue, def: 0, at: VTOFFSET.currentLens.p) } + public static func addVectorOf(availableLenses: Offset, _ fbb: inout FlatBufferBuilder) { fbb.add(offset: availableLenses, at: VTOFFSET.availableLenses.p) } + public static func add(zoomFactor: Double, _ fbb: inout FlatBufferBuilder) { fbb.add(element: zoomFactor, def: 0.0, at: VTOFFSET.zoomFactor.p) } public static func add(minZoom: Double, _ fbb: inout FlatBufferBuilder) { fbb.add(element: minZoom, def: 0.0, at: VTOFFSET.minZoom.p) } public static func add(maxZoom: Double, _ fbb: inout FlatBufferBuilder) { fbb.add(element: maxZoom, def: 0.0, at: VTOFFSET.maxZoom.p) } - public static func endZoomRange(_ fbb: inout FlatBufferBuilder, start: UOffset) -> Offset { let end = Offset(offset: fbb.endTable(at: start)); return end } - public static func createZoomRange( + public static func addVectorOf(zoomStops: Offset, _ fbb: inout FlatBufferBuilder) { fbb.add(offset: zoomStops, at: VTOFFSET.zoomStops.p) } + public static func add(wideAngleZoomFactor: Double, _ fbb: inout FlatBufferBuilder) { fbb.add(element: wideAngleZoomFactor, def: 0.0, at: VTOFFSET.wideAngleZoomFactor.p) } + public static func add(supportsFocusPoint: Bool, _ fbb: inout FlatBufferBuilder) { fbb.add(element: supportsFocusPoint, def: false, + at: VTOFFSET.supportsFocusPoint.p) } + public static func add(exposure: Offset, _ fbb: inout FlatBufferBuilder) { fbb.add(offset: exposure, at: VTOFFSET.exposure.p) } + public static func add(cinematic: Offset, _ fbb: inout FlatBufferBuilder) { fbb.add(offset: cinematic, at: VTOFFSET.cinematic.p) } + public static func endControlState(_ fbb: inout FlatBufferBuilder, start: UOffset) -> Offset { let end = Offset(offset: fbb.endTable(at: start)); return end } + public static func createControlState( _ fbb: inout FlatBufferBuilder, + seq: UInt64 = 0, + mode: RemoteShutter_RecordingModeEnum = .unknown, + activeDeviceIdOffset activeDeviceId: Offset = Offset(), + currentLens: RemoteShutter_CameraLensType = .wideangle, + availableLensesVectorOffset availableLenses: Offset = Offset(), + zoomFactor: Double = 0.0, minZoom: Double = 0.0, - maxZoom: Double = 0.0 + maxZoom: Double = 0.0, + zoomStopsVectorOffset zoomStops: Offset = Offset(), + wideAngleZoomFactor: Double = 0.0, + supportsFocusPoint: Bool = false, + exposureOffset exposure: Offset = Offset(), + cinematicOffset cinematic: Offset = Offset() ) -> Offset { - let __start = RemoteShutter_ZoomRange.startZoomRange(&fbb) - RemoteShutter_ZoomRange.add(minZoom: minZoom, &fbb) - RemoteShutter_ZoomRange.add(maxZoom: maxZoom, &fbb) - return RemoteShutter_ZoomRange.endZoomRange(&fbb, start: __start) + let __start = RemoteShutter_ControlState.startControlState(&fbb) + RemoteShutter_ControlState.add(seq: seq, &fbb) + RemoteShutter_ControlState.add(mode: mode, &fbb) + RemoteShutter_ControlState.add(activeDeviceId: activeDeviceId, &fbb) + RemoteShutter_ControlState.add(currentLens: currentLens, &fbb) + RemoteShutter_ControlState.addVectorOf(availableLenses: availableLenses, &fbb) + RemoteShutter_ControlState.add(zoomFactor: zoomFactor, &fbb) + RemoteShutter_ControlState.add(minZoom: minZoom, &fbb) + RemoteShutter_ControlState.add(maxZoom: maxZoom, &fbb) + RemoteShutter_ControlState.addVectorOf(zoomStops: zoomStops, &fbb) + RemoteShutter_ControlState.add(wideAngleZoomFactor: wideAngleZoomFactor, &fbb) + RemoteShutter_ControlState.add(supportsFocusPoint: supportsFocusPoint, &fbb) + RemoteShutter_ControlState.add(exposure: exposure, &fbb) + RemoteShutter_ControlState.add(cinematic: cinematic, &fbb) + return RemoteShutter_ControlState.endControlState(&fbb, start: __start) } public static func verify(_ verifier: inout Verifier, at position: Int, of type: T.Type) throws where T: Verifiable { var _v = try verifier.visitTable(at: position) + try _v.visit(field: VTOFFSET.seq.p, fieldName: "seq", required: false, type: UInt64.self) + try _v.visit(field: VTOFFSET.mode.p, fieldName: "mode", required: false, type: RemoteShutter_RecordingModeEnum.self) + try _v.visit(field: VTOFFSET.activeDeviceId.p, fieldName: "activeDeviceId", required: false, type: ForwardOffset.self) + try _v.visit(field: VTOFFSET.currentLens.p, fieldName: "currentLens", required: false, type: RemoteShutter_CameraLensType.self) + try _v.visit(field: VTOFFSET.availableLenses.p, fieldName: "availableLenses", required: false, type: ForwardOffset>.self) + try _v.visit(field: VTOFFSET.zoomFactor.p, fieldName: "zoomFactor", required: false, type: Double.self) try _v.visit(field: VTOFFSET.minZoom.p, fieldName: "minZoom", required: false, type: Double.self) try _v.visit(field: VTOFFSET.maxZoom.p, fieldName: "maxZoom", required: false, type: Double.self) - _v.finish() - } -} - -public struct RemoteShutter_ZoomCapability: FlatBufferObject, Verifiable { - - static func validateVersion() { FlatBuffersVersion_25_2_10() } - public var __buffer: ByteBuffer! { return _accessor.bb } - private var _accessor: Table - - public static var id: String { "RCAM" } - public static func finish(_ fbb: inout FlatBufferBuilder, end: Offset, prefix: Bool = false) { fbb.finish(offset: end, fileId: RemoteShutter_ZoomCapability.id, addPrefix: prefix) } - private init(_ t: Table) { _accessor = t } - public init(_ bb: ByteBuffer, o: Int32) { _accessor = Table(bb: bb, position: o) } - - private enum VTOFFSET: VOffset { - case lensType = 4 - case zoomRange = 6 - var v: Int32 { Int32(self.rawValue) } - var p: VOffset { self.rawValue } - } - - public var lensType: RemoteShutter_CameraLensType { let o = _accessor.offset(VTOFFSET.lensType.v); return o == 0 ? .wideangle : RemoteShutter_CameraLensType(rawValue: _accessor.readBuffer(of: Int8.self, at: o)) ?? .wideangle } - public var zoomRange: RemoteShutter_ZoomRange? { let o = _accessor.offset(VTOFFSET.zoomRange.v); return o == 0 ? nil : RemoteShutter_ZoomRange(_accessor.bb, o: _accessor.indirect(o + _accessor.position)) } - public static func startZoomCapability(_ fbb: inout FlatBufferBuilder) -> UOffset { fbb.startTable(with: 2) } - public static func add(lensType: RemoteShutter_CameraLensType, _ fbb: inout FlatBufferBuilder) { fbb.add(element: lensType.rawValue, def: 0, at: VTOFFSET.lensType.p) } - public static func add(zoomRange: Offset, _ fbb: inout FlatBufferBuilder) { fbb.add(offset: zoomRange, at: VTOFFSET.zoomRange.p) } - public static func endZoomCapability(_ fbb: inout FlatBufferBuilder, start: UOffset) -> Offset { let end = Offset(offset: fbb.endTable(at: start)); return end } - public static func createZoomCapability( - _ fbb: inout FlatBufferBuilder, - lensType: RemoteShutter_CameraLensType = .wideangle, - zoomRangeOffset zoomRange: Offset = Offset() - ) -> Offset { - let __start = RemoteShutter_ZoomCapability.startZoomCapability(&fbb) - RemoteShutter_ZoomCapability.add(lensType: lensType, &fbb) - RemoteShutter_ZoomCapability.add(zoomRange: zoomRange, &fbb) - return RemoteShutter_ZoomCapability.endZoomCapability(&fbb, start: __start) - } - - public static func verify(_ verifier: inout Verifier, at position: Int, of type: T.Type) throws where T: Verifiable { - var _v = try verifier.visitTable(at: position) - try _v.visit(field: VTOFFSET.lensType.p, fieldName: "lensType", required: false, type: RemoteShutter_CameraLensType.self) - try _v.visit(field: VTOFFSET.zoomRange.p, fieldName: "zoomRange", required: false, type: ForwardOffset.self) + try _v.visit(field: VTOFFSET.zoomStops.p, fieldName: "zoomStops", required: false, type: ForwardOffset>.self) + try _v.visit(field: VTOFFSET.wideAngleZoomFactor.p, fieldName: "wideAngleZoomFactor", required: false, type: Double.self) + try _v.visit(field: VTOFFSET.supportsFocusPoint.p, fieldName: "supportsFocusPoint", required: false, type: Bool.self) + try _v.visit(field: VTOFFSET.exposure.p, fieldName: "exposure", required: false, type: ForwardOffset.self) + try _v.visit(field: VTOFFSET.cinematic.p, fieldName: "cinematic", required: false, type: ForwardOffset.self) _v.finish() } } @@ -1028,11 +1075,8 @@ public struct RemoteShutter_CameraInfo: FlatBufferObject, Verifiable { case availableLenses = 4 case hasFlash = 6 case hasTorch = 8 - case zoomCapabilities = 10 - case videoQuality = 12 - case photoQuality = 14 - case zoomStops = 16 - case wideAngleZoomFactor = 18 + case videoQuality = 10 + case photoQuality = 12 var v: Int32 { Int32(self.rawValue) } var p: VOffset { self.rawValue } } @@ -1042,48 +1086,31 @@ public struct RemoteShutter_CameraInfo: FlatBufferObject, Verifiable { public func availableLenses(at index: Int32) -> RemoteShutter_CameraLensType? { let o = _accessor.offset(VTOFFSET.availableLenses.v); return o == 0 ? RemoteShutter_CameraLensType.wideangle : RemoteShutter_CameraLensType(rawValue: _accessor.directRead(of: Int8.self, offset: _accessor.vector(at: o) + index * 1)) } public var hasFlash: Bool { let o = _accessor.offset(VTOFFSET.hasFlash.v); return o == 0 ? false : _accessor.readBuffer(of: Bool.self, at: o) } public var hasTorch: Bool { let o = _accessor.offset(VTOFFSET.hasTorch.v); return o == 0 ? false : _accessor.readBuffer(of: Bool.self, at: o) } - public var hasZoomCapabilities: Bool { let o = _accessor.offset(VTOFFSET.zoomCapabilities.v); return o == 0 ? false : true } - public var zoomCapabilitiesCount: Int32 { let o = _accessor.offset(VTOFFSET.zoomCapabilities.v); return o == 0 ? 0 : _accessor.vector(count: o) } - public func zoomCapabilities(at index: Int32) -> RemoteShutter_ZoomCapability? { let o = _accessor.offset(VTOFFSET.zoomCapabilities.v); return o == 0 ? nil : RemoteShutter_ZoomCapability(_accessor.bb, o: _accessor.indirect(_accessor.vector(at: o) + index * 4)) } public var videoQuality: RemoteShutter_VideoQualityCapabilities? { let o = _accessor.offset(VTOFFSET.videoQuality.v); return o == 0 ? nil : RemoteShutter_VideoQualityCapabilities(_accessor.bb, o: _accessor.indirect(o + _accessor.position)) } public var photoQuality: RemoteShutter_PhotoQualityCapabilities? { let o = _accessor.offset(VTOFFSET.photoQuality.v); return o == 0 ? nil : RemoteShutter_PhotoQualityCapabilities(_accessor.bb, o: _accessor.indirect(o + _accessor.position)) } - public var hasZoomStops: Bool { let o = _accessor.offset(VTOFFSET.zoomStops.v); return o == 0 ? false : true } - public var zoomStopsCount: Int32 { let o = _accessor.offset(VTOFFSET.zoomStops.v); return o == 0 ? 0 : _accessor.vector(count: o) } - public func zoomStops(at index: Int32) -> Double { let o = _accessor.offset(VTOFFSET.zoomStops.v); return o == 0 ? 0 : _accessor.directRead(of: Double.self, offset: _accessor.vector(at: o) + index * 8) } - public var zoomStops: [Double] { return _accessor.getVector(at: VTOFFSET.zoomStops.v) ?? [] } - public var wideAngleZoomFactor: Double { let o = _accessor.offset(VTOFFSET.wideAngleZoomFactor.v); return o == 0 ? 0.0 : _accessor.readBuffer(of: Double.self, at: o) } - public static func startCameraInfo(_ fbb: inout FlatBufferBuilder) -> UOffset { fbb.startTable(with: 8) } + public static func startCameraInfo(_ fbb: inout FlatBufferBuilder) -> UOffset { fbb.startTable(with: 5) } public static func addVectorOf(availableLenses: Offset, _ fbb: inout FlatBufferBuilder) { fbb.add(offset: availableLenses, at: VTOFFSET.availableLenses.p) } public static func add(hasFlash: Bool, _ fbb: inout FlatBufferBuilder) { fbb.add(element: hasFlash, def: false, at: VTOFFSET.hasFlash.p) } public static func add(hasTorch: Bool, _ fbb: inout FlatBufferBuilder) { fbb.add(element: hasTorch, def: false, at: VTOFFSET.hasTorch.p) } - public static func addVectorOf(zoomCapabilities: Offset, _ fbb: inout FlatBufferBuilder) { fbb.add(offset: zoomCapabilities, at: VTOFFSET.zoomCapabilities.p) } public static func add(videoQuality: Offset, _ fbb: inout FlatBufferBuilder) { fbb.add(offset: videoQuality, at: VTOFFSET.videoQuality.p) } public static func add(photoQuality: Offset, _ fbb: inout FlatBufferBuilder) { fbb.add(offset: photoQuality, at: VTOFFSET.photoQuality.p) } - public static func addVectorOf(zoomStops: Offset, _ fbb: inout FlatBufferBuilder) { fbb.add(offset: zoomStops, at: VTOFFSET.zoomStops.p) } - public static func add(wideAngleZoomFactor: Double, _ fbb: inout FlatBufferBuilder) { fbb.add(element: wideAngleZoomFactor, def: 0.0, at: VTOFFSET.wideAngleZoomFactor.p) } public static func endCameraInfo(_ fbb: inout FlatBufferBuilder, start: UOffset) -> Offset { let end = Offset(offset: fbb.endTable(at: start)); return end } public static func createCameraInfo( _ fbb: inout FlatBufferBuilder, availableLensesVectorOffset availableLenses: Offset = Offset(), hasFlash: Bool = false, hasTorch: Bool = false, - zoomCapabilitiesVectorOffset zoomCapabilities: Offset = Offset(), videoQualityOffset videoQuality: Offset = Offset(), - photoQualityOffset photoQuality: Offset = Offset(), - zoomStopsVectorOffset zoomStops: Offset = Offset(), - wideAngleZoomFactor: Double = 0.0 + photoQualityOffset photoQuality: Offset = Offset() ) -> Offset { let __start = RemoteShutter_CameraInfo.startCameraInfo(&fbb) RemoteShutter_CameraInfo.addVectorOf(availableLenses: availableLenses, &fbb) RemoteShutter_CameraInfo.add(hasFlash: hasFlash, &fbb) RemoteShutter_CameraInfo.add(hasTorch: hasTorch, &fbb) - RemoteShutter_CameraInfo.addVectorOf(zoomCapabilities: zoomCapabilities, &fbb) RemoteShutter_CameraInfo.add(videoQuality: videoQuality, &fbb) RemoteShutter_CameraInfo.add(photoQuality: photoQuality, &fbb) - RemoteShutter_CameraInfo.addVectorOf(zoomStops: zoomStops, &fbb) - RemoteShutter_CameraInfo.add(wideAngleZoomFactor: wideAngleZoomFactor, &fbb) return RemoteShutter_CameraInfo.endCameraInfo(&fbb, start: __start) } @@ -1092,11 +1119,8 @@ public struct RemoteShutter_CameraInfo: FlatBufferObject, Verifiable { try _v.visit(field: VTOFFSET.availableLenses.p, fieldName: "availableLenses", required: false, type: ForwardOffset>.self) try _v.visit(field: VTOFFSET.hasFlash.p, fieldName: "hasFlash", required: false, type: Bool.self) try _v.visit(field: VTOFFSET.hasTorch.p, fieldName: "hasTorch", required: false, type: Bool.self) - try _v.visit(field: VTOFFSET.zoomCapabilities.p, fieldName: "zoomCapabilities", required: false, type: ForwardOffset, RemoteShutter_ZoomCapability>>.self) try _v.visit(field: VTOFFSET.videoQuality.p, fieldName: "videoQuality", required: false, type: ForwardOffset.self) try _v.visit(field: VTOFFSET.photoQuality.p, fieldName: "photoQuality", required: false, type: ForwardOffset.self) - try _v.visit(field: VTOFFSET.zoomStops.p, fieldName: "zoomStops", required: false, type: ForwardOffset>.self) - try _v.visit(field: VTOFFSET.wideAngleZoomFactor.p, fieldName: "wideAngleZoomFactor", required: false, type: Double.self) _v.finish() } } @@ -1298,14 +1322,9 @@ public struct RemoteShutter_CameraCapabilities: FlatBufferObject, Verifiable { case frontCamera = 4 case backCamera = 6 case cameraDevices = 8 - case activeDeviceId = 10 - case supportsFocusPoint = 12 - case supportsPreviewMode = 14 - case supportsMulticam = 16 - case supportsManualExposure = 18 - case exposure = 20 - case supportsCinematicVideo = 22 - case cinematic = 24 + case supportsPreviewMode = 10 + case supportsMulticam = 12 + case control = 14 var v: Int32 { Int32(self.rawValue) } var p: VOffset { self.rawValue } } @@ -1315,59 +1334,35 @@ public struct RemoteShutter_CameraCapabilities: FlatBufferObject, Verifiable { public var hasCameraDevices: Bool { let o = _accessor.offset(VTOFFSET.cameraDevices.v); return o == 0 ? false : true } public var cameraDevicesCount: Int32 { let o = _accessor.offset(VTOFFSET.cameraDevices.v); return o == 0 ? 0 : _accessor.vector(count: o) } public func cameraDevices(at index: Int32) -> RemoteShutter_CameraDeviceInfo? { let o = _accessor.offset(VTOFFSET.cameraDevices.v); return o == 0 ? nil : RemoteShutter_CameraDeviceInfo(_accessor.bb, o: _accessor.indirect(_accessor.vector(at: o) + index * 4)) } - public var activeDeviceId: String? { let o = _accessor.offset(VTOFFSET.activeDeviceId.v); return o == 0 ? nil : _accessor.string(at: o) } - public var activeDeviceIdSegmentArray: [UInt8]? { return _accessor.getVector(at: VTOFFSET.activeDeviceId.v) } - public var supportsFocusPoint: Bool { let o = _accessor.offset(VTOFFSET.supportsFocusPoint.v); return o == 0 ? false : _accessor.readBuffer(of: Bool.self, at: o) } public var supportsPreviewMode: Bool { let o = _accessor.offset(VTOFFSET.supportsPreviewMode.v); return o == 0 ? false : _accessor.readBuffer(of: Bool.self, at: o) } public var supportsMulticam: Bool { let o = _accessor.offset(VTOFFSET.supportsMulticam.v); return o == 0 ? false : _accessor.readBuffer(of: Bool.self, at: o) } - public var supportsManualExposure: Bool { let o = _accessor.offset(VTOFFSET.supportsManualExposure.v); return o == 0 ? false : _accessor.readBuffer(of: Bool.self, at: o) } - public var exposure: RemoteShutter_ExposureState? { let o = _accessor.offset(VTOFFSET.exposure.v); return o == 0 ? nil : RemoteShutter_ExposureState(_accessor.bb, o: _accessor.indirect(o + _accessor.position)) } - public var supportsCinematicVideo: Bool { let o = _accessor.offset(VTOFFSET.supportsCinematicVideo.v); return o == 0 ? false : _accessor.readBuffer(of: Bool.self, at: o) } - public var cinematic: RemoteShutter_CinematicState? { let o = _accessor.offset(VTOFFSET.cinematic.v); return o == 0 ? nil : RemoteShutter_CinematicState(_accessor.bb, o: _accessor.indirect(o + _accessor.position)) } - public static func startCameraCapabilities(_ fbb: inout FlatBufferBuilder) -> UOffset { fbb.startTable(with: 11) } + public var control: RemoteShutter_ControlState? { let o = _accessor.offset(VTOFFSET.control.v); return o == 0 ? nil : RemoteShutter_ControlState(_accessor.bb, o: _accessor.indirect(o + _accessor.position)) } + public static func startCameraCapabilities(_ fbb: inout FlatBufferBuilder) -> UOffset { fbb.startTable(with: 6) } public static func add(frontCamera: Offset, _ fbb: inout FlatBufferBuilder) { fbb.add(offset: frontCamera, at: VTOFFSET.frontCamera.p) } public static func add(backCamera: Offset, _ fbb: inout FlatBufferBuilder) { fbb.add(offset: backCamera, at: VTOFFSET.backCamera.p) } public static func addVectorOf(cameraDevices: Offset, _ fbb: inout FlatBufferBuilder) { fbb.add(offset: cameraDevices, at: VTOFFSET.cameraDevices.p) } - public static func add(activeDeviceId: Offset, _ fbb: inout FlatBufferBuilder) { fbb.add(offset: activeDeviceId, at: VTOFFSET.activeDeviceId.p) } - public static func add(supportsFocusPoint: Bool, _ fbb: inout FlatBufferBuilder) { fbb.add(element: supportsFocusPoint, def: false, - at: VTOFFSET.supportsFocusPoint.p) } public static func add(supportsPreviewMode: Bool, _ fbb: inout FlatBufferBuilder) { fbb.add(element: supportsPreviewMode, def: false, at: VTOFFSET.supportsPreviewMode.p) } public static func add(supportsMulticam: Bool, _ fbb: inout FlatBufferBuilder) { fbb.add(element: supportsMulticam, def: false, at: VTOFFSET.supportsMulticam.p) } - public static func add(supportsManualExposure: Bool, _ fbb: inout FlatBufferBuilder) { fbb.add(element: supportsManualExposure, def: false, - at: VTOFFSET.supportsManualExposure.p) } - public static func add(exposure: Offset, _ fbb: inout FlatBufferBuilder) { fbb.add(offset: exposure, at: VTOFFSET.exposure.p) } - public static func add(supportsCinematicVideo: Bool, _ fbb: inout FlatBufferBuilder) { fbb.add(element: supportsCinematicVideo, def: false, - at: VTOFFSET.supportsCinematicVideo.p) } - public static func add(cinematic: Offset, _ fbb: inout FlatBufferBuilder) { fbb.add(offset: cinematic, at: VTOFFSET.cinematic.p) } + public static func add(control: Offset, _ fbb: inout FlatBufferBuilder) { fbb.add(offset: control, at: VTOFFSET.control.p) } public static func endCameraCapabilities(_ fbb: inout FlatBufferBuilder, start: UOffset) -> Offset { let end = Offset(offset: fbb.endTable(at: start)); return end } public static func createCameraCapabilities( _ fbb: inout FlatBufferBuilder, frontCameraOffset frontCamera: Offset = Offset(), backCameraOffset backCamera: Offset = Offset(), cameraDevicesVectorOffset cameraDevices: Offset = Offset(), - activeDeviceIdOffset activeDeviceId: Offset = Offset(), - supportsFocusPoint: Bool = false, supportsPreviewMode: Bool = false, supportsMulticam: Bool = false, - supportsManualExposure: Bool = false, - exposureOffset exposure: Offset = Offset(), - supportsCinematicVideo: Bool = false, - cinematicOffset cinematic: Offset = Offset() + controlOffset control: Offset = Offset() ) -> Offset { let __start = RemoteShutter_CameraCapabilities.startCameraCapabilities(&fbb) RemoteShutter_CameraCapabilities.add(frontCamera: frontCamera, &fbb) RemoteShutter_CameraCapabilities.add(backCamera: backCamera, &fbb) RemoteShutter_CameraCapabilities.addVectorOf(cameraDevices: cameraDevices, &fbb) - RemoteShutter_CameraCapabilities.add(activeDeviceId: activeDeviceId, &fbb) - RemoteShutter_CameraCapabilities.add(supportsFocusPoint: supportsFocusPoint, &fbb) RemoteShutter_CameraCapabilities.add(supportsPreviewMode: supportsPreviewMode, &fbb) RemoteShutter_CameraCapabilities.add(supportsMulticam: supportsMulticam, &fbb) - RemoteShutter_CameraCapabilities.add(supportsManualExposure: supportsManualExposure, &fbb) - RemoteShutter_CameraCapabilities.add(exposure: exposure, &fbb) - RemoteShutter_CameraCapabilities.add(supportsCinematicVideo: supportsCinematicVideo, &fbb) - RemoteShutter_CameraCapabilities.add(cinematic: cinematic, &fbb) + RemoteShutter_CameraCapabilities.add(control: control, &fbb) return RemoteShutter_CameraCapabilities.endCameraCapabilities(&fbb, start: __start) } @@ -1376,14 +1371,9 @@ public struct RemoteShutter_CameraCapabilities: FlatBufferObject, Verifiable { try _v.visit(field: VTOFFSET.frontCamera.p, fieldName: "frontCamera", required: false, type: ForwardOffset.self) try _v.visit(field: VTOFFSET.backCamera.p, fieldName: "backCamera", required: false, type: ForwardOffset.self) try _v.visit(field: VTOFFSET.cameraDevices.p, fieldName: "cameraDevices", required: false, type: ForwardOffset, RemoteShutter_CameraDeviceInfo>>.self) - try _v.visit(field: VTOFFSET.activeDeviceId.p, fieldName: "activeDeviceId", required: false, type: ForwardOffset.self) - try _v.visit(field: VTOFFSET.supportsFocusPoint.p, fieldName: "supportsFocusPoint", required: false, type: Bool.self) try _v.visit(field: VTOFFSET.supportsPreviewMode.p, fieldName: "supportsPreviewMode", required: false, type: Bool.self) try _v.visit(field: VTOFFSET.supportsMulticam.p, fieldName: "supportsMulticam", required: false, type: Bool.self) - try _v.visit(field: VTOFFSET.supportsManualExposure.p, fieldName: "supportsManualExposure", required: false, type: Bool.self) - try _v.visit(field: VTOFFSET.exposure.p, fieldName: "exposure", required: false, type: ForwardOffset.self) - try _v.visit(field: VTOFFSET.supportsCinematicVideo.p, fieldName: "supportsCinematicVideo", required: false, type: Bool.self) - try _v.visit(field: VTOFFSET.cinematic.p, fieldName: "cinematic", required: false, type: ForwardOffset.self) + try _v.visit(field: VTOFFSET.control.p, fieldName: "control", required: false, type: ForwardOffset.self) _v.finish() } } @@ -1407,14 +1397,12 @@ public struct RemoteShutter_CameraStateResponse: FlatBufferObject, Verifiable { case capabilities = 12 case mediaData = 14 case recordingStartTime = 16 - case availableLenses = 18 - case zoomRange = 20 - case currentZoom = 22 - case clockSyncEchoT0Ms = 24 - case clockSyncCameraClockMs = 26 - case captureIdEcho = 28 - case exposure = 30 - case cinematic = 32 + case clockSyncEchoT0Ms = 18 + case clockSyncCameraClockMs = 20 + case captureIdEcho = 22 + case control = 24 + case controlRefusal = 26 + case controlRefusalDetail = 28 var v: Int32 { Int32(self.rawValue) } var p: VOffset { self.rawValue } } @@ -1430,18 +1418,15 @@ public struct RemoteShutter_CameraStateResponse: FlatBufferObject, Verifiable { public func mediaData(at index: Int32) -> UInt8 { let o = _accessor.offset(VTOFFSET.mediaData.v); return o == 0 ? 0 : _accessor.directRead(of: UInt8.self, offset: _accessor.vector(at: o) + index * 1) } public var mediaData: [UInt8] { return _accessor.getVector(at: VTOFFSET.mediaData.v) ?? [] } public var recordingStartTime: UInt64 { let o = _accessor.offset(VTOFFSET.recordingStartTime.v); return o == 0 ? 0 : _accessor.readBuffer(of: UInt64.self, at: o) } - public var hasAvailableLenses: Bool { let o = _accessor.offset(VTOFFSET.availableLenses.v); return o == 0 ? false : true } - public var availableLensesCount: Int32 { let o = _accessor.offset(VTOFFSET.availableLenses.v); return o == 0 ? 0 : _accessor.vector(count: o) } - public func availableLenses(at index: Int32) -> RemoteShutter_CameraLensType? { let o = _accessor.offset(VTOFFSET.availableLenses.v); return o == 0 ? RemoteShutter_CameraLensType.wideangle : RemoteShutter_CameraLensType(rawValue: _accessor.directRead(of: Int8.self, offset: _accessor.vector(at: o) + index * 1)) } - public var zoomRange: RemoteShutter_ZoomRange? { let o = _accessor.offset(VTOFFSET.zoomRange.v); return o == 0 ? nil : RemoteShutter_ZoomRange(_accessor.bb, o: _accessor.indirect(o + _accessor.position)) } - public var currentZoom: Double { let o = _accessor.offset(VTOFFSET.currentZoom.v); return o == 0 ? 0.0 : _accessor.readBuffer(of: Double.self, at: o) } public var clockSyncEchoT0Ms: UInt64 { let o = _accessor.offset(VTOFFSET.clockSyncEchoT0Ms.v); return o == 0 ? 0 : _accessor.readBuffer(of: UInt64.self, at: o) } public var clockSyncCameraClockMs: UInt64 { let o = _accessor.offset(VTOFFSET.clockSyncCameraClockMs.v); return o == 0 ? 0 : _accessor.readBuffer(of: UInt64.self, at: o) } public var captureIdEcho: String? { let o = _accessor.offset(VTOFFSET.captureIdEcho.v); return o == 0 ? nil : _accessor.string(at: o) } public var captureIdEchoSegmentArray: [UInt8]? { return _accessor.getVector(at: VTOFFSET.captureIdEcho.v) } - public var exposure: RemoteShutter_ExposureState? { let o = _accessor.offset(VTOFFSET.exposure.v); return o == 0 ? nil : RemoteShutter_ExposureState(_accessor.bb, o: _accessor.indirect(o + _accessor.position)) } - public var cinematic: RemoteShutter_CinematicState? { let o = _accessor.offset(VTOFFSET.cinematic.v); return o == 0 ? nil : RemoteShutter_CinematicState(_accessor.bb, o: _accessor.indirect(o + _accessor.position)) } - public static func startCameraStateResponse(_ fbb: inout FlatBufferBuilder) -> UOffset { fbb.startTable(with: 15) } + public var control: RemoteShutter_ControlState? { let o = _accessor.offset(VTOFFSET.control.v); return o == 0 ? nil : RemoteShutter_ControlState(_accessor.bb, o: _accessor.indirect(o + _accessor.position)) } + public var controlRefusal: RemoteShutter_ControlRefusal { let o = _accessor.offset(VTOFFSET.controlRefusal.v); return o == 0 ? .unknown : RemoteShutter_ControlRefusal(rawValue: _accessor.readBuffer(of: Int8.self, at: o)) ?? .unknown } + public var controlRefusalDetail: String? { let o = _accessor.offset(VTOFFSET.controlRefusalDetail.v); return o == 0 ? nil : _accessor.string(at: o) } + public var controlRefusalDetailSegmentArray: [UInt8]? { return _accessor.getVector(at: VTOFFSET.controlRefusalDetail.v) } + public static func startCameraStateResponse(_ fbb: inout FlatBufferBuilder) -> UOffset { fbb.startTable(with: 13) } public static func add(action: RemoteShutter_CommandAction, _ fbb: inout FlatBufferBuilder) { fbb.add(element: action.rawValue, def: 0, at: VTOFFSET.action.p) } public static func add(success: Bool, _ fbb: inout FlatBufferBuilder) { fbb.add(element: success, def: false, at: VTOFFSET.success.p) } @@ -1450,14 +1435,12 @@ public struct RemoteShutter_CameraStateResponse: FlatBufferObject, Verifiable { public static func add(capabilities: Offset, _ fbb: inout FlatBufferBuilder) { fbb.add(offset: capabilities, at: VTOFFSET.capabilities.p) } public static func addVectorOf(mediaData: Offset, _ fbb: inout FlatBufferBuilder) { fbb.add(offset: mediaData, at: VTOFFSET.mediaData.p) } public static func add(recordingStartTime: UInt64, _ fbb: inout FlatBufferBuilder) { fbb.add(element: recordingStartTime, def: 0, at: VTOFFSET.recordingStartTime.p) } - public static func addVectorOf(availableLenses: Offset, _ fbb: inout FlatBufferBuilder) { fbb.add(offset: availableLenses, at: VTOFFSET.availableLenses.p) } - public static func add(zoomRange: Offset, _ fbb: inout FlatBufferBuilder) { fbb.add(offset: zoomRange, at: VTOFFSET.zoomRange.p) } - public static func add(currentZoom: Double, _ fbb: inout FlatBufferBuilder) { fbb.add(element: currentZoom, def: 0.0, at: VTOFFSET.currentZoom.p) } public static func add(clockSyncEchoT0Ms: UInt64, _ fbb: inout FlatBufferBuilder) { fbb.add(element: clockSyncEchoT0Ms, def: 0, at: VTOFFSET.clockSyncEchoT0Ms.p) } public static func add(clockSyncCameraClockMs: UInt64, _ fbb: inout FlatBufferBuilder) { fbb.add(element: clockSyncCameraClockMs, def: 0, at: VTOFFSET.clockSyncCameraClockMs.p) } public static func add(captureIdEcho: Offset, _ fbb: inout FlatBufferBuilder) { fbb.add(offset: captureIdEcho, at: VTOFFSET.captureIdEcho.p) } - public static func add(exposure: Offset, _ fbb: inout FlatBufferBuilder) { fbb.add(offset: exposure, at: VTOFFSET.exposure.p) } - public static func add(cinematic: Offset, _ fbb: inout FlatBufferBuilder) { fbb.add(offset: cinematic, at: VTOFFSET.cinematic.p) } + public static func add(control: Offset, _ fbb: inout FlatBufferBuilder) { fbb.add(offset: control, at: VTOFFSET.control.p) } + public static func add(controlRefusal: RemoteShutter_ControlRefusal, _ fbb: inout FlatBufferBuilder) { fbb.add(element: controlRefusal.rawValue, def: 0, at: VTOFFSET.controlRefusal.p) } + public static func add(controlRefusalDetail: Offset, _ fbb: inout FlatBufferBuilder) { fbb.add(offset: controlRefusalDetail, at: VTOFFSET.controlRefusalDetail.p) } public static func endCameraStateResponse(_ fbb: inout FlatBufferBuilder, start: UOffset) -> Offset { let end = Offset(offset: fbb.endTable(at: start)); return end } public static func createCameraStateResponse( _ fbb: inout FlatBufferBuilder, @@ -1468,14 +1451,12 @@ public struct RemoteShutter_CameraStateResponse: FlatBufferObject, Verifiable { capabilitiesOffset capabilities: Offset = Offset(), mediaDataVectorOffset mediaData: Offset = Offset(), recordingStartTime: UInt64 = 0, - availableLensesVectorOffset availableLenses: Offset = Offset(), - zoomRangeOffset zoomRange: Offset = Offset(), - currentZoom: Double = 0.0, clockSyncEchoT0Ms: UInt64 = 0, clockSyncCameraClockMs: UInt64 = 0, captureIdEchoOffset captureIdEcho: Offset = Offset(), - exposureOffset exposure: Offset = Offset(), - cinematicOffset cinematic: Offset = Offset() + controlOffset control: Offset = Offset(), + controlRefusal: RemoteShutter_ControlRefusal = .unknown, + controlRefusalDetailOffset controlRefusalDetail: Offset = Offset() ) -> Offset { let __start = RemoteShutter_CameraStateResponse.startCameraStateResponse(&fbb) RemoteShutter_CameraStateResponse.add(action: action, &fbb) @@ -1485,14 +1466,12 @@ public struct RemoteShutter_CameraStateResponse: FlatBufferObject, Verifiable { RemoteShutter_CameraStateResponse.add(capabilities: capabilities, &fbb) RemoteShutter_CameraStateResponse.addVectorOf(mediaData: mediaData, &fbb) RemoteShutter_CameraStateResponse.add(recordingStartTime: recordingStartTime, &fbb) - RemoteShutter_CameraStateResponse.addVectorOf(availableLenses: availableLenses, &fbb) - RemoteShutter_CameraStateResponse.add(zoomRange: zoomRange, &fbb) - RemoteShutter_CameraStateResponse.add(currentZoom: currentZoom, &fbb) RemoteShutter_CameraStateResponse.add(clockSyncEchoT0Ms: clockSyncEchoT0Ms, &fbb) RemoteShutter_CameraStateResponse.add(clockSyncCameraClockMs: clockSyncCameraClockMs, &fbb) RemoteShutter_CameraStateResponse.add(captureIdEcho: captureIdEcho, &fbb) - RemoteShutter_CameraStateResponse.add(exposure: exposure, &fbb) - RemoteShutter_CameraStateResponse.add(cinematic: cinematic, &fbb) + RemoteShutter_CameraStateResponse.add(control: control, &fbb) + RemoteShutter_CameraStateResponse.add(controlRefusal: controlRefusal, &fbb) + RemoteShutter_CameraStateResponse.add(controlRefusalDetail: controlRefusalDetail, &fbb) return RemoteShutter_CameraStateResponse.endCameraStateResponse(&fbb, start: __start) } @@ -1505,14 +1484,12 @@ public struct RemoteShutter_CameraStateResponse: FlatBufferObject, Verifiable { try _v.visit(field: VTOFFSET.capabilities.p, fieldName: "capabilities", required: false, type: ForwardOffset.self) try _v.visit(field: VTOFFSET.mediaData.p, fieldName: "mediaData", required: false, type: ForwardOffset>.self) try _v.visit(field: VTOFFSET.recordingStartTime.p, fieldName: "recordingStartTime", required: false, type: UInt64.self) - try _v.visit(field: VTOFFSET.availableLenses.p, fieldName: "availableLenses", required: false, type: ForwardOffset>.self) - try _v.visit(field: VTOFFSET.zoomRange.p, fieldName: "zoomRange", required: false, type: ForwardOffset.self) - try _v.visit(field: VTOFFSET.currentZoom.p, fieldName: "currentZoom", required: false, type: Double.self) try _v.visit(field: VTOFFSET.clockSyncEchoT0Ms.p, fieldName: "clockSyncEchoT0Ms", required: false, type: UInt64.self) try _v.visit(field: VTOFFSET.clockSyncCameraClockMs.p, fieldName: "clockSyncCameraClockMs", required: false, type: UInt64.self) try _v.visit(field: VTOFFSET.captureIdEcho.p, fieldName: "captureIdEcho", required: false, type: ForwardOffset.self) - try _v.visit(field: VTOFFSET.exposure.p, fieldName: "exposure", required: false, type: ForwardOffset.self) - try _v.visit(field: VTOFFSET.cinematic.p, fieldName: "cinematic", required: false, type: ForwardOffset.self) + try _v.visit(field: VTOFFSET.control.p, fieldName: "control", required: false, type: ForwardOffset.self) + try _v.visit(field: VTOFFSET.controlRefusal.p, fieldName: "controlRefusal", required: false, type: RemoteShutter_ControlRefusal.self) + try _v.visit(field: VTOFFSET.controlRefusalDetail.p, fieldName: "controlRefusalDetail", required: false, type: ForwardOffset.self) _v.finish() } } diff --git a/RemoteCam/MonitorDisplay.swift b/RemoteCam/MonitorDisplay.swift index d0a69604..79d739a8 100644 --- a/RemoteCam/MonitorDisplay.swift +++ b/RemoteCam/MonitorDisplay.swift @@ -17,7 +17,6 @@ protocol MonitorDisplay: AnyObject { var viewModel: MonitorViewModel { get } var frameStreamReceiver: FrameStreamReceiver { get } - var maxZoomFactor: CGFloat { get } func swiftUIConfigurePhotoMode() func swiftUIConfigureVideoMode() @@ -26,8 +25,9 @@ protocol MonitorDisplay: AnyObject { func updateFlashModeInViewModel(_ flashMode: AVCaptureDevice.FlashMode) func updateTorchModeInViewModel(_ torchMode: AVCaptureDevice.TorchMode) - func updateZoomInViewModel(_ factor: CGFloat, maxFactor: CGFloat) - func updateLensTypesInViewModel(_ lenses: [CameraLensType], current: CameraLensType) + /// The whole control-plane snapshot (v11) — zoom, lens, exposure and + /// Cinematic in one value. Replaces the per-field zoom/lens updates. + func applyControlState(_ state: ControlState) /// Leave the monitor screen (e.g. the peer refused the monitor role). func exitMonitor() diff --git a/RemoteCam/MonitorPresenter.swift b/RemoteCam/MonitorPresenter.swift index 249fed97..c9721f59 100644 --- a/RemoteCam/MonitorPresenter.swift +++ b/RemoteCam/MonitorPresenter.swift @@ -91,74 +91,32 @@ public final class MonitorPresenter { onMain { $0.updateTorchModeInViewModel(torchMode) } } - func updateZoom(_ zoomFactor: CGFloat?, zoomRange: RemoteCmd.ZoomRange?, currentLens: CameraLensType?) { - guard let zoomFactor else { return } - onMain { display in - let maxZoom = zoomRange?.maxZoom ?? display.maxZoomFactor - display.updateZoomInViewModel(zoomFactor, maxFactor: maxZoom) - // Sync lens type so zoom and lens controls stay cohesive - if let lens = currentLens { - display.viewModel.updateAvailableLenses(display.viewModel.availableLensTypes, current: lens) - } - } - } - - func updateExposure(_ state: ExposureState?) { - guard let state else { return } - onMain { $0.viewModel.exposure = state } - } - - func updateCinematic(_ state: CinematicState?) { - guard let state else { return } - onMain { $0.viewModel.cinematic = state } - } - - func updateLens(_ lensType: CameraLensType?, - availableLenses: [CameraLensType]?, - currentZoom: CGFloat?, - zoomRange: RemoteCmd.ZoomRange?) { - guard let lensType, let availableLenses else { return } - onMain { display in - display.updateLensTypesInViewModel(availableLenses, current: lensType) - if let currentZoom, let zoomRange { - display.updateZoomInViewModel(currentZoom, maxFactor: zoomRange.maxZoom) - } - } + /// The v11 control-plane channel: the whole snapshot in, stored as the one + /// control fact. Replaces the per-field updateZoom / updateLens / + /// updateExposure / updateCinematic — zoom, lens, exposure and Cinematic + /// are all pure reads of it now, so they can never disagree. + func applyControlState(_ state: ControlState) { + onMain { $0.applyControlState(state) } } func updateCapabilities(_ capabilities: RemoteCmd.CameraCapabilitiesResp) { onMain { display in // Device list first: a Mac camera has no front/back info, so the - // guard below would otherwise starve the device picker. + // guard below would otherwise starve the device picker. The active + // device is the LOGICAL one, carried in the control snapshot. display.viewModel.updateCameraDevices( capabilities.cameraDevices, - activeID: capabilities.activeDeviceID) + activeID: capabilities.control?.activeDeviceID) - // Set before the cameraInfo guard below: preview-mode support is a - // property of the peer, not of whichever camera it has selected, so - // a peer that reports no current camera must not lose the flag. + // Preview-mode support is a property of the peer, not of whichever + // camera it has selected, so a peer with no current camera must not + // lose the flag. (Zoom / lens / exposure / Cinematic no longer live + // here — they arrive as `control`, absorbed via applyControlState.) display.viewModel.supportsCameraStandby = capabilities.supportsPreviewMode - display.viewModel.supportsManualExposure = capabilities.supportsManualExposure - display.viewModel.exposure = capabilities.exposure - display.viewModel.supportsCinematicVideo = capabilities.supportsCinematicVideo - display.viewModel.cinematic = capabilities.cinematic guard let cameraInfo = capabilities.getCurrentCameraInfo() else { return } - // Update lens controls in view model - display.updateLensTypesInViewModel( - cameraInfo.availableLenses, - current: capabilities.currentLens - ) - // Update zoom controls in view model - if let zoomRange = cameraInfo.getZoomCapabilities()[capabilities.currentLens] { - display.updateZoomInViewModel( - capabilities.currentZoom, - maxFactor: zoomRange.maxZoom - ) - } - - // Update quality capabilities in view model + // Static per-position facts only: the quality menus. display.viewModel.updateVideoCapabilities( resolutions: cameraInfo.supportedResolutions, frameRates: cameraInfo.supportedFrameRates, @@ -174,12 +132,6 @@ public final class MonitorPresenter { display.viewModel.updatePhotoQuality( format: capabilities.currentPhotoFormat, hdrMode: capabilities.currentHDRMode) - - // Update zoom stops from camera capabilities - display.viewModel.updateZoomStops( - cameraInfo.zoomStops, - wideAngleZoomFactor: cameraInfo.wideAngleZoomFactor - ) } } diff --git a/RemoteCam/MonitorViewController+SwiftUI.swift b/RemoteCam/MonitorViewController+SwiftUI.swift index ed20f5ad..c722c7e2 100644 --- a/RemoteCam/MonitorViewController+SwiftUI.swift +++ b/RemoteCam/MonitorViewController+SwiftUI.swift @@ -271,8 +271,8 @@ extension MonitorViewController { /// Throttled to 20Hz with a trailing-edge flush so the value the user released on is /// always delivered, mirroring how the Watch drives crown zoom. private func handleZoomChange(_ factor: CGFloat) { - currentZoomFactor = factor - + // No local zoom cache to write: the pill shows its own in-flight value + // until the camera's next control snapshot confirms the new factor. switch zoomThrottle.update(value: Double(factor), now: Date()) { case .sendNow: session ! UICmd.SetZoom(zoomFactor: factor) @@ -375,12 +375,11 @@ extension MonitorViewController { viewModel.updateCameraImage(image) } - func updateZoomInViewModel(_ factor: CGFloat, maxFactor: CGFloat) { - viewModel.updateZoomFactor(factor, maxFactor: maxFactor) - } - - func updateLensTypesInViewModel(_ lenses: [CameraLensType], current: CameraLensType) { - viewModel.updateAvailableLenses(lenses, current: current) + /// The whole control-plane snapshot (v11): the view model stores it, and + /// zoom / lens / exposure / Cinematic all read off it. Replaces the old + /// per-field zoom and lens updates. + func applyControlState(_ state: ControlState) { + viewModel.applyControlState(state) } // MARK: - Video Transfer Progress Methods diff --git a/RemoteCam/MonitorViewController.swift b/RemoteCam/MonitorViewController.swift index de39a358..e2572477 100644 --- a/RemoteCam/MonitorViewController.swift +++ b/RemoteCam/MonitorViewController.swift @@ -54,8 +54,10 @@ public class MonitorViewController: UIViewController { private var zoomLabelTimer: Timer? // MARK: - Zoom and Lens Properties - var currentZoomFactor: CGFloat = 1.0 - public var maxZoomFactor: CGFloat = 10.0 + // Zoom factor, max zoom, and the lens list are no longer stored on the + // controller: they live in the one `MonitorViewModel.controlState` + // snapshot and are read from it. The pill's own pending value covers the + // in-flight echo during a drag. /// Zoom sends are throttled to 20Hz with a trailing-edge flush. A continuous drag on /// the Mac zoom pill emits a value per frame, which would flood the Multipeer channel; @@ -67,7 +69,6 @@ public class MonitorViewController: UIViewController { /// `proSender(for:)`). var proSenders: [ProSliderKind: ThrottledValueSender] = [:] var trailingZoomTimer: Timer? - var availableLensTypes: [CameraLensType] = [.wideAngle] var currentLensType: CameraLensType = .wideAngle var buttonPrompt: String = "" diff --git a/RemoteCam/MonitorViewModel.swift b/RemoteCam/MonitorViewModel.swift index 4b1455ed..e2ee56fd 100644 --- a/RemoteCam/MonitorViewModel.swift +++ b/RemoteCam/MonitorViewModel.swift @@ -62,21 +62,36 @@ class MonitorViewModel: ObservableObject { @Published var recordingElapsedMillis: UInt64? var isShowingRecordingDuration: Bool { uiState == .videoRecording } - // MARK: - Zoom and Lens Properties - @Published var currentZoomFactor: CGFloat = 1.0 - @Published var maxZoomFactor: CGFloat = 10.0 - @Published var availableLensTypes: [CameraLensType] = [.wideAngle] - @Published var currentLensType: CameraLensType = .wideAngle - @Published var zoomStops: [CGFloat] = [1.0] - @Published var wideAngleZoomFactor: CGFloat = 1.0 // Hardware zoom for "1x" reference + // MARK: - Control Plane (the ONE stored control fact) - /// Zoom math for every control on this screen — pinch and the zoom pill. Derived - /// rather than stored so it can never disagree with the published values it is - /// built from. + /// The camera's whole control-plane truth, as of the last snapshot the + /// coordinator folded in (v11: `ControlStateChanged` / capabilities seed). + /// Every control the screen shows — zoom range, lens list, exposure and + /// Cinematic capability and values — is a PURE read of this one value + /// (the computed vars below). Nothing derived is ever stored, so nothing + /// derived can go stale: a control can only be wrong if this snapshot is, + /// and this snapshot is replaced wholesale, never patched field by field. + @Published private(set) var controlState: ControlState? + + /// Fold in the latest snapshot. The stale-drop rule (`absorb`) is applied + /// HERE, where the value lives — callers cannot hand this model a state + /// older than the one it shows, whatever order deliveries arrive in. + func applyControlState(_ state: ControlState) { + DispatchQueue.main.async { self.controlState = ControlState.absorb(self.controlState, state) } + } + + // MARK: - Zoom and Lens Properties (derived from `controlState`) + + var currentZoomFactor: CGFloat { controlState?.zoomFactor ?? 1.0 } + var availableLensTypes: [CameraLensType] { controlState?.availableLenses ?? [.wideAngle] } + var currentLensType: CameraLensType { controlState?.currentLens ?? .wideAngle } + + /// Zoom math for every control on this screen — pinch and the zoom pill. + /// Comes straight off the snapshot's own `zoomScale`, so its range is + /// already whatever the camera can honor right now (e.g. narrowed under + /// Cinematic) with no combining left to this screen. var zoomScale: ZoomScale { - ZoomScale(stops: zoomStops, - maxZoomFactor: maxZoomFactor, - wideAngleZoomFactor: wideAngleZoomFactor) + controlState?.zoomScale ?? ZoomScale(stops: [1.0], maxZoomFactor: 1.0, wideAngleZoomFactor: 1.0) } // MARK: - Aspect Ratio Properties @@ -202,26 +217,9 @@ class MonitorViewModel: ObservableObject { } } - func updateZoomFactor(_ factor: CGFloat, maxFactor: CGFloat) { - DispatchQueue.main.async { - self.currentZoomFactor = factor - self.maxZoomFactor = ZoomScaleSeed.clampMaxZoom(maxFactor, wideAngle: self.wideAngleZoomFactor) - } - } - - func updateAvailableLenses(_ lenses: [CameraLensType], current: CameraLensType) { - DispatchQueue.main.async { - self.availableLensTypes = lenses - self.currentLensType = current - } - } - - func updateZoomStops(_ stops: [CGFloat], wideAngleZoomFactor: CGFloat) { - DispatchQueue.main.async { - self.zoomStops = stops - self.wideAngleZoomFactor = wideAngleZoomFactor - } - } + // Zoom, lens, and stops are no longer pushed field by field: they are + // computed off `controlState`, updated by `applyControlState`. The old + // updateZoomFactor / updateAvailableLenses / updateZoomStops are gone. func updateAspectRatio(_ ratio: AspectRatio) { DispatchQueue.main.async { @@ -260,16 +258,20 @@ class MonitorViewModel: ObservableObject { /// the standby tray tile — an older camera ignores the command, so offering /// a control that does nothing would be worse than hiding it. @Published var supportsCameraStandby: Bool = false + + // Pro-controls capability and truth are pure reads of `controlState`: + // capability IS presence (a nil field means the active camera can't do + // it — no tile, no command), and the values are the camera's echo, never + // what the pill last dragged to. /// Whether the peer's ACTIVE camera can do manual exposure. Gates the /// exposure control — absent, not disabled, when the camera can't. - @Published var supportsManualExposure: Bool = false - /// The camera's echoed exposure truth (mode, shutter, ISO, ranges). The - /// monitor renders only this, never the value it last dragged to. - @Published var exposure: ExposureState? + var supportsManualExposure: Bool { controlState?.supportsManualExposure ?? false } + /// The camera's echoed exposure truth (mode, shutter, ISO, ranges). + var exposure: ExposureState? { controlState?.exposure } /// Whether the peer can record Cinematic video (iOS 26+ camera). - @Published var supportsCinematicVideo: Bool = false + var supportsCinematicVideo: Bool { controlState?.supportsCinematicVideo ?? false } /// The camera's echoed Cinematic truth. - @Published var cinematic: CinematicState? + var cinematic: CinematicState? { controlState?.cinematic } // MARK: - Video Quality Update Methods func updateVideoQuality(resolution: VideoResolution, frameRate: VideoFrameRate) { diff --git a/RemoteCam/MulticamController.swift b/RemoteCam/MulticamController.swift index 32c77251..30ad688b 100644 --- a/RemoteCam/MulticamController.swift +++ b/RemoteCam/MulticamController.swift @@ -64,24 +64,16 @@ struct MulticamLaneInfo: Equatable { /// capabilities. Not the flip-button gate (that mirrors the 1:1 monitor and /// stays ungated); a projection of capabilities for diagnostics/tests. let canFlipCamera: Bool - /// This camera can focus at a point — gates the viewfinder's focus tap so - /// the user never gets a reticle (or a paywall) for a camera that can't. - let supportsFocusPoint: Bool - /// Pro controls (issue #206) for this camera: what it advertised and its - /// current echoed truth. The PRO tile follows the focused lane's flags. - let supportsManualExposure: Bool - let exposure: ExposureState? - let supportsCinematicVideo: Bool - let cinematic: CinematicState? + /// This camera's complete control-plane truth (v11) — zoom range + factor, + /// lens, focus-point support, manual exposure, Cinematic — as ONE value. + /// Everything the tile and the focused pill/slider need is a pure + /// derivation of this (`control?.zoomScale`, `control?.exposure != nil`, + /// …), so a stale range for one control while another moved is + /// unrepresentable. Nil until the first snapshot lands. + let control: ControlState? /// This camera's current device has a torch (front cameras don't) — gates /// the torch glyph when this lane is focused. let hasTorch: Bool - /// Zoom state for the focused zoom pill (the same values the 1:1 monitor - /// builds its `ZoomScale` from). `zoomFactor` is the live hardware factor. - let zoomFactor: CGFloat - let maxZoomFactor: CGFloat - let zoomStops: [CGFloat] - let wideAngleZoomFactor: CGFloat /// Optimistic torch / flash state so the control-capsule glyphs tint like /// the 1:1 monitor's the instant they are tapped. let torchOn: Bool @@ -605,9 +597,11 @@ public actor MulticamController { case let caps as RemoteCmd.CameraCapabilitiesResp: logInfo("director: caps from \(link.displayName) — torch=\(caps.getCurrentCameraInfo()?.hasTorch ?? false), camera=\(caps.currentCamera)") link.capabilities = caps - seedZoom(link, from: caps) - link.exposure = caps.exposure - link.cinematic = caps.cinematic + // The capabilities carry the control-plane seed; fold it in like + // any snapshot so the very first exchange configures the lane. + if let control = caps.control { + link.control = ControlState.absorb(link.control, control) + } if link.status != .failed { link.status = .linked } // A late joiner may not match the running rig quality: flag it (its // tile badges + the tray offers re-match) rather than silently @@ -649,28 +643,26 @@ public actor MulticamController { } if let caps = resp.cameraCapabilities { link.capabilities = caps - seedZoom(link, from: caps) - link.exposure = caps.exposure - link.cinematic = caps.cinematic + if let control = caps.control { + link.control = ControlState.absorb(link.control, control) + } } - case let resp as RemoteCmd.SetExposureResp: - // The camera's exposure truth after our request — applied, - // clamped, or refused (mode Auto + error). Only this echo moves - // the panel's dials. - if let state = resp.state { link.exposure = state } - surfaceRefusal(resp.error, what: "exposure", on: link) - - case let resp as RemoteCmd.SetCinematicResp: - if let state = resp.state { link.cinematic = state } - surfaceRefusal(resp.error, what: "Cinematic", on: link) - - case let resp as RemoteCmd.SetZoomResp: - // The focused camera settled on a zoom; reflect its factor and range - // on that lane so the pill's thumb and ceiling track the hardware. - if let factor = resp.zoomFactor { link.zoomFactor = factor } - if let maxZoom = resp.zoomRange?.maxZoom { - link.maxZoomFactor = ZoomScaleSeed.clampMaxZoom(maxZoom, wideAngle: link.wideAngleZoomFactor) + case let changed as RemoteCmd.ControlStateChanged: + // THE control-plane channel: one fold for every mutation (zoom, + // lens, exposure, Cinematic) and every unsolicited constraint + // move. The lane renders `f(control)`, so this single write keeps + // every dependent control (a Cinematic-narrowed zoom range, + // exposure ranges after a quality change) mutually consistent. + link.control = ControlState.absorb(link.control, changed.state) + // A refused mutation is said out loud — the snapshot already + // reset the panel to the unchanged truth, so silence would read + // as "the control does nothing". + if let refusal = changed.refusal { + let message = "\(link.displayName): \(refusal.message(detail: changed.refusalDetail))" + logWarning("director: control refused on \(link.displayName) — \(message)") + let display = display + OperationQueue.main.addOperation { display?.showTransientError(message) } } case let ack as RemoteCmd.ScheduledCaptureAck: @@ -896,7 +888,7 @@ public actor MulticamController { } private func handleFocusAtPoint(x: Float, y: Float, target: MCPeerID) { - guard links[target]?.capabilities?.supportsFocusPoint == true else { return } + guard links[target]?.control?.supportsFocusPoint == true else { return } sendTo(target, RemoteCmd.FocusAtPoint(x: x, y: y)) } @@ -918,25 +910,17 @@ public actor MulticamController { } private func handleSetExposure(_ intent: ExposureIntent, target: MCPeerID) { - guard links[target]?.capabilities?.supportsManualExposure == true else { return } + // Capability IS presence: an exposure block in the snapshot means the + // camera can honor SetExposure. Refusals return via ControlStateChanged. + guard links[target]?.control?.exposure != nil else { return } sendTo(target, RemoteCmd.SetExposure(intent: intent)) } private func handleSetCinematic(_ intent: CinematicIntent, target: MCPeerID) { - guard links[target]?.capabilities?.supportsCinematicVideo == true else { return } + guard links[target]?.control?.cinematic != nil else { return } sendTo(target, RemoteCmd.SetCinematic(intent: intent)) } - /// A refused pro-control request is said out loud (the same toast a - /// refused camera switch uses); the echo already reset the panel. - private func surfaceRefusal(_ error: Error?, what: String, on link: CameraLink) { - guard let error else { return } - logWarning("director: \(what) on \(link.displayName) refused — \(error._domain)") - let display = display - let message = "\(link.displayName): \(error._domain)" - OperationQueue.main.addOperation { display?.showTransientError(message) } - } - /// The rig's photo/video mode is a setting the cameras are told about, /// like standby and aspect: each camera's own screen follows the /// director, and Cinematic (a video effect) is only accepted by a camera @@ -995,16 +979,6 @@ public actor MulticamController { if focusedPeer == peer { focusedPeer = order.first } } - /// Seed a lane's zoom scale from a capabilities exchange via the shared - /// `ZoomScaleSeed` — the same values the 1:1 monitor derives. - private func seedZoom(_ link: CameraLink, from caps: RemoteCmd.CameraCapabilitiesResp) { - guard let seed = ZoomScaleSeed.seed(from: caps) else { return } - link.zoomStops = seed.zoomStops - link.wideAngleZoomFactor = seed.wideAngleZoomFactor - link.zoomFactor = seed.zoomFactor - if let maxZoom = seed.maxZoomFactor { link.maxZoomFactor = maxZoom } - } - // MARK: - Synced photo capture (all cameras) /// Test seams. @@ -1040,7 +1014,6 @@ public actor MulticamController { link.status = .linked link.capabilities = RemoteCmd.CameraCapabilitiesResp( frontCamera: nil, backCamera: nil, currentCamera: .back, - currentLens: .wideAngle, currentZoom: 1.0, supportsMulticam: supportsMulticam, error: nil) if let offsetMillis { // t0 == t3 == 0 → rtt 0, midpoint 0, so offset == cameraClock. diff --git a/RemoteCam/MulticamViewModel.swift b/RemoteCam/MulticamViewModel.swift index 6d20d4b6..5bc2f52f 100644 --- a/RemoteCam/MulticamViewModel.swift +++ b/RemoteCam/MulticamViewModel.swift @@ -37,16 +37,17 @@ final class CameraLane: ObservableObject, Identifiable { var needsQualityRematch: Bool { info.needsQualityRematch } var collection: CameraLink.LaneCollectionState { info.collection } var canFlipCamera: Bool { info.canFlipCamera } - var supportsFocusPoint: Bool { info.supportsFocusPoint } - var supportsManualExposure: Bool { info.supportsManualExposure } - var exposure: ExposureState? { info.exposure } - var supportsCinematicVideo: Bool { info.supportsCinematicVideo } - var cinematic: CinematicState? { info.cinematic } - var zoomFactor: CGFloat { info.zoomFactor } + /// This lane's control-plane truth; every pro/zoom read below is a pure + /// derivation of it, so they can never disagree with one another. + var control: ControlState? { info.control } + var supportsFocusPoint: Bool { info.control?.supportsFocusPoint ?? false } + var supportsManualExposure: Bool { info.control?.exposure != nil } + var exposure: ExposureState? { info.control?.exposure } + var supportsCinematicVideo: Bool { info.control?.cinematic != nil } + var cinematic: CinematicState? { info.control?.cinematic } + var zoomFactor: CGFloat { info.control?.zoomFactor ?? 1.0 } var zoomScale: ZoomScale { - ZoomScale(stops: info.zoomStops, - maxZoomFactor: info.maxZoomFactor, - wideAngleZoomFactor: info.wideAngleZoomFactor) + info.control?.zoomScale ?? ZoomScale(stops: [1.0], maxZoomFactor: 1.0, wideAngleZoomFactor: 1.0) } var torchOn: Bool { info.torchOn } var flashOn: Bool { info.flashOn } diff --git a/RemoteCam/RemoteCmdFlatBuffers.swift b/RemoteCam/RemoteCmdFlatBuffers.swift index 43fac5d7..146566c3 100644 --- a/RemoteCam/RemoteCmdFlatBuffers.swift +++ b/RemoteCam/RemoteCmdFlatBuffers.swift @@ -39,18 +39,15 @@ func serializeToFlatBuffer(_ msg: Message) -> Data? { case let m as RemoteCmd.SetStreamProfile: return m.toFlatBuffer() case let m as RemoteCmd.RequestVideoResend: return m.toFlatBuffer() case let m as RemoteCmd.SetZoom: return m.toFlatBuffer() - case let m as RemoteCmd.SetZoomResp: return m.toFlatBuffer() case let m as RemoteCmd.FocusAtPoint: return m.toFlatBuffer() case let m as RemoteCmd.SetExposure: return m.toFlatBuffer() - case let m as RemoteCmd.SetExposureResp: return m.toFlatBuffer() case let m as RemoteCmd.SetCinematic: return m.toFlatBuffer() - case let m as RemoteCmd.SetCinematicResp: return m.toFlatBuffer() + case let m as RemoteCmd.ControlStateChanged: return m.toFlatBuffer() case let m as RemoteCmd.SetCameraPreviewMode: return m.toFlatBuffer() case let m as RemoteCmd.CameraPreviewModeResp: return m.toFlatBuffer() case let m as RemoteCmd.EndSession: return m.toFlatBuffer() case let m as RemoteCmd.CameraCapabilitiesResp: return m.toFlatBuffer() case let m as RemoteCmd.SwitchLens: return m.toFlatBuffer() - case let m as RemoteCmd.SwitchLensResp: return m.toFlatBuffer() case let m as RemoteCmd.PeerBecameCamera: return m.toFlatBuffer() case let m as RemoteCmd.PeerBecameMonitor: return m.toFlatBuffer() case let m as RemoteCmd.ToggleFlash: return m.toFlatBuffer() @@ -261,15 +258,6 @@ private func encodeCameraInfo(_ info: RemoteCmd.CameraInfo, _ fbb: inout FlatBuf let lenses = info.availableLenses.map { toFBLens($0) } let lensesVector = fbb.createVector(lenses) - let caps = info.getZoomCapabilities() - var zoomCapOffsets: [Offset] = [] - for (lens, range) in caps { - let rangeOffset = RemoteShutter_ZoomRange.createZoomRange(&fbb, minZoom: Double(range.minZoom), maxZoom: Double(range.maxZoom)) - let capOffset = RemoteShutter_ZoomCapability.createZoomCapability(&fbb, lensType: toFBLens(lens), zoomRangeOffset: rangeOffset) - zoomCapOffsets.append(capOffset) - } - let zoomCapsVector = fbb.createVector(ofOffsets: zoomCapOffsets) - // Video quality capabilities var videoQualityOffset = Offset() if !info.supportedResolutions.isEmpty { @@ -299,19 +287,13 @@ private func encodeCameraInfo(_ info: RemoteCmd.CameraInfo, _ fbb: inout FlatBuf let photoQualityOffset = RemoteShutter_PhotoQualityCapabilities.createPhotoQualityCapabilities( &fbb, supportsHeif: info.supportsHEIF, supportsHdr: info.supportsHDR) - // Zoom stops - let zoomStopsVector = fbb.createVector(info.zoomStops.map { Double($0) }) - return RemoteShutter_CameraInfo.createCameraInfo( &fbb, availableLensesVectorOffset: lensesVector, hasFlash: info.hasFlash, hasTorch: info.hasTorch, - zoomCapabilitiesVectorOffset: zoomCapsVector, videoQualityOffset: videoQualityOffset, - photoQualityOffset: photoQualityOffset, - zoomStopsVectorOffset: zoomStopsVector, - wideAngleZoomFactor: Double(info.wideAngleZoomFactor) + photoQualityOffset: photoQualityOffset ) } @@ -325,16 +307,6 @@ private func decodeCameraInfo(_ fb: RemoteShutter_CameraInfo) -> RemoteCmd.Camer } } - var zoomCaps: [CameraLensType: RemoteCmd.ZoomRange] = [:] - for i in 0.. RemoteCmd.Camer let supportsHEIF = fb.photoQuality?.supportsHeif ?? false let supportsHDR = fb.photoQuality?.supportsHdr ?? false - // Decode zoom stops - var zoomStops: [CGFloat] = [] - for i in 0.. 0 ? CGFloat(fb.wideAngleZoomFactor) : 1.0 - return RemoteCmd.CameraInfo( availableLenses: lenses, hasFlash: fb.hasFlash, hasTorch: fb.hasTorch, - zoomCapabilities: zoomCaps, supportedResolutions: supportedResolutions, supportedFrameRates: supportedFrameRates, resolutionFrameRates: resolutionFrameRates, supportsHEIF: supportsHEIF, - supportsHDR: supportsHDR, - zoomStops: zoomStops, - wideAngleZoomFactor: wideAngleZoomFactor + supportsHDR: supportsHDR ) } @@ -424,34 +382,24 @@ private func encodeCapabilitiesEnvelope( } devicesVector = fbb.createVector(ofOffsets: deviceOffsets) } - let activeIDOffset = c.activeDeviceID.map { fbb.create(string: $0) } ?? Offset() - let exposureOffset = encodeExposureState(c.exposure, &fbb) - let cinematicOffset = encodeCinematicState(c.cinematic, &fbb) + let controlOffset = encodeControlState(c.control, &fbb) let capsOffset = RemoteShutter_CameraCapabilities.createCameraCapabilities( &fbb, frontCameraOffset: frontOffset, backCameraOffset: backOffset, cameraDevicesVectorOffset: devicesVector, - activeDeviceIdOffset: activeIDOffset, - supportsFocusPoint: c.supportsFocusPoint, supportsPreviewMode: c.supportsPreviewMode, supportsMulticam: c.supportsMulticam, - supportsManualExposure: c.supportsManualExposure, - exposureOffset: exposureOffset, - supportsCinematicVideo: c.supportsCinematicVideo, - cinematicOffset: cinematicOffset) + controlOffset: controlOffset) let stateOffset = RemoteShutter_CameraState.createCameraState( &fbb, currentCamera: toFBCamPos(c.currentCamera), - currentLens: toFBLens(c.currentLens), - zoomFactor: Double(c.currentZoom), videoResolution: toFBResolution(c.currentVideoResolution), videoFrameRate: toFBFrameRate(c.currentVideoFrameRate), photoFormat: toFBPhotoFormat(c.currentPhotoFormat), hdrMode: toFBHDRMode(c.currentHDRMode), - activeDeviceIdOffset: activeIDOffset, previewMode: toFBPreviewMode(c.previewMode)) return (capsOffset, stateOffset) @@ -627,21 +575,92 @@ extension RemoteCmd.SetExposure { } } -extension RemoteCmd.SetExposureResp { +extension RemoteCmd.ControlStateChanged { func toFlatBuffer() -> Data { var fbb = FlatBufferBuilder() - let errorOffset = (error as NSError?).map { fbb.create(string: RemoteCmd.wireErrorMessage($0)) } ?? Offset() - let exposureOffset = encodeExposureState(state, &fbb) + let detailOffset = refusalDetail.map { fbb.create(string: $0) } ?? Offset() + let controlOffset = encodeControlState(state, &fbb) let resp = RemoteShutter_CameraStateResponse.createCameraStateResponse( &fbb, - action: .setexposure, - success: error == nil, - errorOffset: errorOffset, - exposureOffset: exposureOffset) - return buildResponse(&fbb, action: .setexposure, response: resp) + action: .controlstatechanged, + success: refusal == nil, + controlOffset: controlOffset, + controlRefusal: toFBRefusal(refusal), + controlRefusalDetailOffset: detailOffset) + return buildResponse(&fbb, action: .controlstatechanged, response: resp) } } +func toFBRefusal(_ refusal: ControlRefusalReason?) -> RemoteShutter_ControlRefusal { + switch refusal { + case nil: return .none_ + case .photoMode: return .photomode + case .recording: return .recording + case .unsupported: return .unsupported + case .sessionRefused: return .sessionrefused + } +} + +func fromFBRefusal(_ refusal: RemoteShutter_ControlRefusal) -> ControlRefusalReason? { + switch refusal { + // Unknown (a malformed/future refusal) still surfaces as a refusal — + // never as silence. + case .unknown: return .sessionRefused + case .none_: return nil + case .photomode: return .photoMode + case .recording: return .recording + case .unsupported: return .unsupported + case .sessionrefused: return .sessionRefused + } +} + +func encodeControlState(_ state: ControlState?, _ fbb: inout FlatBufferBuilder) -> Offset { + guard let state else { return Offset() } + let deviceIDOffset = state.activeDeviceID.map { fbb.create(string: $0) } ?? Offset() + let lensesVector = fbb.createVector(state.availableLenses.map { toFBLens($0) }) + let stopsVector = fbb.createVector(state.zoomStops.map { Double($0) }) + let exposureOffset = encodeExposureState(state.exposure, &fbb) + let cinematicOffset = encodeCinematicState(state.cinematic, &fbb) + return RemoteShutter_ControlState.createControlState( + &fbb, + seq: state.seq, + mode: toFBRecordingMode(state.mode), + activeDeviceIdOffset: deviceIDOffset, + currentLens: toFBLens(state.currentLens), + availableLensesVectorOffset: lensesVector, + zoomFactor: Double(state.zoomFactor), + minZoom: Double(state.minZoom), + maxZoom: Double(state.maxZoom), + zoomStopsVectorOffset: stopsVector, + wideAngleZoomFactor: Double(state.wideAngleZoomFactor), + supportsFocusPoint: state.supportsFocusPoint, + exposureOffset: exposureOffset, + cinematicOffset: cinematicOffset) +} + +func decodeControlState(_ fb: RemoteShutter_ControlState?) -> ControlState? { + guard let fb else { return nil } + var lenses: [CameraLensType] = [] + for i in 0.. 0 ? CGFloat(fb.wideAngleZoomFactor) : 1.0, + supportsFocusPoint: fb.supportsFocusPoint, + exposure: decodeExposureState(fb.exposure), + cinematic: decodeCinematicState(fb.cinematic)) +} + extension RemoteCmd.SetCinematic { func toFlatBuffer() -> Data { var fbb = FlatBufferBuilder() @@ -657,20 +676,6 @@ extension RemoteCmd.SetCinematic { } } -extension RemoteCmd.SetCinematicResp { - func toFlatBuffer() -> Data { - var fbb = FlatBufferBuilder() - let errorOffset = (error as NSError?).map { fbb.create(string: RemoteCmd.wireErrorMessage($0)) } ?? Offset() - let cinematicOffset = encodeCinematicState(state, &fbb) - let resp = RemoteShutter_CameraStateResponse.createCameraStateResponse( - &fbb, - action: .setcinematic, - success: error == nil, - errorOffset: errorOffset, - cinematicOffset: cinematicOffset) - return buildResponse(&fbb, action: .setcinematic, response: resp) - } -} extension RemoteCmd.EndSession { func toFlatBuffer() -> Data { @@ -702,37 +707,6 @@ extension RemoteCmd.CameraPreviewModeResp { } } -extension RemoteCmd.SetZoomResp { - func toFlatBuffer() -> Data { - var fbb = FlatBufferBuilder() - let errorOffset = (error as NSError?).map { fbb.create(string: RemoteCmd.wireErrorMessage($0)) } ?? Offset() - - var stateOffset = Offset() - if zoomFactor != nil || currentLens != nil { - stateOffset = RemoteShutter_CameraState.createCameraState( - &fbb, - currentLens: currentLens.map { toFBLens($0) } ?? .wideangle, - zoomFactor: zoomFactor.map { Double($0) } ?? 0.0 - ) - } - - var zoomRangeOffset = Offset() - if let range = zoomRange { - zoomRangeOffset = RemoteShutter_ZoomRange.createZoomRange(&fbb, minZoom: Double(range.minZoom), maxZoom: Double(range.maxZoom)) - } - - let resp = RemoteShutter_CameraStateResponse.createCameraStateResponse( - &fbb, - action: .setzoom, - success: error == nil, - errorOffset: errorOffset, - currentStateOffset: stateOffset, - zoomRangeOffset: zoomRangeOffset - ) - return buildResponse(&fbb, action: .setzoom, response: resp) - } -} - extension RemoteCmd.CameraCapabilitiesResp { func toFlatBuffer() -> Data { encodeCapabilitiesResponse(action: .requestcapabilities, capabilities: self, error: error) @@ -747,44 +721,6 @@ extension RemoteCmd.SwitchLens { } } -extension RemoteCmd.SwitchLensResp { - func toFlatBuffer() -> Data { - var fbb = FlatBufferBuilder() - let errorOffset = (error as NSError?).map { fbb.create(string: RemoteCmd.wireErrorMessage($0)) } ?? Offset() - - var stateOffset = Offset() - if lensType != nil || currentZoom != nil { - stateOffset = RemoteShutter_CameraState.createCameraState( - &fbb, - currentLens: lensType.map { toFBLens($0) } ?? .wideangle, - zoomFactor: currentZoom.map { Double($0) } ?? 0.0 - ) - } - - var zoomRangeOffset = Offset() - if let range = zoomRange { - zoomRangeOffset = RemoteShutter_ZoomRange.createZoomRange(&fbb, minZoom: Double(range.minZoom), maxZoom: Double(range.maxZoom)) - } - - var lensesVector = Offset() - if let lenses = availableLenses { - lensesVector = fbb.createVector(lenses.map { toFBLens($0) }) - } - - let resp = RemoteShutter_CameraStateResponse.createCameraStateResponse( - &fbb, - action: .switchlens, - success: error == nil, - errorOffset: errorOffset, - currentStateOffset: stateOffset, - availableLensesVectorOffset: lensesVector, - zoomRangeOffset: zoomRangeOffset, - currentZoom: currentZoom.map { Double($0) } ?? 0.0 - ) - return buildResponse(&fbb, action: .switchlens, response: resp) - } -} - extension RemoteCmd.PeerBecameCamera { func toFlatBuffer() -> Data { var fbb = FlatBufferBuilder() @@ -1522,6 +1458,10 @@ extension RemoteCmd { case .endsession: return EndSession() + + case .controlstatechanged: + // A response-only action arriving as a command is malformed. + return nil } } @@ -1580,41 +1520,18 @@ extension RemoteCmd { return TakePicResp(sender: nil, pic: picData, error: nsError) } - case .setzoom: - let state = resp.currentState - let zoomFactor: CGFloat? = state != nil ? CGFloat(state!.zoomFactor) : nil - let currentLens: CameraLensType? = state != nil ? fromFBLens(state!.currentLens) : nil - let zoomRange: ZoomRange? = resp.zoomRange.map { ZoomRange(minZoom: CGFloat($0.minZoom), maxZoom: CGFloat($0.maxZoom)) } - return SetZoomResp(zoomFactor: zoomFactor, currentLens: currentLens, zoomRange: zoomRange, error: nsError) + case .controlstatechanged: + // The snapshot is the message; a response without one is malformed + // and dropped (never guessed at). + guard let control = decodeControlState(resp.control) else { return nil } + let detail = resp.controlRefusalDetail + return ControlStateChanged(state: control, + refusal: fromFBRefusal(resp.controlRefusal), + refusalDetail: detail) case .requestcapabilities: return decodeCameraCapabilitiesResp(resp, error: nsError) - case .setexposure: - return SetExposureResp(state: decodeExposureState(resp.exposure), error: nsError) - - case .setcinematic: - return SetCinematicResp(state: decodeCinematicState(resp.cinematic), error: nsError) - - case .switchlens: - let state = resp.currentState - let lensType: CameraLensType? = state != nil ? fromFBLens(state!.currentLens) : nil - let currentZoom: CGFloat? = state != nil ? CGFloat(state!.zoomFactor) : nil - let zoomRange: ZoomRange? = resp.zoomRange.map { ZoomRange(minZoom: CGFloat($0.minZoom), maxZoom: CGFloat($0.maxZoom)) } - - var lenses: [CameraLensType]? = nil - if resp.hasAvailableLenses { - var arr: [CameraLensType] = [] - for i in 0.. monitor: the exposure state after a `SetExposure` (applied - /// mode/values plus the active format's range). - public class SetExposureResp: Message, @unchecked Sendable { - public let state: ExposureState? - public let error: Error? - - public init(state: ExposureState?, error: Error?) { - self.state = state - self.error = error - super.init(sender: nil) - } - } - /// Monitor -> camera: Cinematic video on/off + simulated aperture (iOS /// 26+). Answered with `SetCinematicResp`. Only sent to peers that /// advertised `CameraCapabilitiesResp.supportsCinematicVideo`. @@ -449,14 +436,22 @@ public class RemoteCmd: Message, @unchecked Sendable { } } - /// Camera -> monitor: the Cinematic truth after a `SetCinematic`. - public class SetCinematicResp: Message, @unchecked Sendable { - public let state: CinematicState? - public let error: Error? + /// Camera -> monitor/director: THE control-plane truth channel (v11). + /// The answer to every control mutation (SetZoom, SwitchLens, SetExposure, + /// SetCinematic) and an unsolicited push whenever a constraint moves + /// without the remote asking. The snapshot is present even on refusal — + /// it is the unchanged truth the remote should show. + public class ControlStateChanged: Message, @unchecked Sendable { + public let state: ControlState + public let refusal: ControlRefusalReason? + /// Camera-side diagnostic suffix (device, format, outputs). + public let refusalDetail: String? - public init(state: CinematicState?, error: Error?) { + public init(state: ControlState, refusal: ControlRefusalReason? = nil, + refusalDetail: String? = nil) { self.state = state - self.error = error + self.refusal = refusal + self.refusalDetail = refusalDetail super.init(sender: nil) } } @@ -503,46 +498,33 @@ public class RemoteCmd: Message, @unchecked Sendable { // MARK: - Camera Capabilities Structure + /// Static per-position facts (quality menus, flash/torch presence). + /// Anything that changes with the session — zoom, lenses in use, + /// exposure — lives in `ControlState`, never here. public struct CameraInfo: Codable, Equatable { public let availableLenses: [CameraLensType] public let hasFlash: Bool public let hasTorch: Bool - public let zoomCapabilities: [Int: ZoomRange] // CameraLensType.rawValue -> ZoomRange public let supportedResolutions: [VideoResolution] public let supportedFrameRates: [VideoFrameRate] public let resolutionFrameRates: [Int: [VideoFrameRate]] // VideoResolution.rawValue -> supported FPS public let supportsHEIF: Bool public let supportsHDR: Bool - public let zoomStops: [CGFloat] // Hardware zoom factors for each stop (e.g., [1.0, 2.0, 6.0]) - public let wideAngleZoomFactor: CGFloat // Hardware zoom factor for the wide-angle camera (the "1x" reference) public init(availableLenses: [CameraLensType], hasFlash: Bool, hasTorch: Bool, - zoomCapabilities: [CameraLensType: ZoomRange], supportedResolutions: [VideoResolution] = [.hd1080p], supportedFrameRates: [VideoFrameRate] = [.fps30], resolutionFrameRates: [VideoResolution: [VideoFrameRate]] = [:], supportsHEIF: Bool = false, - supportsHDR: Bool = false, - zoomStops: [CGFloat] = [1.0], - wideAngleZoomFactor: CGFloat = 1.0) { + supportsHDR: Bool = false) { self.availableLenses = availableLenses self.hasFlash = hasFlash self.hasTorch = hasTorch - self.zoomCapabilities = Dictionary(uniqueKeysWithValues: zoomCapabilities.map { key, value in (key.rawValue, value) }) self.supportedResolutions = supportedResolutions self.supportedFrameRates = supportedFrameRates self.resolutionFrameRates = Dictionary(uniqueKeysWithValues: resolutionFrameRates.map { key, value in (key.rawValue, value) }) self.supportsHEIF = supportsHEIF self.supportsHDR = supportsHDR - self.zoomStops = zoomStops - self.wideAngleZoomFactor = wideAngleZoomFactor - } - - public func getZoomCapabilities() -> [CameraLensType: ZoomRange] { - return Dictionary(uniqueKeysWithValues: zoomCapabilities.compactMap { (rawValue, range) in - guard let lensType = CameraLensType(rawValue: rawValue) else { return nil } - return (lensType, range) - }) } public func getResolutionFrameRates() -> [VideoResolution: [VideoFrameRate]] { @@ -553,15 +535,6 @@ public class RemoteCmd: Message, @unchecked Sendable { } } - public struct ZoomRange: Codable, Equatable { - public let minZoom: CGFloat - public let maxZoom: CGFloat - - public init(minZoom: CGFloat, maxZoom: CGFloat) { - self.minZoom = minZoom - self.maxZoom = maxZoom - } - } // MARK: - Camera Device List (N cameras; Macs have no front/back pair) @@ -599,83 +572,54 @@ public class RemoteCmd: Message, @unchecked Sendable { // MARK: - Enhanced Camera Response + /// What the camera peer HAS: static device facts and session-level + /// features. What it is DOING — and every live range — is `control`, the + /// same `ControlState` that `ControlStateChanged` pushes, carried here so + /// the very first exchange seeds the remote completely. public class CameraCapabilitiesResp: Message, @unchecked Sendable { public let frontCamera: CameraInfo? public let backCamera: CameraInfo? public let currentCamera: AVCaptureDevice.Position - public let currentLens: CameraLensType - public let currentZoom: CGFloat public let currentVideoResolution: VideoResolution public let currentVideoFrameRate: VideoFrameRate public let currentPhotoFormat: PhotoFormat public let currentHDRMode: HDRMode public let cameraDevices: [CameraDeviceEntry] - public let activeDeviceID: String? - /// True when this peer's build understands `RemoteCmd.FocusAtPoint`. The - /// monitor's tap-to-focus gate reads this so it never sends the command - /// to a peer that would decode it as `TakePicture`. - public let supportsFocusPoint: Bool - /// True when this peer's build understands - /// `RemoteCmd.SetCameraPreviewMode`. The monitor's standby gate reads - /// this so it never sends the command to a peer that would misread it. + /// False = peer has no local preview-mode control. public let supportsPreviewMode: Bool - /// True when this peer's build can join a multicam director session - /// (scheduled capture, stream profiles). A director must not send - /// multicam commands to a peer that doesn't advertise this. + /// False = peer cannot join a multicam director session. public let supportsMulticam: Bool - /// The camera's current local-preview mode, so the monitor can reflect - /// it from the first capabilities exchange. + /// The camera's current local-preview mode. public let previewMode: CameraPreviewMode - /// True when the ACTIVE device can do custom exposure. The monitor's - /// exposure gate reads this so it never sends `SetExposure` to a peer - /// that cannot honor it — and shows no control at all. - public let supportsManualExposure: Bool - /// Current exposure truth + ranges, so the panel opens populated. - public let exposure: ExposureState? - /// True when the peer's active device can record Cinematic video - /// (iOS 26+). Gates `RemoteCmd.SetCinematic` and the monitor control. - public let supportsCinematicVideo: Bool - /// Current Cinematic truth + aperture range. - public let cinematic: CinematicState? + /// The control-plane seed. Nil only from a malformed peer; treated as + /// "no controls" everywhere. + public let control: ControlState? public let error: Error? public init(frontCamera: CameraInfo?, backCamera: CameraInfo?, - currentCamera: AVCaptureDevice.Position, currentLens: CameraLensType, - currentZoom: CGFloat, + currentCamera: AVCaptureDevice.Position, currentVideoResolution: VideoResolution = .hd1080p, currentVideoFrameRate: VideoFrameRate = .fps30, currentPhotoFormat: PhotoFormat = .jpeg, currentHDRMode: HDRMode = .off, cameraDevices: [CameraDeviceEntry] = [], - activeDeviceID: String? = nil, - supportsFocusPoint: Bool = false, supportsPreviewMode: Bool = false, supportsMulticam: Bool = false, previewMode: CameraPreviewMode = .on, - supportsManualExposure: Bool = false, - exposure: ExposureState? = nil, - supportsCinematicVideo: Bool = false, - cinematic: CinematicState? = nil, + control: ControlState? = nil, error: Error?) { self.frontCamera = frontCamera self.backCamera = backCamera self.currentCamera = currentCamera - self.currentLens = currentLens - self.currentZoom = currentZoom self.currentVideoResolution = currentVideoResolution self.currentVideoFrameRate = currentVideoFrameRate self.currentPhotoFormat = currentPhotoFormat self.currentHDRMode = currentHDRMode self.cameraDevices = cameraDevices - self.activeDeviceID = activeDeviceID - self.supportsFocusPoint = supportsFocusPoint self.supportsPreviewMode = supportsPreviewMode self.supportsMulticam = supportsMulticam self.previewMode = previewMode - self.supportsManualExposure = supportsManualExposure - self.exposure = exposure - self.supportsCinematicVideo = supportsCinematicVideo - self.cinematic = cinematic + self.control = control self.error = error super.init(sender: nil) } @@ -732,24 +676,6 @@ public class RemoteCmd: Message, @unchecked Sendable { } } - public class SwitchLensResp: Message, @unchecked Sendable { - public let lensType: CameraLensType? - public let availableLenses: [CameraLensType]? - public let currentZoom: CGFloat? - public let zoomRange: ZoomRange? - public let error: Error? - - public init(lensType: CameraLensType?, availableLenses: [CameraLensType]?, - currentZoom: CGFloat?, zoomRange: ZoomRange?, error: Error?) { - self.lensType = lensType - self.availableLenses = availableLenses - self.currentZoom = currentZoom - self.zoomRange = zoomRange - self.error = error - super.init(sender: nil) - } - } - /// What the two role announcements have in common: who the peer says it is. /// `shortVersion` is the pairing gate (see `PeerAppCompatibility`); the other /// two are diagnostics, carried so a refusal in the field can be read back @@ -884,20 +810,6 @@ public class RemoteCmd: Message, @unchecked Sendable { /// capabilities in, UI re-synced — so it shares that state's handling. public class SelectCameraDeviceResp: ToggleCameraResp, @unchecked Sendable {} - public class SetZoomResp: Message, @unchecked Sendable { - public let zoomFactor: CGFloat? - public let currentLens: CameraLensType? - public let zoomRange: ZoomRange? - public let error: Error? - - public init(zoomFactor: CGFloat?, currentLens: CameraLensType?, zoomRange: ZoomRange?, error: Error?) { - self.zoomFactor = zoomFactor - self.currentLens = currentLens - self.zoomRange = zoomRange - self.error = error - super.init(sender: nil) - } - } public class RequestCameraCapabilities: Message, @unchecked Sendable { public init() { diff --git a/RemoteCam/SessionCoordinator.swift b/RemoteCam/SessionCoordinator.swift index 5f3e7e85..583af7d5 100644 --- a/RemoteCam/SessionCoordinator.swift +++ b/RemoteCam/SessionCoordinator.swift @@ -227,35 +227,30 @@ public actor SessionCoordinator { /// Selecting a device on a peer that has none is meaningless, so don't. private var peerAdvertisedCameraDevices = false - /// Whether the connected camera peer advertised focus-point support in its - /// capabilities — the feature gate for `RemoteCmd.FocusAtPoint`. - private var peerSupportsFocusPoint = false + /// The camera peer's latest control-plane snapshot — the ONE source for + /// every live gate (focus / exposure / Cinematic) and range. Seeded by + /// capabilities, replaced by each `ControlStateChanged`; `nil` until the + /// first exchange. Capability IS presence: `.exposure != nil` gates + /// `SetExposure`, `.cinematic != nil` gates `SetCinematic`, + /// `.supportsFocusPoint` gates `FocusAtPoint`. + private var peerControl: ControlState? /// Test support. - func peerSupportsFocusPointForTesting() -> Bool { peerSupportsFocusPoint } + func peerControlForTesting() -> ControlState? { peerControl } + /// Derived views of the same snapshot, kept so behavior tests read the + /// gate exactly as the send paths do (capability = presence). + func peerSupportsManualExposureForTesting() -> Bool { peerControl?.exposure != nil } + func peerSupportsCinematicVideoForTesting() -> Bool { peerControl?.cinematic != nil } + func peerSupportsFocusPointForTesting() -> Bool { peerControl?.supportsFocusPoint == true } /// Whether the connected camera peer advertised preview-mode support in its /// capabilities — the feature gate for `RemoteCmd.SetCameraPreviewMode`. + /// A session-level flag, not part of the control snapshot. private var peerSupportsPreviewMode = false /// Test support. func peerSupportsPreviewModeForTesting() -> Bool { peerSupportsPreviewMode } - /// Whether the connected camera peer's ACTIVE device advertised manual - /// exposure — the feature gate for `RemoteCmd.SetExposure`. Re-absorbed on - /// every capabilities refresh because it changes with the device. - private var peerSupportsManualExposure = false - - /// Test support. - func peerSupportsManualExposureForTesting() -> Bool { peerSupportsManualExposure } - - /// Whether the connected camera peer advertised Cinematic-video support — - /// the feature gate for `RemoteCmd.SetCinematic`. - private var peerSupportsCinematicVideo = false - - /// Test support. - func peerSupportsCinematicVideoForTesting() -> Bool { peerSupportsCinematicVideo } - /// Monitor side: at least one VP9 preview frame has arrived. Proves the /// camera peer speaks VP9, which gates sending `RemoteCmd.RequestKeyframe`. private var monitorReceivedVP9Frame = false @@ -805,10 +800,8 @@ public actor SessionCoordinator { func popToScanning() async { lastCameraStateReportSeq = 0 peerAdvertisedCameraDevices = false - peerSupportsFocusPoint = false peerSupportsPreviewMode = false - peerSupportsManualExposure = false - peerSupportsCinematicVideo = false + peerControl = nil monitorReceivedVP9Frame = false // The session is being torn down for good (deliberate leave, EndSession, // or a dead link) — a fresh session starts single-cam until a director @@ -1243,7 +1236,7 @@ public actor SessionCoordinator { // device and the confirm above passes. Landing back on the // pre-toggle device means the switch failed — say so instead // of reporting a no-op success. - if let before, let after = capabilities?.activeDeviceID, after == before { + if let before, let after = capabilities?.control?.activeDeviceID, after == before { await sendOrGoToScanning(RemoteCmd.ToggleCameraResp( cameraCapabilities: nil, error: couldNotSwitchCameraError())) } else { @@ -1263,7 +1256,7 @@ public actor SessionCoordinator { let capabilities = await ctrl.gatherCurrentCameraCapabilities() // Same truth check as the toggle: not on the requested device // after the confirm ⇒ the engine reverted a failed switch. - if let after = capabilities?.activeDeviceID, after != select.uniqueID { + if let after = capabilities?.control?.activeDeviceID, after != select.uniqueID { await sendOrGoToScanning(RemoteCmd.SelectCameraDeviceResp( cameraCapabilities: nil, error: couldNotSwitchCameraError())) } else { @@ -1300,14 +1293,7 @@ public actor SessionCoordinator { } case let zoom as RemoteCmd.SetZoom: - do { - let (factor, lens, range) = try await ctrl.setZoom(zoomFactor: zoom.zoomFactor) - await sendOrGoToScanning(RemoteCmd.SetZoomResp( - zoomFactor: factor, currentLens: lens, zoomRange: range, error: nil)) - } catch { - await sendOrGoToScanning(RemoteCmd.SetZoomResp( - zoomFactor: nil, currentLens: nil, zoomRange: nil, error: error as NSError)) - } + await respondWithControlState(ctrl) { try await ctrl.setZoom(zoomFactor: zoom.zoomFactor) } case let focus as RemoteCmd.FocusAtPoint: // Fire-and-forget: the monitor already showed its reticle. A device @@ -1315,20 +1301,18 @@ public actor SessionCoordinator { try? await ctrl.focusAtPoint(x: focus.x, y: focus.y) case let exposure as RemoteCmd.SetExposure: - await handleSetExposure(exposure, ctrl: ctrl) + await respondWithControlState(ctrl) { try await ctrl.setExposure(exposure.intent) } case let cinematic as RemoteCmd.SetCinematic: - await handleSetCinematic(cinematic, ctrl: ctrl) + await respondWithControlState(ctrl) { try await ctrl.setCinematic(cinematic.intent) } case let lens as RemoteCmd.SwitchLens: - do { - let (lensType, available, zoom, range) = try await ctrl.switchLens(to: lens.lensType) - await sendOrGoToScanning(RemoteCmd.SwitchLensResp( - lensType: lensType, availableLenses: available, currentZoom: zoom, zoomRange: range, error: nil)) - } catch { - await sendOrGoToScanning(RemoteCmd.SwitchLensResp( - lensType: nil, availableLenses: nil, currentZoom: nil, zoomRange: nil, error: error as NSError)) - } + await respondWithControlState(ctrl) { try await ctrl.switchLens(to: lens.lensType) } + + case let push as UICmd.PushControlState: + // A constraint moved without a remote command (device swap, mode + // change): forward the fresh snapshot unsolicited. + await sendOrGoToScanning(RemoteCmd.ControlStateChanged(state: push.state)) case let sync as RemoteCmd.SyncMonitorSettings: let mode = sync.mode @@ -1410,44 +1394,48 @@ public actor SessionCoordinator { /// recording-truth derivation on top. private func absorbCapabilities(_ capabilities: RemoteCmd.CameraCapabilitiesResp) { peerAdvertisedCameraDevices = !capabilities.cameraDevices.isEmpty - peerSupportsFocusPoint = capabilities.supportsFocusPoint peerSupportsPreviewMode = capabilities.supportsPreviewMode - peerSupportsManualExposure = capabilities.supportsManualExposure - peerSupportsCinematicVideo = capabilities.supportsCinematicVideo monitor?.updateCapabilities(capabilities) monitor?.updatePreviewMode(capabilities.previewMode) - } - - /// Camera side of `SetExposure`: apply, then echo the device's truth (or - /// the error) so the monitor never shows a state the camera didn't confirm. - private func handleSetExposure(_ cmd: RemoteCmd.SetExposure, ctrl: CameraControlling) async { - do { - let state = try await ctrl.setExposure(cmd.intent) - await sendOrGoToScanning(RemoteCmd.SetExposureResp(state: state, error: nil)) - } catch { - await sendOrGoToScanning(RemoteCmd.SetExposureResp(state: nil, error: error as NSError)) - } - } - - /// Camera side of `SetCinematic`, mirroring `handleSetExposure`. - private func handleSetCinematic(_ cmd: RemoteCmd.SetCinematic, ctrl: CameraControlling) async { + // The control-plane seed rides in capabilities; every live gate and + // range derives from this one snapshot (`peerControl`). + if let control = capabilities.control { absorbControlState(control) } + } + + /// The ONE monitor-side write of camera-control truth: newer wins (stale + /// snapshots drop), the whole picture renders from one value, and a + /// refusal is said out loud. Delivery order, duplicate pushes, and races + /// between a request and an unsolicited push all collapse into + /// `ControlState.absorb`. + private func absorbControlState(_ state: ControlState, + refusal: ControlRefusalReason? = nil, + detail: String? = nil) { + let merged = ControlState.absorb(peerControl, state) + peerControl = merged + monitor?.applyControlState(merged) + if let refusal { showErrorAlert(refusal.message(detail: detail)) } + } + + /// Camera side of every control mutation: apply, then answer with the + /// full snapshot (`ControlStateChanged`). A `CinematicRefusal` still + /// answers with the unchanged snapshot plus the typed reason, so a refused + /// control never looks like a control that did nothing; any other error + /// becomes a `.sessionRefused` carrying its message. One shape replaces the + /// old per-control SetZoomResp / SwitchLensResp / SetExposureResp / + /// SetCinematicResp responses. + private func respondWithControlState(_ ctrl: CameraControlling, + _ mutate: () async throws -> ControlState) async { do { - let state = try await ctrl.setCinematic(cmd.intent) - await sendOrGoToScanning(RemoteCmd.SetCinematicResp(state: state, error: nil)) - // Cinematic narrows (or, on disable, restores) the zoom range and - // may have clamped the current factor — tell the monitor so its - // zoom pill re-scales to what the camera can now honor. - await sendOrGoToScanning(RemoteCmd.SetZoomResp( - zoomFactor: await ctrl.getCurrentZoomFactor(), - currentLens: nil, - zoomRange: RemoteCmd.ZoomRange(minZoom: await ctrl.getMinZoomFactor(), - maxZoom: await ctrl.getMaxZoomFactor()), - error: nil)) + let state = try await mutate() + await sendOrGoToScanning(RemoteCmd.ControlStateChanged(state: state)) } catch let refusal as CaptureEngine.CinematicRefusal { - // A refusal is a message for the person at the remote, not a fault. - await sendOrGoToScanning(RemoteCmd.SetCinematicResp(state: nil, error: refusal.asNSError)) + guard let state = await ctrl.controlState() else { return } + await sendOrGoToScanning(RemoteCmd.ControlStateChanged( + state: state, refusal: refusal.reason, refusalDetail: refusal.detail)) } catch { - await sendOrGoToScanning(RemoteCmd.SetCinematicResp(state: nil, error: error as NSError)) + guard let state = await ctrl.controlState() else { return } + await sendOrGoToScanning(RemoteCmd.ControlStateChanged( + state: state, refusal: .sessionRefused, refusalDetail: (error as NSError).domain)) } } @@ -1730,14 +1718,7 @@ public actor SessionCoordinator { switch msg { case let zoom as RemoteCmd.SetZoom: - do { - let (factor, lens, range) = try await ctrl.setZoom(zoomFactor: zoom.zoomFactor) - await sendOrGoToScanning(RemoteCmd.SetZoomResp( - zoomFactor: factor, currentLens: lens, zoomRange: range, error: nil)) - } catch { - await sendOrGoToScanning(RemoteCmd.SetZoomResp( - zoomFactor: nil, currentLens: nil, zoomRange: nil, error: error as NSError)) - } + await respondWithControlState(ctrl) { try await ctrl.setZoom(zoomFactor: zoom.zoomFactor) } case let focus as RemoteCmd.FocusAtPoint: // Fire-and-forget; focusing is allowed while recording too. @@ -1746,21 +1727,17 @@ public actor SessionCoordinator { case let exposure as RemoteCmd.SetExposure: // Allowed while recording: the policy caps the shutter at the frame // duration so the clip's frame rate holds. - await handleSetExposure(exposure, ctrl: ctrl) + await respondWithControlState(ctrl) { try await ctrl.setExposure(exposure.intent) } case let cinematic as RemoteCmd.SetCinematic: - // The policy rejects mid-take changes; the response says so. - await handleSetCinematic(cinematic, ctrl: ctrl) + // The policy rejects mid-take changes; the snapshot carries the refusal. + await respondWithControlState(ctrl) { try await ctrl.setCinematic(cinematic.intent) } case let lens as RemoteCmd.SwitchLens: - do { - let (lensType, available, zoom, range) = try await ctrl.switchLens(to: lens.lensType) - await sendOrGoToScanning(RemoteCmd.SwitchLensResp( - lensType: lensType, availableLenses: available, currentZoom: zoom, zoomRange: range, error: nil)) - } catch { - await sendOrGoToScanning(RemoteCmd.SwitchLensResp( - lensType: nil, availableLenses: nil, currentZoom: nil, zoomRange: nil, error: error as NSError)) - } + await respondWithControlState(ctrl) { try await ctrl.switchLens(to: lens.lensType) } + + case let push as UICmd.PushControlState: + await sendOrGoToScanning(RemoteCmd.ControlStateChanged(state: push.state)) case is RemoteCmd.RequestKeyframe: // The preview stream keeps flowing while recording, so a desynced @@ -2181,10 +2158,8 @@ public actor SessionCoordinator { await sendOrGoToScanning(RemoteCmd.SelectCameraDeviceResp(cameraCapabilities: nil, error: unableToProcessError(msg))) case is RemoteCmd.ToggleFlash: await sendOrGoToScanning(RemoteCmd.ToggleFlashResp(flashMode: nil, error: unableToProcessError(msg))) - case is RemoteCmd.SetZoom: - await sendOrGoToScanning(RemoteCmd.SetZoomResp(zoomFactor: nil, currentLens: nil, zoomRange: nil, error: unableToProcessError(msg))) - case is RemoteCmd.SwitchLens: - await sendOrGoToScanning(RemoteCmd.SwitchLensResp(lensType: nil, availableLenses: nil, currentZoom: nil, zoomRange: nil, error: unableToProcessError(msg))) + // SetZoom / SwitchLens need no busy-state error reply: the monitor + // self-heals from the next `ControlStateChanged` snapshot. case is RemoteCmd.SetAspectRatio: await sendOrGoToScanning(RemoteCmd.SetAspectRatioResp(aspectRatio: nil, error: unableToProcessError(msg))) case is RemoteCmd.StartRecordingVideo: @@ -2452,7 +2427,7 @@ public actor SessionCoordinator { case let focus as UICmd.FocusAtPoint: // Wire-safety gate: never send to a peer that would decode action 21 // as TakePicture. Silently dropped otherwise (reticle already shown). - guard peerSupportsFocusPoint else { + guard peerControl?.supportsFocusPoint == true else { debugLog("FocusAtPoint dropped: peer did not advertise focus-point support") break } @@ -2460,30 +2435,24 @@ public actor SessionCoordinator { case let exposure as UICmd.SetExposure: // Wire-safety gate mirroring FocusAtPoint: never send action 33 to a - // peer whose active camera cannot honor it. - guard peerSupportsManualExposure else { + // peer whose active camera cannot honor it (no exposure in the snapshot). + guard peerControl?.exposure != nil else { debugLog("SetExposure dropped: peer did not advertise manual-exposure support") break } sendMessage(RemoteCmd.SetExposure(intent: exposure.intent)) - case let exposureResp as RemoteCmd.SetExposureResp: - monitor?.updateExposure(exposureResp.state) - if let error = exposureResp.error { showErrorAlert(error._domain) } - case let cinematic as UICmd.SetCinematic: - guard peerSupportsCinematicVideo else { + guard peerControl?.cinematic != nil else { debugLog("SetCinematic dropped: peer did not advertise Cinematic support") break } sendMessage(RemoteCmd.SetCinematic(intent: cinematic.intent)) - case let cinematicResp as RemoteCmd.SetCinematicResp: - monitor?.updateCinematic(cinematicResp.state) - // A refused toggle is said out loud (the camera-switch rule): the - // tile already shows the unchanged truth, so silence would read - // as "the button does nothing". - if let error = cinematicResp.error { showErrorAlert(error._domain) } + case let changed as RemoteCmd.ControlStateChanged: + // The one control-truth channel: the answer to every mutation and + // every unsolicited constraint move. A refusal is surfaced here. + absorbControlState(changed.state, refusal: changed.refusal, detail: changed.refusalDetail) case let preview as UICmd.SetCameraPreviewMode: // Wire-safety gate mirroring FocusAtPoint: never send action 24 to a @@ -2494,9 +2463,6 @@ public actor SessionCoordinator { } sendMessage(RemoteCmd.SetCameraPreviewMode(mode: preview.mode)) - case let zoomResp as RemoteCmd.SetZoomResp: - monitor?.updateZoom(zoomResp.zoomFactor, zoomRange: zoomResp.zoomRange, currentLens: zoomResp.currentLens) - case let torchResp as RemoteCmd.ToggleTorchResp: monitor?.updateTorchMode(torchResp.torchMode) @@ -2643,13 +2609,9 @@ public actor SessionCoordinator { // Also matches SelectCameraDeviceResp (a subclass): a completed // device selection re-syncs the monitor exactly like a toggle. // Forward the fresh capabilities so the monitor UI re-syncs to the - // new camera (lens list, zoom range, quality). + // new camera (device list, control snapshot, quality). if let capabilities = toggleResp.cameraCapabilities { - peerAdvertisedCameraDevices = !capabilities.cameraDevices.isEmpty - peerSupportsFocusPoint = capabilities.supportsFocusPoint - peerSupportsPreviewMode = capabilities.supportsPreviewMode - monitor?.updateCapabilities(capabilities) - monitor?.updatePreviewMode(capabilities.previewMode) + absorbCapabilities(capabilities) } else if let error = toggleResp.error { showErrorAlert(error._domain) } else { @@ -2688,16 +2650,10 @@ public actor SessionCoordinator { case is UICmd.SwitchLens: break // Already sent from parent state; ignore duplicate taps - case let lensResp as RemoteCmd.SwitchLensResp: - if lensResp.lensType != nil { - monitor?.updateLens(lensResp.lensType, - availableLenses: lensResp.availableLenses, - currentZoom: lensResp.currentZoom, - zoomRange: lensResp.zoomRange) - } else if let error = lensResp.error { - showErrorAlert(error._domain) - } else { - } + case let changed as RemoteCmd.ControlStateChanged: + // A lens switch answers with the full snapshot (lens, zoom range, + // exposure) — the monitor re-syncs and the transient state ends. + absorbControlState(changed.state, refusal: changed.refusal, detail: changed.refusalDetail) await transition(to: returnState()) case let disconnected as DisconnectPeer: @@ -2825,7 +2781,7 @@ public actor SessionCoordinator { sendMessage(RemoteCmd.SetZoom(zoomFactor: zoom.zoomFactor)) case let focus as UICmd.FocusAtPoint: - guard peerSupportsFocusPoint else { + guard peerControl?.supportsFocusPoint == true else { debugLog("FocusAtPoint dropped: peer did not advertise focus-point support") break } @@ -2833,30 +2789,22 @@ public actor SessionCoordinator { case let exposure as UICmd.SetExposure: // Wire-safety gate mirroring FocusAtPoint: never send action 33 to a - // peer whose active camera cannot honor it. - guard peerSupportsManualExposure else { + // peer whose active camera cannot honor it (no exposure in the snapshot). + guard peerControl?.exposure != nil else { debugLog("SetExposure dropped: peer did not advertise manual-exposure support") break } sendMessage(RemoteCmd.SetExposure(intent: exposure.intent)) - case let exposureResp as RemoteCmd.SetExposureResp: - monitor?.updateExposure(exposureResp.state) - if let error = exposureResp.error { showErrorAlert(error._domain) } - case let cinematic as UICmd.SetCinematic: - guard peerSupportsCinematicVideo else { + guard peerControl?.cinematic != nil else { debugLog("SetCinematic dropped: peer did not advertise Cinematic support") break } sendMessage(RemoteCmd.SetCinematic(intent: cinematic.intent)) - case let cinematicResp as RemoteCmd.SetCinematicResp: - monitor?.updateCinematic(cinematicResp.state) - // A refused toggle is said out loud (the camera-switch rule): the - // tile already shows the unchanged truth, so silence would read - // as "the button does nothing". - if let error = cinematicResp.error { showErrorAlert(error._domain) } + case let changed as RemoteCmd.ControlStateChanged: + absorbControlState(changed.state, refusal: changed.refusal, detail: changed.refusalDetail) case let preview as UICmd.SetCameraPreviewMode: guard peerSupportsPreviewMode else { @@ -2865,9 +2813,6 @@ public actor SessionCoordinator { } sendMessage(RemoteCmd.SetCameraPreviewMode(mode: preview.mode)) - case let zoomResp as RemoteCmd.SetZoomResp: - monitor?.updateZoom(zoomResp.zoomFactor, zoomRange: zoomResp.zoomRange, currentLens: zoomResp.currentLens) - case let lens as UICmd.SwitchLens: if sendMessage(RemoteCmd.SwitchLens(lensType: lens.lensType)) { let generation = scheduleTimeout(.monitorSwitchingLens) diff --git a/RemoteCam/UICmds.swift b/RemoteCam/UICmds.swift index 32ba3d47..22fceb03 100644 --- a/RemoteCam/UICmds.swift +++ b/RemoteCam/UICmds.swift @@ -12,7 +12,7 @@ import Stormo import UIKit import AVFoundation -enum RecordingMode { +public enum RecordingMode { case Photo case Video case Shorts @@ -261,17 +261,14 @@ public class UICmd { } } - public class SetZoomResp: Message, @unchecked Sendable { - public let zoomFactor: CGFloat? - public let currentLens: CameraLensType? - public let zoomRange: ZoomRange? - public let error: Error? + /// Camera screen -> session: the engine's control snapshot moved without + /// a remote command (device swap, quality change, mode change). The + /// camera states forward it as an unsolicited `ControlStateChanged`. + public class PushControlState: Message, @unchecked Sendable { + public let state: ControlState - public init(zoomFactor: CGFloat?, currentLens: CameraLensType?, zoomRange: ZoomRange?, error: Error?) { - self.zoomFactor = zoomFactor - self.currentLens = currentLens - self.zoomRange = zoomRange - self.error = error + public init(state: ControlState) { + self.state = state super.init(sender: nil) } } @@ -286,24 +283,6 @@ public class UICmd { } } - public class SwitchLensResp: Message, @unchecked Sendable { - public let lensType: CameraLensType? - public let availableLenses: [CameraLensType]? - public let currentZoom: CGFloat? - public let zoomRange: ZoomRange? - public let error: Error? - - public init(lensType: CameraLensType?, availableLenses: [CameraLensType]?, - currentZoom: CGFloat?, zoomRange: ZoomRange?, error: Error?) { - self.lensType = lensType - self.availableLenses = availableLenses - self.currentZoom = currentZoom - self.zoomRange = zoomRange - self.error = error - super.init(sender: nil) - } - } - public class ToggleFlash: Message, @unchecked Sendable { public init() { diff --git a/RemoteCam/ZoomScale.swift b/RemoteCam/ZoomScale.swift index 965b19d6..d94d1d27 100644 --- a/RemoteCam/ZoomScale.swift +++ b/RemoteCam/ZoomScale.swift @@ -18,11 +18,25 @@ struct ZoomScale: Equatable { let minZoom: CGFloat let maxZoom: CGFloat - init(stops: [CGFloat], maxZoomFactor: CGFloat, wideAngleZoomFactor: CGFloat) { + /// Display zoom tops out at 5× the wide-angle reference, so a pill never + /// offers unreachable range. The one place this constant lives. + static let maxDisplayZoom: CGFloat = 5.0 + + /// Clamp a camera-reported max zoom to the display ceiling. + static func displayCapped(_ maxFactor: CGFloat, wideAngle: CGFloat) -> CGFloat { + min(maxFactor, maxDisplayZoom * wideAngle) + } + + /// `minZoomFactor` is a hard floor below which the camera cannot go right + /// now (Cinematic narrows zoom from both ends); stops beneath it are not + /// offered. Nil/invalid = the first stop is the floor, as ever. + init(stops: [CGFloat], maxZoomFactor: CGFloat, wideAngleZoomFactor: CGFloat, + minZoomFactor: CGFloat? = nil) { let usable = stops.filter { $0.isFinite && $0 > 0 }.sorted() let safeStops = usable.isEmpty ? [1.0] : usable - let low = safeStops[0] - // `maxZoomFactor` arrives as a default (10.0) before the first SetZoomResp and can + let floor = minZoomFactor.flatMap { ($0.isFinite && $0 > 0) ? $0 : nil } + let low = max(safeStops[0], floor ?? 0) + // `maxZoomFactor` arrives as a default before the first snapshot and can // legitimately land at or below the low stop on a fixed-focal-length camera. let ceiling = (maxZoomFactor.isFinite && maxZoomFactor > low) ? maxZoomFactor : low @@ -30,9 +44,11 @@ struct ZoomScale: Equatable { self.maxZoom = ceiling self.wideAngleZoomFactor = (wideAngleZoomFactor.isFinite && wideAngleZoomFactor > 0) ? wideAngleZoomFactor : 1.0 - // A stop past the ceiling can't be reached, so it must not be offered as a detent. - // `low` always survives, so this can never empty the array. - self.stops = safeStops.filter { $0 <= ceiling } + // A stop outside [low, ceiling] can't be reached, so it must not be + // offered as a detent; if the floor swallowed every stop, the floor + // itself is the one detent. + let reachable = safeStops.filter { $0 >= low && $0 <= ceiling } + self.stops = reachable.isEmpty ? [low] : reachable } /// True when the range has collapsed and there is nothing to zoom: before the first diff --git a/RemoteCam/ZoomScaleSeed.swift b/RemoteCam/ZoomScaleSeed.swift deleted file mode 100644 index 15963b8c..00000000 --- a/RemoteCam/ZoomScaleSeed.swift +++ /dev/null @@ -1,49 +0,0 @@ -// -// ZoomScaleSeed.swift -// RemoteShutter -// -// Copyright © 2026 Security Union LLC. All rights reserved. -// - -import CoreGraphics - -/// The single home for the zoom-range math both camera-control paths share: -/// the 1:1 monitor (`MonitorViewModel`/`MonitorPresenter`) and the multicam -/// director (`MulticamController`). Pure — no view, no isolation — so both -/// derive identical values from the same capabilities. -enum ZoomScaleSeed { - - /// Display zoom tops out at 5× the wide-angle reference, so a pill never - /// offers unreachable range. The one place this constant lives. - static let maxDisplayZoom: CGFloat = 5.0 - - /// Clamp a camera-reported max zoom to the display ceiling. - static func clampMaxZoom(_ maxFactor: CGFloat, wideAngle: CGFloat) -> CGFloat { - min(maxFactor, maxDisplayZoom * wideAngle) - } - - /// The zoom values seeded from a capabilities exchange. - struct Seed { - let zoomFactor: CGFloat - let zoomStops: [CGFloat] - let wideAngleZoomFactor: CGFloat - /// The clamped ceiling, or nil when the current lens advertised no - /// range — callers leave their existing ceiling untouched then. - let maxZoomFactor: CGFloat? - } - - /// Read zoom state from a capabilities response the way both paths do: - /// stops and wide-angle reference from the current camera, the ceiling from - /// the current lens's zoom range (clamped). Nil when there is no current - /// camera to read. - static func seed(from caps: RemoteCmd.CameraCapabilitiesResp) -> Seed? { - guard let info = caps.getCurrentCameraInfo() else { return nil } - let wide = info.wideAngleZoomFactor - let maxZoom = info.getZoomCapabilities()[caps.currentLens] - .map { clampMaxZoom($0.maxZoom, wideAngle: wide) } - return Seed(zoomFactor: caps.currentZoom, - zoomStops: info.zoomStops, - wideAngleZoomFactor: wide, - maxZoomFactor: maxZoom) - } -} diff --git a/RemoteCamTests/CaptureIntegrationTests.swift b/RemoteCamTests/CaptureIntegrationTests.swift index 697df576..72c056c9 100644 --- a/RemoteCamTests/CaptureIntegrationTests.swift +++ b/RemoteCamTests/CaptureIntegrationTests.swift @@ -441,7 +441,7 @@ final class CaptureIntegrationTests: XCTestCase { guard devices.count >= 2 else { throw XCTSkip("needs two selectable cameras to flip between") } let before = await rig.currentCameraDevice() let state = try await rig.setExposure(ExposureIntent.manual(durationSeconds: 1.0 / 250, iso: 0)) - guard state.mode == .manual else { throw XCTSkip("no device here accepts custom exposure") } + guard state.exposure?.mode == .manual else { throw XCTSkip("no device here accepts custom exposure") } _ = try await rig.toggleCamera() let after = await rig.currentCameraDevice() @@ -468,15 +468,15 @@ final class CaptureIntegrationTests: XCTestCase { let wanted = 1.0 / 250 let state = try await rig.setExposure(ExposureIntent.manual(durationSeconds: wanted, iso: device.activeFormat.minISO * 2)) - XCTAssertEqual(state.mode, .manual) - XCTAssertEqual(state.durationSeconds, wanted, accuracy: wanted * 0.1) + XCTAssertEqual(state.exposure?.mode, .manual) + XCTAssertEqual(state.exposure?.durationSeconds ?? 0, wanted, accuracy: wanted * 0.1) XCTAssertEqual(device.exposureMode, .custom) // A long shutter may legitimately stretch the frame duration in photo // mode; Auto must bring the frame rate back to what quality chose. _ = try await rig.setExposure(ExposureIntent.manual(durationSeconds: 0.5, iso: 0)) let restored = try await rig.setExposure(ExposureIntent.auto) - XCTAssertEqual(restored.mode, .auto) + XCTAssertEqual(restored.exposure?.mode, .auto) XCTAssertEqual(device.exposureMode, .continuousAutoExposure) XCTAssertEqual(CMTimeGetSeconds(device.activeVideoMaxFrameDuration), CMTimeGetSeconds(fpsBefore), accuracy: 0.001) diff --git a/RemoteCamTests/CaptureSyncMetadataTests.swift b/RemoteCamTests/CaptureSyncMetadataTests.swift index 88334893..e9ca9d19 100644 --- a/RemoteCamTests/CaptureSyncMetadataTests.swift +++ b/RemoteCamTests/CaptureSyncMetadataTests.swift @@ -111,8 +111,7 @@ final class CaptureSyncMetadataTests: XCTestCase { func testCapabilitiesDefaultToNoMulticam() { let resp = RemoteCmd.CameraCapabilitiesResp( frontCamera: nil, backCamera: nil, - currentCamera: .back, currentLens: .wideAngle, - currentZoom: 1.0, error: nil) + currentCamera: .back, error: nil) XCTAssertFalse(resp.supportsMulticam) } } diff --git a/RemoteCamTests/ControlStateTests.swift b/RemoteCamTests/ControlStateTests.swift new file mode 100644 index 00000000..8de67ade --- /dev/null +++ b/RemoteCamTests/ControlStateTests.swift @@ -0,0 +1,113 @@ +// +// ControlStateTests.swift +// RemoteShutterTests +// +// The pure core of the v11 control plane: the `absorb` fold, and the +// derivations remotes render (`zoomScale`, capability = presence). No wire, +// no engine — this is the maths every consumer trusts. +// + +import XCTest +@testable import RemoteShutter + +final class ControlStateTests: XCTestCase { + + private func snapshot(seq: UInt64, + minZoom: CGFloat = 1.0, + maxZoom: CGFloat = 10.0, + stops: [CGFloat] = [1.0, 2.0, 6.0], + wide: CGFloat = 2.0, + exposure: ExposureState? = nil, + cinematic: CinematicState? = nil) -> ControlState { + ControlState(seq: seq, + zoomFactor: 1.0, + minZoom: minZoom, maxZoom: maxZoom, + zoomStops: stops, wideAngleZoomFactor: wide, + exposure: exposure, cinematic: cinematic) + } + + // MARK: - absorb: the one write + + func testAbsorbTakesTheFirstSnapshotWhenNothingStored() { + let incoming = snapshot(seq: 5) + XCTAssertEqual(ControlState.absorb(nil, incoming), incoming) + } + + func testAbsorbKeepsTheNewerSnapshot() { + let old = snapshot(seq: 5, maxZoom: 10) + let new = snapshot(seq: 6, maxZoom: 3) + XCTAssertEqual(ControlState.absorb(old, new), new) + } + + func testAbsorbDropsAStaleSnapshot() { + let current = snapshot(seq: 9, maxZoom: 3) + let stale = snapshot(seq: 4, maxZoom: 10) + XCTAssertEqual(ControlState.absorb(current, stale), current, + "a delayed/reordered older snapshot must never overwrite fresher truth") + } + + func testAbsorbPrefersIncomingOnEqualSeq() { + // Equal seq means "same generation, re-sent" — take the incoming copy, + // never a wedge that could ignore a re-push. + let current = snapshot(seq: 7, maxZoom: 10) + let resent = snapshot(seq: 7, maxZoom: 3) + XCTAssertEqual(ControlState.absorb(current, resent), resent) + } + + // MARK: - zoomScale derivation (Cinematic narrows; display cap) + + func testZoomScaleFloorNarrowsStopsUnderCinematic() { + // Cinematic restricts zoom to [3, 6]; stops below the floor drop out, + // so the pill can never offer a factor the camera would reject. + let scale = snapshot(seq: 1, minZoom: 3, maxZoom: 6, stops: [1.0, 2.0, 6.0], wide: 2.0).zoomScale + XCTAssertEqual(scale.minZoom, 3) + XCTAssertFalse(scale.stops.contains(1.0), "the 1× stop is below the Cinematic floor") + XCTAssertFalse(scale.stops.contains(2.0), "the 2× stop is below the Cinematic floor") + XCTAssertTrue(scale.stops.contains(6.0)) + } + + func testZoomScaleWideRangeKeepsEveryStop() { + let scale = snapshot(seq: 1, minZoom: 1, maxZoom: 10, stops: [1.0, 2.0, 6.0], wide: 2.0).zoomScale + XCTAssertEqual(scale.minZoom, 1) + XCTAssertEqual(Set(scale.stops), Set([1.0, 2.0, 6.0])) + } + + func testZoomScaleCapsRunawayMaxAtFiveTimesWide() { + // Display zoom tops out at 5× the wide-angle reference (hardware 2.0), + // so a huge digital-zoom ceiling never leaks into the pill. + let scale = snapshot(seq: 1, minZoom: 1, maxZoom: 100, stops: [1.0, 2.0], wide: 2.0).zoomScale + XCTAssertEqual(scale.maxZoom, ZoomScale.displayCapped(100, wideAngle: 2.0)) + XCTAssertEqual(scale.maxZoom, 10) + } + + // MARK: - capability = presence + + func testCapabilityIsPresence() { + let none = snapshot(seq: 1) + XCTAssertFalse(none.supportsManualExposure) + XCTAssertFalse(none.supportsCinematicVideo) + + let exposure = ExposureState(mode: .auto, durationSeconds: 1.0 / 120, iso: 64, + minDurationSeconds: 1.0 / 8000, maxDurationSeconds: 1, + minISO: 32, maxISO: 3200) + let cinematic = CinematicState(enabled: false, simulatedAperture: 2.0, + minSimulatedAperture: 1.4, maxSimulatedAperture: 16, + defaultSimulatedAperture: 2.0, apertureLocked: false, + notEnoughLight: false) + let full = snapshot(seq: 2, exposure: exposure, cinematic: cinematic) + XCTAssertTrue(full.supportsManualExposure) + XCTAssertTrue(full.supportsCinematicVideo) + } + + // MARK: - refusal messaging + + func testRefusalMessageAppendsDetailWhenPresent() { + XCTAssertEqual(ControlRefusalReason.photoMode.message(detail: nil), + "Switch to video mode for Cinematic") + let withDetail = ControlRefusalReason.sessionRefused.message(detail: "Back Camera; 1920x1080") + XCTAssertTrue(withDetail.contains("Back Camera; 1920x1080")) + XCTAssertTrue(withDetail.hasPrefix("The camera refused that setting")) + XCTAssertEqual(ControlRefusalReason.sessionRefused.message(detail: ""), + "The camera refused that setting", "an empty detail adds no parens") + } +} diff --git a/RemoteCamTests/LoopbackSessionTests.swift b/RemoteCamTests/LoopbackSessionTests.swift index d9a73f7a..8e76b50e 100644 --- a/RemoteCamTests/LoopbackSessionTests.swift +++ b/RemoteCamTests/LoopbackSessionTests.swift @@ -410,7 +410,10 @@ class LoopbackSessionTests: XCTestCase { let sentZooms = monitorTransport.sentMessages.compactMap { $0 as? RemoteCmd.SetZoom } XCTAssertEqual(sentZooms.count, 1) XCTAssertEqual(sentZooms[0].zoomFactor, 2.5, accuracy: 0.001) - XCTAssertTrue(cameraTransport.sentMessages.contains { $0 is RemoteCmd.SetZoomResp }) + // A peer with no camera has no snapshot to answer with: no + // ControlStateChanged comes back, the monitor keeps its last truth, + // and nothing hangs — the next snapshot self-heals the pill. + XCTAssertFalse(cameraTransport.sentMessages.contains { $0 is RemoteCmd.ControlStateChanged }) let monitorState = await monitorCoordinator.currentStateName() XCTAssertEqual(monitorState, .monitor) } @@ -515,7 +518,8 @@ class LoopbackSessionTests: XCTestCase { XCTAssertNil(resps.first?.error) XCTAssertNotNil(resps.first?.cameraCapabilities, "toggle response must carry fresh capabilities") - XCTAssertEqual(resps.first?.cameraCapabilities?.currentLens, .wideAngle) + XCTAssertNotNil(resps.first?.cameraCapabilities?.control, + "the control seed rides in the refreshed capabilities") let monitorState = await monitorCoordinator.currentStateName() XCTAssertEqual(monitorState, .monitor) } @@ -533,7 +537,7 @@ class LoopbackSessionTests: XCTestCase { let resps = cameraTransport.sentMessages.compactMap { $0 as? RemoteCmd.SelectCameraDeviceResp } XCTAssertEqual(resps.count, 1) XCTAssertNil(resps.first?.error) - XCTAssertEqual(resps.first?.cameraCapabilities?.activeDeviceID, "fake-front") + XCTAssertEqual(resps.first?.cameraCapabilities?.control?.activeDeviceID, "fake-front") XCTAssertEqual(resps.first?.cameraCapabilities?.cameraDevices.count, 2) XCTAssertEqual( resps.first?.cameraCapabilities?.cameraDevices.first { $0.isActive }?.uniqueID, @@ -680,10 +684,13 @@ class LoopbackSessionTests: XCTestCase { await drainBothSessions() XCTAssertEqual(fakeCamera.zoomCalls, [2.5]) - let resps = cameraTransport.sentMessages.compactMap { $0 as? RemoteCmd.SetZoomResp } - XCTAssertEqual(resps.count, 1) - XCTAssertEqual(resps.first?.zoomFactor ?? 0, 2.5, accuracy: 0.001) - XCTAssertEqual(resps.first?.zoomRange?.maxZoom ?? 0, 10, accuracy: 0.001) + // The camera answers with the whole control snapshot, not a bespoke + // zoom response: the applied factor and the honorable range in one value. + let resps = cameraTransport.sentMessages.compactMap { $0 as? RemoteCmd.ControlStateChanged } + XCTAssertFalse(resps.isEmpty) + XCTAssertEqual(resps.last?.state.zoomFactor ?? 0, 2.5, accuracy: 0.001) + XCTAssertEqual(resps.last?.state.maxZoom ?? 0, 10, accuracy: 0.001) + XCTAssertNil(resps.last?.refusal) let monitorState = await monitorCoordinator.currentStateName() XCTAssertEqual(monitorState, .monitor) } @@ -721,18 +728,18 @@ class LoopbackSessionTests: XCTestCase { XCTAssertTrue(fakeCamera.takePictureCalls.isEmpty) // The camera echoes its truth; the monitor stays put (a setting, not a // request state that could wedge the screen). - let resp = cameraTransport.sentMessages.compactMap { $0 as? RemoteCmd.SetExposureResp }.last - XCTAssertEqual(resp?.state?.mode, .manual) - XCTAssertEqual(resp?.state?.durationSeconds ?? 0, 1.0 / 250, accuracy: 1e-9) - XCTAssertEqual(resp?.state?.iso ?? 0, 400) + let resp = cameraTransport.sentMessages.compactMap { $0 as? RemoteCmd.ControlStateChanged }.last + XCTAssertEqual(resp?.state.exposure?.mode, .manual) + XCTAssertEqual(resp?.state.exposure?.durationSeconds ?? 0, 1.0 / 250, accuracy: 1e-9) + XCTAssertEqual(resp?.state.exposure?.iso ?? 0, 400) let monitorState = await monitorCoordinator.currentStateName() XCTAssertEqual(monitorState, .monitor) monitorCoordinator.tell(UICmd.SetExposure(intent: .auto)) await drainBothSessions() XCTAssertEqual(fakeCamera.exposureCalls.last, .auto) - let autoResp = cameraTransport.sentMessages.compactMap { $0 as? RemoteCmd.SetExposureResp }.last - XCTAssertEqual(autoResp?.state?.mode, .auto) + let autoResp = cameraTransport.sentMessages.compactMap { $0 as? RemoteCmd.ControlStateChanged }.last + XCTAssertEqual(autoResp?.state.exposure?.mode, .auto) } /// Mirrors the focus gate: a camera whose active device cannot do custom @@ -770,19 +777,22 @@ class LoopbackSessionTests: XCTestCase { await drainBothSessions() XCTAssertEqual(fakeCamera.cinematicCalls, [.on(aperture: 2.8)]) - let resp = cameraTransport.sentMessages.compactMap { $0 as? RemoteCmd.SetCinematicResp }.last - XCTAssertEqual(resp?.state?.enabled, true) - XCTAssertEqual(resp?.state?.simulatedAperture ?? 0, 2.8) - // Cinematic changes the zoom range: the camera re-advertises it so the - // monitor's pill re-scales (Cinematic narrows zoom; disabling widens it). - XCTAssertNotNil(cameraTransport.sentMessages.compactMap { $0 as? RemoteCmd.SetZoomResp }.last, - "enabling Cinematic must republish the zoom range") + let resp = cameraTransport.sentMessages.compactMap { $0 as? RemoteCmd.ControlStateChanged }.last + XCTAssertEqual(resp?.state.cinematic?.enabled, true) + XCTAssertEqual(resp?.state.cinematic?.simulatedAperture ?? 0, 2.8) + // The regression: Cinematic narrows the zoom band, and because the + // range travels in the SAME snapshot, the monitor's pill re-scales + // with no separate republish to forget. (Fake narrows max 10 -> 3.) + XCTAssertEqual(resp?.state.maxZoom ?? 0, 3.0, accuracy: 0.001, + "enabling Cinematic must hand the monitor the narrowed zoom range") monitorCoordinator.tell(UICmd.SetCinematic(intent: .off)) await drainBothSessions() XCTAssertEqual(fakeCamera.cinematicCalls.last, .off) - let offResp = cameraTransport.sentMessages.compactMap { $0 as? RemoteCmd.SetCinematicResp }.last - XCTAssertEqual(offResp?.state?.enabled, false) + let offResp = cameraTransport.sentMessages.compactMap { $0 as? RemoteCmd.ControlStateChanged }.last + XCTAssertEqual(offResp?.state.cinematic?.enabled, false) + XCTAssertEqual(offResp?.state.maxZoom ?? 0, 10.0, accuracy: 0.001, + "disabling Cinematic restores the full zoom range") let monitorState = await monitorCoordinator.currentStateName() XCTAssertEqual(monitorState, .monitor) } @@ -842,10 +852,10 @@ class LoopbackSessionTests: XCTestCase { await drainBothSessions() XCTAssertEqual(fakeCamera.lensSwitches, [.telephoto]) - let resps = cameraTransport.sentMessages.compactMap { $0 as? RemoteCmd.SwitchLensResp } - XCTAssertEqual(resps.count, 1) - XCTAssertEqual(resps.first?.lensType, .telephoto) - XCTAssertNil(resps.first?.error) + let resps = cameraTransport.sentMessages.compactMap { $0 as? RemoteCmd.ControlStateChanged } + XCTAssertFalse(resps.isEmpty) + XCTAssertEqual(resps.last?.state.currentLens, .telephoto) + XCTAssertNil(resps.last?.refusal) // Response unbecomes monitorSwitchingLens back to photo mode. let monitorState = await monitorCoordinator.currentStateName() XCTAssertEqual(monitorState, .monitor) diff --git a/RemoteCamTests/MessageDumpTests.swift b/RemoteCamTests/MessageDumpTests.swift index 1a54f420..c6da8b54 100644 --- a/RemoteCamTests/MessageDumpTests.swift +++ b/RemoteCamTests/MessageDumpTests.swift @@ -6,25 +6,21 @@ import XCTest /// matter most (capabilities, pro-control intents), not an exact layout. final class MessageDumpTests: XCTestCase { - func testCapabilitiesShowProControlFieldsAndNestedState() { - let caps = RemoteCmd.CameraCapabilitiesResp( - frontCamera: nil, backCamera: nil, currentCamera: .back, - currentLens: .wideAngle, currentZoom: 1.0, - supportsManualExposure: true, + func testControlStateChangedShowsNestedSnapshotFields() { + // The v11 truth channel: one snapshot with the exposure nested inside. + let control = ControlState( + seq: 7, currentLens: .wideAngle, zoomFactor: 1.0, exposure: ExposureState(mode: .manual, durationSeconds: 1.0 / 125, iso: 400, minDurationSeconds: 1.0 / 8000, maxDurationSeconds: 1, - minISO: 32, maxISO: 3200), - supportsCinematicVideo: false, cinematic: nil, error: nil) + minISO: 32, maxISO: 3200)) + let dump = MessageDump.describe(RemoteCmd.ControlStateChanged(state: control)) - let dump = MessageDump.describe(caps) - XCTAssertTrue(dump.contains("supportsManualExposure: true"), dump) - XCTAssertTrue(dump.contains("supportsCinematicVideo: false"), dump) - XCTAssertTrue(dump.contains("exposure:\n"), "nested state opens its own block\n\(dump)") - XCTAssertTrue(dump.contains(" mode: manual"), dump) - XCTAssertTrue(dump.contains(" iso: 400.0"), dump) + XCTAssertTrue(dump.contains("state:"), dump) + XCTAssertTrue(dump.contains("seq: 7"), dump) + XCTAssertTrue(dump.contains("mode: manual"), "the nested exposure opens its own block\n\(dump)") + XCTAssertTrue(dump.contains("iso: 400.0"), dump) XCTAssertTrue(dump.contains("(1/125)"), "shutter reads as a fraction\n\(dump)") XCTAssertTrue(dump.contains("cinematic: nil"), dump) - XCTAssertTrue(dump.contains("error: nil"), dump) XCTAssertFalse(dump.contains("sender"), "plumbing is not a field\n\(dump)") } @@ -38,7 +34,6 @@ final class MessageDumpTests: XCTestCase { func testArraysListCountThenElements() { let caps = RemoteCmd.CameraCapabilitiesResp( frontCamera: nil, backCamera: nil, currentCamera: .back, - currentLens: .wideAngle, currentZoom: 1.0, cameraDevices: [RemoteCmd.CameraDeviceEntry(uniqueID: "id-1", localizedName: "Back Camera", positionRaw: 1, isActive: true, isSuspended: false, info: nil)], @@ -50,8 +45,9 @@ final class MessageDumpTests: XCTestCase { } func testErrorsShowDomainAndCode() { - let resp = RemoteCmd.SetExposureResp(state: nil, error: NSError(domain: "Unsupported", code: 7)) - XCTAssertEqual(MessageDump.describe(resp), "state: nil\nerror: error(Unsupported 7)") + let resp = RemoteCmd.ToggleTorchResp(torchMode: nil, error: NSError(domain: "Unsupported", code: 7)) + let dump = MessageDump.describe(resp) + XCTAssertTrue(dump.contains("error: error(Unsupported 7)"), dump) } func testMessageWithoutFields() { diff --git a/RemoteCamTests/MonitorPresenterTests.swift b/RemoteCamTests/MonitorPresenterTests.swift index 280b08d6..e8aa9f32 100644 --- a/RemoteCamTests/MonitorPresenterTests.swift +++ b/RemoteCamTests/MonitorPresenterTests.swift @@ -17,7 +17,6 @@ import AVFoundation class FakeMonitorDisplay: MonitorDisplay { let viewModel = MonitorViewModel() let frameStreamReceiver = FrameStreamReceiver() - var maxZoomFactor: CGFloat = 10.0 var photoModeConfigured = 0 var videoModeConfigured = 0 @@ -26,8 +25,8 @@ class FakeMonitorDisplay: MonitorDisplay { var exits = 0 var flashModes: [AVCaptureDevice.FlashMode] = [] var torchModes: [AVCaptureDevice.TorchMode] = [] - var zoomUpdates: [(factor: CGFloat, maxFactor: CGFloat)] = [] - var lensUpdates: [(lenses: [CameraLensType], current: CameraLensType)] = [] + /// Every control snapshot the presenter applied, in order. + var controlStates: [ControlState] = [] // Mirrors the real MonitorViewController conformance (counter + the // view-model configure), so end-to-end tests can assert the screen state @@ -44,11 +43,12 @@ class FakeMonitorDisplay: MonitorDisplay { func updateTorchModeInViewModel(_ torchMode: AVCaptureDevice.TorchMode) { torchModes.append(torchMode) } - func updateZoomInViewModel(_ factor: CGFloat, maxFactor: CGFloat) { - zoomUpdates.append((factor, maxFactor)) - } - func updateLensTypesInViewModel(_ lenses: [CameraLensType], current: CameraLensType) { - lensUpdates.append((lenses, current)) + // Mirrors the real MonitorViewController: the snapshot lands in the view + // model (so `exposure`, `zoomScale`, … read back), and is recorded for + // order/count assertions. + func applyControlState(_ state: ControlState) { + controlStates.append(state) + viewModel.applyControlState(state) } } @@ -95,30 +95,17 @@ class MonitorPresenterTests: XCTestCase { // MARK: - Exposure echo - func testUpdateExposureLandsInViewModelOnMain() { + func testApplyControlStateLandsExposureInViewModelOnMain() { let state = ExposureState(mode: .manual, durationSeconds: 1.0 / 125, iso: 200, minDurationSeconds: 1.0 / 8000, maxDurationSeconds: 0.5, minISO: 50, maxISO: 1600) - presenter.updateExposure(state) - presenter.updateExposure(nil) // an errored response carries no truth: keep the last one - drain() - XCTAssertEqual(display.viewModel.exposure, state) - } - - func testCapabilitiesCarryExposureSupportAndTruth() { - let state = ExposureState(mode: .auto, durationSeconds: 1.0 / 60, iso: 100, - minDurationSeconds: 1.0 / 8000, maxDurationSeconds: 0.5, minISO: 50, maxISO: 1600) - presenter.updateCapabilities(RemoteCmd.CameraCapabilitiesResp( - frontCamera: nil, backCamera: nil, - currentCamera: .back, currentLens: .wideAngle, currentZoom: 1.0, - supportsManualExposure: true, exposure: state, error: nil)) + presenter.applyControlState(ControlState(seq: 1, exposure: state)) drain() XCTAssertTrue(display.viewModel.supportsManualExposure) XCTAssertEqual(display.viewModel.exposure, state) - // A swap to a device that can't: the control disappears. - presenter.updateCapabilities(RemoteCmd.CameraCapabilitiesResp( - frontCamera: nil, backCamera: nil, - currentCamera: .back, currentLens: .wideAngle, currentZoom: 1.0, error: nil)) + // A swap to a device that can't do it: the snapshot omits exposure and + // the control disappears — capability is presence. + presenter.applyControlState(ControlState(seq: 2)) drain() XCTAssertFalse(display.viewModel.supportsManualExposure) XCTAssertNil(display.viewModel.exposure) @@ -149,41 +136,35 @@ class MonitorPresenterTests: XCTestCase { XCTAssertTrue(display.flashModes.isEmpty) } - // MARK: - Zoom responses + // MARK: - Control snapshot: zoom + lens are one value now - func testSetZoomRespUpdatesZoom() { - presenter.updateZoom(3.0, - zoomRange: RemoteCmd.ZoomRange(minZoom: 1.0, maxZoom: 12.0), - currentLens: nil) + func testApplyControlStateDrivesZoomAndLens() { + presenter.applyControlState(ControlState( + seq: 1, + currentLens: .telephoto, + availableLenses: [.wideAngle, .telephoto], + zoomFactor: 3.0, minZoom: 1.0, maxZoom: 12.0, + zoomStops: [1.0, 3.0], wideAngleZoomFactor: 1.0)) drain() - XCTAssertEqual(display.zoomUpdates.count, 1) - XCTAssertEqual(display.zoomUpdates[0].factor, 3.0, accuracy: 0.001) - XCTAssertEqual(display.zoomUpdates[0].maxFactor, 12.0, accuracy: 0.001) + XCTAssertEqual(display.controlStates.count, 1) + XCTAssertEqual(display.viewModel.currentZoomFactor, 3.0, accuracy: 0.001) + XCTAssertEqual(display.viewModel.currentLensType, .telephoto) + XCTAssertEqual(display.viewModel.availableLensTypes, [.wideAngle, .telephoto]) + // The pill's ceiling is the snapshot's effective max (display-capped). + XCTAssertEqual(display.viewModel.zoomScale.maxZoom, 5.0, accuracy: 0.001) } - func testSetZoomRespWithoutRangeFallsBackToDisplayMax() { - presenter.updateZoom(2.0, zoomRange: nil, currentLens: nil) + /// The fold drops a stale snapshot: an out-of-order older seq never + /// overwrites fresher zoom/lens truth. + func testApplyControlStateDropsStaleSnapshot() { + presenter.applyControlState(ControlState(seq: 9, zoomFactor: 4.0, minZoom: 1.0, maxZoom: 10.0, + zoomStops: [1.0], wideAngleZoomFactor: 1.0)) + presenter.applyControlState(ControlState(seq: 4, zoomFactor: 1.0, minZoom: 1.0, maxZoom: 10.0, + zoomStops: [1.0], wideAngleZoomFactor: 1.0)) drain() - - XCTAssertEqual(display.zoomUpdates.count, 1) - XCTAssertEqual(display.zoomUpdates[0].maxFactor, display.maxZoomFactor, accuracy: 0.001) - } - - // MARK: - Lens responses - - func testSwitchLensRespUpdatesLensesAndZoom() { - presenter.updateLens(.telephoto, - availableLenses: [.wideAngle, .telephoto], - currentZoom: 2.0, - zoomRange: RemoteCmd.ZoomRange(minZoom: 1.0, maxZoom: 8.0)) - drain() - - XCTAssertEqual(display.lensUpdates.count, 1) - XCTAssertEqual(display.lensUpdates[0].current, .telephoto) - XCTAssertEqual(display.lensUpdates[0].lenses, [.wideAngle, .telephoto]) - XCTAssertEqual(display.zoomUpdates.count, 1) - XCTAssertEqual(display.zoomUpdates[0].maxFactor, 8.0, accuracy: 0.001) + XCTAssertEqual(display.viewModel.currentZoomFactor, 4.0, accuracy: 0.001, + "the older snapshot must not clobber the newer zoom") } // MARK: - Camera device list @@ -202,17 +183,15 @@ class MonitorPresenterTests: XCTestCase { isActive: false, info: nil) ] let capabilities = RemoteCmd.CameraCapabilitiesResp( - frontCamera: nil, backCamera: nil, - currentCamera: .back, currentLens: .wideAngle, currentZoom: 1.0, - cameraDevices: devices, activeDeviceID: "facetime-0", error: nil) + frontCamera: nil, backCamera: nil, currentCamera: .back, + cameraDevices: devices, + control: ControlState(seq: 1, activeDeviceID: "facetime-0"), error: nil) presenter.updateCapabilities(capabilities) drain() XCTAssertEqual(display.viewModel.remoteCameraDevices, devices) XCTAssertEqual(display.viewModel.activeRemoteDeviceID, "facetime-0") - // No front/back info: the lens/zoom sync is skipped, not crashed. - XCTAssertTrue(display.lensUpdates.isEmpty) } func testLegacyCapabilitiesClearDeviceList() { @@ -222,8 +201,7 @@ class MonitorPresenterTests: XCTestCase { positionRaw: 0, isActive: true, info: nil) ] let capabilities = RemoteCmd.CameraCapabilitiesResp( - frontCamera: nil, backCamera: nil, - currentCamera: .back, currentLens: .wideAngle, currentZoom: 1.0, + frontCamera: nil, backCamera: nil, currentCamera: .back, error: nil) presenter.updateCapabilities(capabilities) diff --git a/RemoteCamTests/MonitorScreenSnapshotTests.swift b/RemoteCamTests/MonitorScreenSnapshotTests.swift index 09865369..43aff77f 100644 --- a/RemoteCamTests/MonitorScreenSnapshotTests.swift +++ b/RemoteCamTests/MonitorScreenSnapshotTests.swift @@ -37,10 +37,13 @@ final class MonitorScreenSnapshotTests: SnapshotTestCase { private func makeConnectedModel() -> MonitorViewModel { let model = MonitorViewModel() model.frames.cameraImage = syntheticCameraFrame() - model.availableLensTypes = [.ultraWide, .wideAngle, .telephoto] - model.currentLensType = .wideAngle - model.zoomStops = [1.0, 2.0, 5.0] - model.currentZoomFactor = 1.0 + // Zoom / lens are computed off the control snapshot now — seed it. + model.applyControlState(ControlState( + seq: 1, + currentLens: .wideAngle, + availableLenses: [.ultraWide, .wideAngle, .telephoto], + zoomFactor: 1.0, minZoom: 1.0, maxZoom: 5.0, + zoomStops: [1.0, 2.0, 5.0], wideAngleZoomFactor: 1.0)) return model } diff --git a/RemoteCamTests/MulticamControllerTests.swift b/RemoteCamTests/MulticamControllerTests.swift index 7adf890f..e466efea 100644 --- a/RemoteCamTests/MulticamControllerTests.swift +++ b/RemoteCamTests/MulticamControllerTests.swift @@ -153,19 +153,19 @@ final class MulticamControllerTests: XCTestCase { /// Capabilities advertising manual exposure (and optionally Cinematic), /// with the exposure truth the panel seeds from. private func proCaps(cinematic: Bool = false) -> RemoteCmd.CameraCapabilitiesResp { + // v11: capability = presence in the control snapshot the caps carry. RemoteCmd.CameraCapabilitiesResp( - frontCamera: nil, backCamera: nil, - currentCamera: .back, currentLens: .wideAngle, currentZoom: 1.0, + frontCamera: nil, backCamera: nil, currentCamera: .back, supportsMulticam: true, - supportsManualExposure: true, - exposure: ExposureState(mode: .auto, durationSeconds: 1.0 / 120, iso: 64, - minDurationSeconds: 1.0 / 8000, maxDurationSeconds: 1, - minISO: 32, maxISO: 3200), - supportsCinematicVideo: cinematic, - cinematic: cinematic ? CinematicState(enabled: false, simulatedAperture: 2.0, - minSimulatedAperture: 1.4, maxSimulatedAperture: 16, - defaultSimulatedAperture: 2.0, - apertureLocked: false, notEnoughLight: false) : nil, + control: ControlState( + seq: 1, + exposure: ExposureState(mode: .auto, durationSeconds: 1.0 / 120, iso: 64, + minDurationSeconds: 1.0 / 8000, maxDurationSeconds: 1, + minISO: 32, maxISO: 3200), + cinematic: cinematic ? CinematicState(enabled: false, simulatedAperture: 2.0, + minSimulatedAperture: 1.4, maxSimulatedAperture: 16, + defaultSimulatedAperture: 2.0, + apertureLocked: false, notEnoughLight: false) : nil), error: nil) } @@ -201,24 +201,30 @@ final class MulticamControllerTests: XCTestCase { await controller.waitForIdle() var lane = await controller.lanesForTesting().first - XCTAssertEqual(lane?.supportsManualExposure, true) - XCTAssertEqual(lane?.exposure?.mode, .auto) - XCTAssertEqual(lane?.exposure?.iso, 64) + XCTAssertEqual(lane?.control?.supportsManualExposure, true) + XCTAssertEqual(lane?.control?.exposure?.mode, .auto) + XCTAssertEqual(lane?.control?.exposure?.iso, 64) + // A ControlStateChanged echo (newer seq) replaces the lane's truth. let applied = ExposureState(mode: .manual, durationSeconds: 1.0 / 250, iso: 400, minDurationSeconds: 1.0 / 8000, maxDurationSeconds: 1, minISO: 32, maxISO: 3200) - controller.didReceiveMessage(RemoteCmd.SetExposureResp(state: applied, error: nil), from: camA) + controller.didReceiveMessage( + RemoteCmd.ControlStateChanged(state: ControlState(seq: 10, exposure: applied)), from: camA) await controller.waitForIdle() lane = await controller.lanesForTesting().first - XCTAssertEqual(lane?.exposure, applied) + XCTAssertEqual(lane?.control?.exposure, applied) + // A refusal carries the unchanged snapshot AND a reason; the director + // says it out loud rather than letting the toggle look inert. controller.didReceiveMessage( - RemoteCmd.SetCinematicResp(state: nil, error: NSError(domain: "Cinematic needs video mode", code: 0)), - from: camA) + RemoteCmd.ControlStateChanged(state: ControlState(seq: 11, exposure: applied), + refusal: .photoMode), from: camA) await controller.waitForIdle() await pumpMainUntil { !display.transientErrors.isEmpty } - XCTAssertEqual(display.transientErrors.last, "\(camA.displayName): Cinematic needs video mode") + XCTAssertTrue(display.transientErrors.last?.contains( + ControlRefusalReason.photoMode.message(detail: nil)) ?? false, + "a refusal must surface its reason: \(display.transientErrors)") } /// The rig's mode is a setting every camera is told about (like standby @@ -315,8 +321,9 @@ final class MulticamControllerTests: XCTestCase { controller.setZoom(CGFloat(factor), on: camA) } controller.didReceiveMessage( - RemoteCmd.SetZoomResp(zoomFactor: 3.5, currentLens: .wideAngle, - zoomRange: RemoteCmd.ZoomRange(minZoom: 1, maxZoom: 8), error: nil), + RemoteCmd.ControlStateChanged(state: ControlState( + seq: 10, currentLens: .wideAngle, zoomFactor: 3.5, + minZoom: 1, maxZoom: 8, zoomStops: [1, 2], wideAngleZoomFactor: 1)), from: camA) await controller.waitForIdle() transport.sentMessages.removeAll() @@ -1288,13 +1295,12 @@ final class MulticamControllerTests: XCTestCase { previewMode: Bool = false) -> RemoteCmd.CameraCapabilitiesResp { let info = RemoteCmd.CameraInfo( availableLenses: [.wideAngle], hasFlash: true, hasTorch: torch, - zoomCapabilities: [:], supportedResolutions: Array(matrix.keys), supportedFrameRates: Array(Set(matrix.values.flatMap { $0 })), resolutionFrameRates: matrix, supportsHEIF: heif, supportsHDR: hdr) return RemoteCmd.CameraCapabilitiesResp( frontCamera: nil, backCamera: info, - currentCamera: .back, currentLens: .wideAngle, currentZoom: 1.0, + currentCamera: .back, supportsPreviewMode: previewMode, supportsMulticam: true, error: nil) } @@ -1877,7 +1883,7 @@ final class MulticamControllerTests: XCTestCase { private func multicamCaps() -> RemoteCmd.CameraCapabilitiesResp { RemoteCmd.CameraCapabilitiesResp( frontCamera: nil, backCamera: nil, - currentCamera: .back, currentLens: .wideAngle, currentZoom: 1.0, + currentCamera: .back, supportsMulticam: true, error: nil) } @@ -1885,14 +1891,13 @@ final class MulticamControllerTests: XCTestCase { /// back (`bothPositions: false`) — the flip button's enable condition. private func flipCaps(bothPositions: Bool) -> RemoteCmd.CameraCapabilitiesResp { let lens = RemoteCmd.CameraInfo( - availableLenses: [.wideAngle], hasFlash: true, hasTorch: true, - zoomCapabilities: [:], supportedResolutions: [.hd1080p], + availableLenses: [.wideAngle], hasFlash: true, hasTorch: true, supportedResolutions: [.hd1080p], supportedFrameRates: [.fps30], resolutionFrameRates: [.hd1080p: [.fps30]], supportsHEIF: false, supportsHDR: false) return RemoteCmd.CameraCapabilitiesResp( frontCamera: bothPositions ? lens : nil, backCamera: lens, - currentCamera: .back, currentLens: .wideAngle, currentZoom: 1.0, + currentCamera: .back, supportsMulticam: true, error: nil) } @@ -1974,14 +1979,17 @@ final class MulticamControllerTests: XCTestCase { private func zoomCaps(maxZoom: CGFloat, wideAngle: CGFloat = 1.0) -> RemoteCmd.CameraCapabilitiesResp { let info = RemoteCmd.CameraInfo( availableLenses: [.wideAngle], hasFlash: true, hasTorch: true, - zoomCapabilities: [.wideAngle: RemoteCmd.ZoomRange(minZoom: wideAngle, maxZoom: maxZoom)], supportedResolutions: [.hd1080p], supportedFrameRates: [.fps30], - resolutionFrameRates: [.hd1080p: [.fps30]], supportsHEIF: false, supportsHDR: false, - zoomStops: [wideAngle, wideAngle * 2], wideAngleZoomFactor: wideAngle) + resolutionFrameRates: [.hd1080p: [.fps30]], supportsHEIF: false, supportsHDR: false) + // The live zoom range rides the control snapshot, not CameraInfo. return RemoteCmd.CameraCapabilitiesResp( frontCamera: nil, backCamera: info, - currentCamera: .back, currentLens: .wideAngle, currentZoom: wideAngle, - supportsMulticam: true, error: nil) + currentCamera: .back, + supportsMulticam: true, + control: ControlState(seq: 1, zoomFactor: wideAngle, + minZoom: wideAngle, maxZoom: maxZoom, + zoomStops: [wideAngle, wideAngle * 2], wideAngleZoomFactor: wideAngle), + error: nil) } func testZoomGoesOnlyToTheFocusedCamera() async { @@ -2006,32 +2014,33 @@ final class MulticamControllerTests: XCTestCase { await controller.waitForIdle() controller.didReceiveMessage( - RemoteCmd.SetZoomResp(zoomFactor: 4.0, currentLens: .wideAngle, - zoomRange: RemoteCmd.ZoomRange(minZoom: 1, maxZoom: 6), error: nil), + RemoteCmd.ControlStateChanged(state: ControlState( + seq: 10, currentLens: .wideAngle, zoomFactor: 4.0, + minZoom: 1, maxZoom: 6, zoomStops: [1, 2], wideAngleZoomFactor: 1)), from: camA) await controller.waitForIdle() let lanes = await controller.lanesForTesting() let a = lanes.first { $0.peerID == camA } let b = lanes.first { $0.peerID == camB } - XCTAssertEqual(a?.zoomFactor, 4.0, "the responder's lane tracks the new factor") + XCTAssertEqual(a?.control?.zoomFactor, 4.0, "the responder's lane tracks the new factor") // Range 1–6 clamps to the 5×wide-angle display ceiling, like the 1:1 monitor. - XCTAssertEqual(a?.maxZoomFactor, 5.0, "its ceiling is capped at 5×wide") - XCTAssertEqual(b?.zoomFactor, 1.0, "the other lane is untouched") + XCTAssertEqual(a?.control?.zoomScale.maxZoom, 5.0, "its ceiling is capped at 5×wide") + XCTAssertEqual(b?.control?.zoomFactor, 1.0, "the other lane is untouched") } // MARK: - Tap-to-focus (focused peer only) private func focusCaps(supportsFocus: Bool) -> RemoteCmd.CameraCapabilitiesResp { let info = RemoteCmd.CameraInfo( - availableLenses: [.wideAngle], hasFlash: true, hasTorch: true, - zoomCapabilities: [:], supportedResolutions: [.hd1080p], + availableLenses: [.wideAngle], hasFlash: true, hasTorch: true, supportedResolutions: [.hd1080p], supportedFrameRates: [.fps30], resolutionFrameRates: [.hd1080p: [.fps30]], supportsHEIF: false, supportsHDR: false) return RemoteCmd.CameraCapabilitiesResp( frontCamera: nil, backCamera: info, - currentCamera: .back, currentLens: .wideAngle, currentZoom: 1.0, - supportsFocusPoint: supportsFocus, supportsMulticam: true, error: nil) + currentCamera: .back, + supportsMulticam: true, + control: ControlState(seq: 1, supportsFocusPoint: supportsFocus), error: nil) } /// The lane snapshot projects `supportsFocusPoint`, so the viewfinder can @@ -2043,8 +2052,8 @@ final class MulticamControllerTests: XCTestCase { await controller.waitForIdle() let lanes = await controller.lanesForTesting() - XCTAssertEqual(lanes.first { $0.peerID == camA }?.supportsFocusPoint, true) - XCTAssertEqual(lanes.first { $0.peerID == camB }?.supportsFocusPoint, false) + XCTAssertEqual(lanes.first { $0.peerID == camA }?.control?.supportsFocusPoint, true) + XCTAssertEqual(lanes.first { $0.peerID == camB }?.control?.supportsFocusPoint, false) } func testFocusGoesOnlyToTheFocusedCameraWithMappedCoords() async { diff --git a/RemoteCamTests/MulticamViewModelTests.swift b/RemoteCamTests/MulticamViewModelTests.swift index 82ef5391..a0017221 100644 --- a/RemoteCamTests/MulticamViewModelTests.swift +++ b/RemoteCamTests/MulticamViewModelTests.swift @@ -15,6 +15,15 @@ final class MulticamViewModelTests: XCTestCase { private let camA = MCPeerID(displayName: "CameraA") private let camB = MCPeerID(displayName: "CameraB") + /// Sample states that make a capability "present" in the lane's control + /// snapshot — capability is presence, exactly as on the wire. + private static let sampleExposure = ExposureState( + mode: .auto, durationSeconds: 1.0 / 120, iso: 64, + minDurationSeconds: 1.0 / 10_000, maxDurationSeconds: 1.0, minISO: 32, maxISO: 3200) + private static let sampleCinematic = CinematicState( + enabled: false, simulatedAperture: 2.0, minSimulatedAperture: 1.4, maxSimulatedAperture: 16, + defaultSimulatedAperture: 2.0, apertureLocked: false, notEnoughLight: false) + private func info(_ peer: MCPeerID, status: CameraLink.Status = .linked, focused: Bool = false, canFlipCamera: Bool = false, supportsFocusPoint: Bool = false, hasTorch: Bool = false, @@ -23,18 +32,23 @@ final class MulticamViewModelTests: XCTestCase { zoomFactor: CGFloat = 1.0, maxZoomFactor: CGFloat = 10.0, zoomStops: [CGFloat] = [1.0], wideAngleZoomFactor: CGFloat = 1.0, torchOn: Bool = false, flashOn: Bool = false) -> MulticamLaneInfo { - MulticamLaneInfo(peerID: peer, displayName: peer.displayName, - status: status, isFocused: focused, clockOffsetMillis: nil, - captureOutcome: nil, isRecording: false, recordingElapsedMillis: nil, - needsQualityRematch: false, - collection: .idle, canFlipCamera: canFlipCamera, - supportsFocusPoint: supportsFocusPoint, - supportsManualExposure: supportsManualExposure, exposure: nil, - supportsCinematicVideo: supportsCinematicVideo, cinematic: nil, - hasTorch: hasTorch, - zoomFactor: zoomFactor, maxZoomFactor: maxZoomFactor, - zoomStops: zoomStops, wideAngleZoomFactor: wideAngleZoomFactor, - torchOn: torchOn, flashOn: flashOn) + // v11: the lane carries ONE control snapshot; zoom / lens / exposure / + // Cinematic / focus all live in it (WP3's MulticamLaneInfo shape). + let control = ControlState( + seq: 1, + zoomFactor: zoomFactor, minZoom: 1.0, maxZoom: maxZoomFactor, + zoomStops: zoomStops, wideAngleZoomFactor: wideAngleZoomFactor, + supportsFocusPoint: supportsFocusPoint, + exposure: supportsManualExposure ? Self.sampleExposure : nil, + cinematic: supportsCinematicVideo ? Self.sampleCinematic : nil) + return MulticamLaneInfo(peerID: peer, displayName: peer.displayName, + status: status, isFocused: focused, clockOffsetMillis: nil, + captureOutcome: nil, isRecording: false, recordingElapsedMillis: nil, + needsQualityRematch: false, + collection: .idle, canFlipCamera: canFlipCamera, + control: control, + hasTorch: hasTorch, + torchOn: torchOn, flashOn: flashOn) } /// The pro tiles are a property of the FOCUSED camera: refocusing from a @@ -151,7 +165,9 @@ final class MulticamViewModelTests: XCTestCase { func testFocusedZoomPillSwapsRangeWithFocusAndHidesWhenDegenerate() { let vm = MulticamViewModel() - // Camera A: real range 1–6; Camera B: a wider 2–8 on a 2× wide-angle. + // Camera A: hardware max 6 on a 1× wide-angle — the derivation caps + // display zoom at 5× the wide reference, so the pill tops out at 5. + // Camera B: 2–8 on a 2× wide-angle (cap 10, so 8 stands). vm.apply([info(camA, focused: true, zoomFactor: 3, maxZoomFactor: 6, zoomStops: [1, 2], wideAngleZoomFactor: 1), info(camB, zoomFactor: 4, maxZoomFactor: 8, @@ -159,7 +175,7 @@ final class MulticamViewModelTests: XCTestCase { XCTAssertTrue(vm.showsFocusedZoomPill) XCTAssertEqual(vm.focusedZoomFactor, 3) - XCTAssertEqual(vm.focusedZoomScale.maxZoom, 6) + XCTAssertEqual(vm.focusedZoomScale.maxZoom, 5, "display-capped at 5× the wide reference") // Refocusing swaps the displayed range to camera B's. vm.apply([info(camA, zoomFactor: 3, maxZoomFactor: 6, zoomStops: [1, 2]), @@ -266,48 +282,6 @@ final class FocusedCameraControlStateTests: XCTestCase { } } -/// The shared zoom math both paths derive from. -final class ZoomScaleSeedTests: XCTestCase { - func testClampCapsAtFiveTimesWideAngle() { - XCTAssertEqual(ZoomScaleSeed.clampMaxZoom(8, wideAngle: 1), 5) // 5×1 ceiling - XCTAssertEqual(ZoomScaleSeed.clampMaxZoom(8, wideAngle: 2), 8) // 5×2 = 10, so 8 stands - XCTAssertEqual(ZoomScaleSeed.clampMaxZoom(20, wideAngle: 2), 10) // capped at 5×2 - } - - func testSeedReadsStopsWideAngleFactorAndClampedRange() { - let info = RemoteCmd.CameraInfo( - availableLenses: [.wideAngle], hasFlash: true, hasTorch: true, - zoomCapabilities: [.wideAngle: RemoteCmd.ZoomRange(minZoom: 1, maxZoom: 8)], - supportedResolutions: [.hd1080p], supportedFrameRates: [.fps30], - resolutionFrameRates: [.hd1080p: [.fps30]], supportsHEIF: false, supportsHDR: false, - zoomStops: [1, 2], wideAngleZoomFactor: 1) - let caps = RemoteCmd.CameraCapabilitiesResp( - frontCamera: nil, backCamera: info, - currentCamera: .back, currentLens: .wideAngle, currentZoom: 3, - supportsMulticam: true, error: nil) - - let seed = ZoomScaleSeed.seed(from: caps) - XCTAssertEqual(seed?.zoomFactor, 3) - XCTAssertEqual(seed?.zoomStops, [1, 2]) - XCTAssertEqual(seed?.wideAngleZoomFactor, 1) - XCTAssertEqual(seed?.maxZoomFactor, 5, "range 1–8 clamps to the 5×wide ceiling") - } - - func testSeedLeavesCeilingUnsetWhenNoRangeForCurrentLens() { - let info = RemoteCmd.CameraInfo( - availableLenses: [.wideAngle], hasFlash: false, hasTorch: false, - zoomCapabilities: [:], // no range advertised - supportedResolutions: [.hd1080p], supportedFrameRates: [.fps30], - resolutionFrameRates: [.hd1080p: [.fps30]], supportsHEIF: false, supportsHDR: false, - zoomStops: [1], wideAngleZoomFactor: 1) - let caps = RemoteCmd.CameraCapabilitiesResp( - frontCamera: nil, backCamera: info, - currentCamera: .back, currentLens: .wideAngle, currentZoom: 1, - supportsMulticam: true, error: nil) - XCTAssertNil(ZoomScaleSeed.seed(from: caps)?.maxZoomFactor) - } -} - /// The genuinely-shared coordinator mechanics both actors adopt. final class PeerSessionCoreTests: XCTestCase { func testOnFrameForwardingCopiesEveryField() { diff --git a/RemoteCamTests/RemoteCamSessionTests.swift b/RemoteCamTests/RemoteCamSessionTests.swift index e4fd2ba3..8e7ba1bd 100644 --- a/RemoteCamTests/RemoteCamSessionTests.swift +++ b/RemoteCamTests/RemoteCamSessionTests.swift @@ -1477,7 +1477,7 @@ class SessionCoordinatorTests: XCTestCase { let capabilities = RemoteCmd.CameraCapabilitiesResp( frontCamera: nil, backCamera: nil, - currentCamera: .back, currentLens: .wideAngle, currentZoom: 1.0, error: nil) + currentCamera: .back, error: nil) await harness.deliver(RemoteCmd.ToggleCameraResp(cameraCapabilities: capabilities, error: nil)) let name = await harness.stateName() @@ -1542,9 +1542,9 @@ class SessionCoordinatorTests: XCTestCase { func testMonitorSwitchingLensSuccessResponseUnbecomes() async { await enterMonitorSwitchingLens() - await harness.deliver(RemoteCmd.SwitchLensResp( - lensType: .telephoto, availableLenses: [.wideAngle, .telephoto], - currentZoom: 2.0, zoomRange: RemoteCmd.ZoomRange(minZoom: 1.0, maxZoom: 10.0), error: nil)) + await harness.deliver(RemoteCmd.ControlStateChanged(state: ControlState( + seq: 10, currentLens: .telephoto, availableLenses: [.wideAngle, .telephoto], + zoomFactor: 2.0, minZoom: 1.0, maxZoom: 10.0))) let name = await harness.stateName() XCTAssertEqual(name, .monitor) @@ -1553,9 +1553,9 @@ class SessionCoordinatorTests: XCTestCase { func testMonitorSwitchingLensErrorResponseUnbecomes() async { await enterMonitorSwitchingLens() - let error = NSError(domain: "LensError", code: 1, userInfo: nil) - await harness.deliver(RemoteCmd.SwitchLensResp( - lensType: nil, availableLenses: nil, currentZoom: nil, zoomRange: nil, error: error)) + // A lens switch that could not take arrives as a refusal on the snapshot. + await harness.deliver(RemoteCmd.ControlStateChanged( + state: ControlState(seq: 10, currentLens: .wideAngle), refusal: .unsupported)) let name = await harness.stateName() XCTAssertEqual(name, .monitor) @@ -1563,12 +1563,11 @@ class SessionCoordinatorTests: XCTestCase { func testMonitorSwitchingLensNilNilResponseUnbecomes() async { await enterMonitorSwitchingLens() - await harness.deliver(RemoteCmd.SwitchLensResp( - lensType: nil, availableLenses: nil, currentZoom: nil, zoomRange: nil, error: nil)) + await harness.deliver(RemoteCmd.ControlStateChanged(state: ControlState(seq: 10))) let name = await harness.stateName() XCTAssertEqual(name, .monitor, - "State should unbecome even when both lensType and error are nil") + "State should unbecome when the control snapshot lands") } func testMonitorSwitchingLensDisconnectPeerStartsReconnecting() async { diff --git a/RemoteCamTests/RemoteCmdSerializationTests.swift b/RemoteCamTests/RemoteCmdSerializationTests.swift index 7b39a8fa..3775f5f5 100644 --- a/RemoteCamTests/RemoteCmdSerializationTests.swift +++ b/RemoteCamTests/RemoteCmdSerializationTests.swift @@ -57,17 +57,14 @@ final class RemoteCmdSerializationTests: XCTestCase { case let m as RemoteCmd.SetStreamProfile: return m.toFlatBuffer() case let m as RemoteCmd.RequestVideoResend: return m.toFlatBuffer() case let m as RemoteCmd.SetZoom: return m.toFlatBuffer() - case let m as RemoteCmd.SetZoomResp: return m.toFlatBuffer() case let m as RemoteCmd.FocusAtPoint: return m.toFlatBuffer() case let m as RemoteCmd.SetExposure: return m.toFlatBuffer() - case let m as RemoteCmd.SetExposureResp: return m.toFlatBuffer() + case let m as RemoteCmd.ControlStateChanged: return m.toFlatBuffer() case let m as RemoteCmd.SetCinematic: return m.toFlatBuffer() - case let m as RemoteCmd.SetCinematicResp: return m.toFlatBuffer() case let m as RemoteCmd.SetCameraPreviewMode: return m.toFlatBuffer() case let m as RemoteCmd.CameraPreviewModeResp: return m.toFlatBuffer() case let m as RemoteCmd.CameraCapabilitiesResp: return m.toFlatBuffer() case let m as RemoteCmd.SwitchLens: return m.toFlatBuffer() - case let m as RemoteCmd.SwitchLensResp: return m.toFlatBuffer() case let m as RemoteCmd.PeerBecameCamera: return m.toFlatBuffer() case let m as RemoteCmd.PeerBecameMonitor: return m.toFlatBuffer() case let m as RemoteCmd.ToggleFlash: return m.toFlatBuffer() @@ -355,12 +352,12 @@ final class RemoteCmdSerializationTests: XCTestCase { } func testCameraCapabilities_supportsFocusPointRoundTrip() { + // Focus-point support now rides the control snapshot the caps carry. let original = RemoteCmd.CameraCapabilitiesResp( - frontCamera: nil, backCamera: nil, - currentCamera: .back, currentLens: .wideAngle, currentZoom: 1.0, - supportsFocusPoint: true, error: nil) + frontCamera: nil, backCamera: nil, currentCamera: .back, + control: ControlState(seq: 1, supportsFocusPoint: true), error: nil) let decoded: RemoteCmd.CameraCapabilitiesResp = roundTrip(original) - XCTAssertTrue(decoded.supportsFocusPoint) + XCTAssertEqual(decoded.control?.supportsFocusPoint, true) } // MARK: - 11c. SetExposure @@ -380,38 +377,26 @@ final class RemoteCmdSerializationTests: XCTestCase { XCTAssertEqual(decoded.intent, .auto) } - func testSetExposureResp_roundTrip() { - let decoded: RemoteCmd.SetExposureResp = roundTrip(RemoteCmd.SetExposureResp(state: sampleExposure, error: nil)) - XCTAssertEqual(decoded.state, sampleExposure) - XCTAssertNil(decoded.error) - } - - func testSetExposureResp_errorRoundTrip() { - let err = NSError(domain: "No camera device available", code: 0) - let decoded: RemoteCmd.SetExposureResp = roundTrip(RemoteCmd.SetExposureResp(state: nil, error: err)) - XCTAssertNil(decoded.state) - XCTAssertEqual((decoded.error as NSError?)?.domain, "No camera device available") - } - func testCameraCapabilities_manualExposureRoundTrip() { let original = RemoteCmd.CameraCapabilitiesResp( - frontCamera: nil, backCamera: nil, - currentCamera: .back, currentLens: .wideAngle, currentZoom: 1.0, - supportsManualExposure: true, exposure: sampleExposure, error: nil) + frontCamera: nil, backCamera: nil, currentCamera: .back, + control: ControlState(seq: 1, exposure: sampleExposure), error: nil) let decoded: RemoteCmd.CameraCapabilitiesResp = roundTrip(original) - XCTAssertTrue(decoded.supportsManualExposure) - XCTAssertEqual(decoded.exposure, sampleExposure) + XCTAssertEqual(decoded.control?.supportsManualExposure, true) + XCTAssertEqual(decoded.control?.exposure, sampleExposure) } /// A peer that predates exposure control leaves the fields absent: the /// monitor must read "no support, no truth", never a fabricated Auto. - func testCameraCapabilities_legacyPeerHasNoExposure() { + func testCameraCapabilities_noExposureWhenControlOmitsIt() { + // A device without manual exposure carries a control snapshot whose + // `exposure` is absent — capability is presence, never a boolean. let original = RemoteCmd.CameraCapabilitiesResp( - frontCamera: nil, backCamera: nil, - currentCamera: .back, currentLens: .wideAngle, currentZoom: 1.0, error: nil) + frontCamera: nil, backCamera: nil, currentCamera: .back, + control: ControlState(seq: 1), error: nil) let decoded: RemoteCmd.CameraCapabilitiesResp = roundTrip(original) - XCTAssertFalse(decoded.supportsManualExposure) - XCTAssertNil(decoded.exposure) + XCTAssertEqual(decoded.control?.supportsManualExposure, false) + XCTAssertNil(decoded.control?.exposure) } // MARK: - 11d. SetCinematic @@ -431,67 +416,71 @@ final class RemoteCmdSerializationTests: XCTestCase { XCTAssertEqual(off.intent, .off) } - func testSetCinematicResp_roundTrip() { - let decoded: RemoteCmd.SetCinematicResp = roundTrip(RemoteCmd.SetCinematicResp(state: sampleCinematic, error: nil)) - XCTAssertEqual(decoded.state, sampleCinematic) - XCTAssertNil(decoded.error) - } - func testCameraCapabilities_cinematicRoundTrip() { let original = RemoteCmd.CameraCapabilitiesResp( - frontCamera: nil, backCamera: nil, - currentCamera: .back, currentLens: .wideAngle, currentZoom: 1.0, - supportsCinematicVideo: true, cinematic: sampleCinematic, error: nil) + frontCamera: nil, backCamera: nil, currentCamera: .back, + control: ControlState(seq: 1, cinematic: sampleCinematic), error: nil) let decoded: RemoteCmd.CameraCapabilitiesResp = roundTrip(original) - XCTAssertTrue(decoded.supportsCinematicVideo) - XCTAssertEqual(decoded.cinematic, sampleCinematic) - // Absent on legacy peers. - let legacy: RemoteCmd.CameraCapabilitiesResp = roundTrip(RemoteCmd.CameraCapabilitiesResp( - frontCamera: nil, backCamera: nil, - currentCamera: .back, currentLens: .wideAngle, currentZoom: 1.0, error: nil)) - XCTAssertFalse(legacy.supportsCinematicVideo) - XCTAssertNil(legacy.cinematic) - } - - // MARK: - 12. SetZoomResp - - func testSetZoomResp_roundTrip() { - let range = RemoteCmd.ZoomRange(minZoom: 1.0, maxZoom: 10.0) - let original = RemoteCmd.SetZoomResp( - zoomFactor: 3.0, - currentLens: .telephoto, - zoomRange: range, - error: nil - ) - let decoded: RemoteCmd.SetZoomResp = roundTrip(original) - XCTAssertEqual(decoded.zoomFactor!, 3.0, accuracy: 0.001) - XCTAssertEqual(decoded.currentLens, .telephoto) - XCTAssertEqual(decoded.zoomRange?.minZoom, 1.0) - XCTAssertEqual(decoded.zoomRange?.maxZoom, 10.0) - XCTAssertNil(decoded.error) - } - - func testSetZoomResp_wideAngleLens() { - let original = RemoteCmd.SetZoomResp( - zoomFactor: 1.0, - currentLens: .wideAngle, - zoomRange: RemoteCmd.ZoomRange(minZoom: 1.0, maxZoom: 5.0), - error: nil - ) - let decoded: RemoteCmd.SetZoomResp = roundTrip(original) - XCTAssertEqual(decoded.currentLens, .wideAngle, "wideAngle (rawValue 0) must survive round-trip") - XCTAssertEqual(decoded.zoomFactor!, 1.0, accuracy: 0.001) - } - - func testSetZoomResp_withError() { - let error = NSError(domain: "zoom", code: 5, userInfo: [NSLocalizedDescriptionKey: "zoom failed"]) - let original = RemoteCmd.SetZoomResp(zoomFactor: nil, currentLens: nil, zoomRange: nil, error: error) - let decoded: RemoteCmd.SetZoomResp = roundTrip(original) - XCTAssertNil(decoded.zoomFactor) - XCTAssertNil(decoded.currentLens) - XCTAssertNil(decoded.zoomRange) - XCTAssertNotNil(decoded.error) - XCTAssertEqual(decoded.error?.localizedDescription, "zoom failed") + XCTAssertEqual(decoded.control?.supportsCinematicVideo, true) + XCTAssertEqual(decoded.control?.cinematic, sampleCinematic) + // Absent when the control snapshot omits it. + let none: RemoteCmd.CameraCapabilitiesResp = roundTrip(RemoteCmd.CameraCapabilitiesResp( + frontCamera: nil, backCamera: nil, currentCamera: .back, + control: ControlState(seq: 1), error: nil)) + XCTAssertEqual(none.control?.supportsCinematicVideo, false) + XCTAssertNil(none.control?.cinematic) + } + + // MARK: - 12. ControlStateChanged (the control-plane truth channel) + + private let fullControl = ControlState( + seq: 12, + mode: .Video, + activeDeviceID: "back-triple", + currentLens: .telephoto, + availableLenses: [.wideAngle, .ultraWide, .telephoto], + zoomFactor: 3.0, minZoom: 1.0, maxZoom: 10.0, + zoomStops: [1.0, 2.0, 6.0], wideAngleZoomFactor: 2.0, + supportsFocusPoint: true, + exposure: ExposureState(mode: .manual, durationSeconds: 1.0 / 250, iso: 400, + minDurationSeconds: 1.0 / 10_000, maxDurationSeconds: 1.0, + minISO: 32, maxISO: 3200), + cinematic: CinematicState(enabled: true, simulatedAperture: 2.8, + minSimulatedAperture: 1.4, maxSimulatedAperture: 16, + defaultSimulatedAperture: 2.0, apertureLocked: true, + notEnoughLight: true)) + + func testControlStateChanged_fullSnapshotRoundTrip() { + let decoded: RemoteCmd.ControlStateChanged = roundTrip(RemoteCmd.ControlStateChanged(state: fullControl)) + XCTAssertEqual(decoded.state, fullControl) + // A clean apply carries no refusal. + XCTAssertNil(decoded.refusal) + XCTAssertNil(decoded.refusalDetail) + } + + func testControlStateChanged_omittedCapabilitiesStayNil() { + // wideAngle rawValue 0 and an exposure/cinematic-free device must + // survive: capability is presence, so the fields decode back to nil. + let bare = ControlState(seq: 3, currentLens: .wideAngle, + zoomFactor: 1.0, minZoom: 1.0, maxZoom: 5.0, + zoomStops: [1.0], wideAngleZoomFactor: 1.0) + let decoded: RemoteCmd.ControlStateChanged = roundTrip(RemoteCmd.ControlStateChanged(state: bare)) + XCTAssertEqual(decoded.state.currentLens, .wideAngle) + XCTAssertNil(decoded.state.exposure) + XCTAssertNil(decoded.state.cinematic) + XCTAssertFalse(decoded.state.supportsManualExposure) + } + + func testControlStateChanged_eachRefusalReasonRoundTrips() { + for reason in [ControlRefusalReason.photoMode, .recording, .unsupported, .sessionRefused] { + let decoded: RemoteCmd.ControlStateChanged = roundTrip( + RemoteCmd.ControlStateChanged(state: fullControl, refusal: reason, + refusalDetail: "Back Camera; 1920x1080")) + XCTAssertEqual(decoded.refusal, reason, "refusal \(reason) must survive the wire") + XCTAssertEqual(decoded.refusalDetail, "Back Camera; 1920x1080") + // The snapshot is carried even on refusal — it is the truth to show. + XCTAssertEqual(decoded.state, fullControl) + } } // MARK: - 13. CameraCapabilitiesResp @@ -500,24 +489,18 @@ final class RemoteCmdSerializationTests: XCTestCase { let backCamera = RemoteCmd.CameraInfo( availableLenses: [.wideAngle, .ultraWide, .telephoto], hasFlash: true, - hasTorch: true, - zoomCapabilities: [ - .wideAngle: RemoteCmd.ZoomRange(minZoom: 1.0, maxZoom: 10.0), - .ultraWide: RemoteCmd.ZoomRange(minZoom: 1.0, maxZoom: 2.0) - ] + hasTorch: true ) let frontCamera = RemoteCmd.CameraInfo( availableLenses: [.wideAngle], hasFlash: false, - hasTorch: false, - zoomCapabilities: [.wideAngle: RemoteCmd.ZoomRange(minZoom: 1.0, maxZoom: 5.0)] + hasTorch: false ) let original = RemoteCmd.CameraCapabilitiesResp( frontCamera: frontCamera, backCamera: backCamera, currentCamera: .back, - currentLens: .wideAngle, - currentZoom: 2.5, + control: ControlState(seq: 1, currentLens: .wideAngle, zoomFactor: 2.5), error: nil ) let decoded: RemoteCmd.CameraCapabilitiesResp = roundTrip(original) @@ -529,8 +512,8 @@ final class RemoteCmdSerializationTests: XCTestCase { XCTAssertEqual(decoded.frontCamera?.availableLenses.count, 1) XCTAssertFalse(decoded.frontCamera?.hasFlash ?? true) XCTAssertEqual(decoded.currentCamera, .back) - XCTAssertEqual(decoded.currentLens, .wideAngle) - XCTAssertEqual(decoded.currentZoom, 2.5, accuracy: 0.001) + XCTAssertEqual(decoded.control?.currentLens, .wideAngle) + XCTAssertEqual(decoded.control?.zoomFactor ?? 0, 2.5, accuracy: 0.001) XCTAssertNil(decoded.error) } @@ -539,15 +522,14 @@ final class RemoteCmdSerializationTests: XCTestCase { frontCamera: nil, backCamera: nil, currentCamera: .front, - currentLens: .ultraWide, - currentZoom: 1.0, + control: ControlState(seq: 1, currentLens: .ultraWide), error: nil ) let decoded: RemoteCmd.CameraCapabilitiesResp = roundTrip(original) XCTAssertNil(decoded.frontCamera) XCTAssertNil(decoded.backCamera) XCTAssertEqual(decoded.currentCamera, .front) - XCTAssertEqual(decoded.currentLens, .ultraWide) + XCTAssertEqual(decoded.control?.currentLens, .ultraWide) } // MARK: - 13a. Camera state report (the recording-truth channel) @@ -586,8 +568,7 @@ final class RemoteCmdSerializationTests: XCTestCase { let usbInfo = RemoteCmd.CameraInfo( availableLenses: [.wideAngle], hasFlash: false, - hasTorch: false, - zoomCapabilities: [.wideAngle: RemoteCmd.ZoomRange(minZoom: 1.0, maxZoom: 1.0)] + hasTorch: false ) let devices = [ RemoteCmd.CameraDeviceEntry( @@ -601,14 +582,14 @@ final class RemoteCmdSerializationTests: XCTestCase { ] let capabilities = RemoteCmd.CameraCapabilitiesResp( frontCamera: nil, backCamera: nil, - currentCamera: .back, currentLens: .wideAngle, currentZoom: 1.0, - cameraDevices: devices, activeDeviceID: "usb-0", error: nil) + currentCamera: .back, cameraDevices: devices, + control: ControlState(seq: 1, activeDeviceID: "usb-0"), error: nil) let original = RemoteCmd.SelectCameraDeviceResp(cameraCapabilities: capabilities, error: nil) let decoded: RemoteCmd.SelectCameraDeviceResp = roundTrip(original) let decodedCaps = decoded.cameraCapabilities XCTAssertNil(decoded.error) - XCTAssertEqual(decodedCaps?.activeDeviceID, "usb-0") + XCTAssertEqual(decodedCaps?.control?.activeDeviceID, "usb-0") XCTAssertEqual(decodedCaps?.cameraDevices.count, 2) XCTAssertEqual(decodedCaps?.cameraDevices[0].uniqueID, "builtin-0") XCTAssertEqual(decodedCaps?.cameraDevices[0].localizedName, "FaceTime HD Camera") @@ -633,8 +614,7 @@ final class RemoteCmdSerializationTests: XCTestCase { ] let capabilities = RemoteCmd.CameraCapabilitiesResp( frontCamera: nil, backCamera: nil, - currentCamera: .back, currentLens: .wideAngle, currentZoom: 1.0, - cameraDevices: devices, activeDeviceID: "usb-0", error: nil) + currentCamera: .back, cameraDevices: devices, error: nil) let original = RemoteCmd.SelectCameraDeviceResp(cameraCapabilities: capabilities, error: nil) let decoded: RemoteCmd.SelectCameraDeviceResp = roundTrip(original) @@ -656,11 +636,10 @@ final class RemoteCmdSerializationTests: XCTestCase { // the decoded list must be empty (the monitor's gate stays closed). let original = RemoteCmd.CameraCapabilitiesResp( frontCamera: nil, backCamera: nil, - currentCamera: .back, currentLens: .wideAngle, currentZoom: 1.0, - error: nil) + currentCamera: .back, error: nil) let decoded: RemoteCmd.CameraCapabilitiesResp = roundTrip(original) XCTAssertTrue(decoded.cameraDevices.isEmpty) - XCTAssertNil(decoded.activeDeviceID) + XCTAssertNil(decoded.control?.activeDeviceID) } func testCameraCapabilitiesResp_deviceListRoundTrip() { @@ -672,15 +651,15 @@ final class RemoteCmdSerializationTests: XCTestCase { ] let original = RemoteCmd.CameraCapabilitiesResp( frontCamera: nil, backCamera: nil, - currentCamera: .back, currentLens: .wideAngle, currentZoom: 1.0, - cameraDevices: devices, activeDeviceID: "back-0", error: nil) + currentCamera: .back, cameraDevices: devices, + control: ControlState(seq: 1, activeDeviceID: "back-0"), error: nil) let decoded: RemoteCmd.CameraCapabilitiesResp = roundTrip(original) XCTAssertEqual(decoded.cameraDevices, devices.map { RemoteCmd.CameraDeviceEntry( uniqueID: $0.uniqueID, localizedName: $0.localizedName, positionRaw: $0.positionRaw, isActive: $0.isActive, info: nil) }) - XCTAssertEqual(decoded.activeDeviceID, "back-0") + XCTAssertEqual(decoded.control?.activeDeviceID, "back-0") } // MARK: - 14. SwitchLens @@ -697,57 +676,6 @@ final class RemoteCmdSerializationTests: XCTestCase { XCTAssertEqual(decoded.lensType, .wideAngle) } - // MARK: - 15. SwitchLensResp - - func testSwitchLensResp_roundTrip() { - let range = RemoteCmd.ZoomRange(minZoom: 1.0, maxZoom: 8.0) - let original = RemoteCmd.SwitchLensResp( - lensType: .ultraWide, - availableLenses: [.wideAngle, .ultraWide, .telephoto], - currentZoom: 1.5, - zoomRange: range, - error: nil - ) - let decoded: RemoteCmd.SwitchLensResp = roundTrip(original) - XCTAssertEqual(decoded.lensType, .ultraWide) - XCTAssertEqual(decoded.availableLenses?.count, 3) - XCTAssertEqual(decoded.currentZoom!, 1.5, accuracy: 0.001) - XCTAssertEqual(decoded.zoomRange?.minZoom, 1.0) - XCTAssertEqual(decoded.zoomRange?.maxZoom, 8.0) - XCTAssertNil(decoded.error) - } - - func testSwitchLensResp_wideAngle() { - let original = RemoteCmd.SwitchLensResp( - lensType: .wideAngle, - availableLenses: [.wideAngle], - currentZoom: 1.0, - zoomRange: RemoteCmd.ZoomRange(minZoom: 1.0, maxZoom: 5.0), - error: nil - ) - let decoded: RemoteCmd.SwitchLensResp = roundTrip(original) - XCTAssertEqual(decoded.lensType, .wideAngle, "wideAngle (rawValue 0) should survive round-trip") - XCTAssertEqual(decoded.currentZoom!, 1.0, accuracy: 0.001) - } - - func testSwitchLensResp_withError() { - let error = NSError(domain: "lens", code: 3, userInfo: [NSLocalizedDescriptionKey: "lens switch failed"]) - let original = RemoteCmd.SwitchLensResp( - lensType: nil, - availableLenses: nil, - currentZoom: nil, - zoomRange: nil, - error: error - ) - let decoded: RemoteCmd.SwitchLensResp = roundTrip(original) - XCTAssertNil(decoded.lensType) - XCTAssertNil(decoded.availableLenses) - XCTAssertNil(decoded.currentZoom) - XCTAssertNil(decoded.zoomRange) - XCTAssertNotNil(decoded.error) - XCTAssertEqual(decoded.error?.localizedDescription, "lens switch failed") - } - // MARK: - 16. PeerBecameCamera func testPeerBecameCamera_roundTrip() { @@ -890,22 +818,18 @@ final class RemoteCmdSerializationTests: XCTestCase { let backCamera = RemoteCmd.CameraInfo( availableLenses: [.wideAngle, .telephoto], hasFlash: true, - hasTorch: true, - zoomCapabilities: [.wideAngle: RemoteCmd.ZoomRange(minZoom: 1.0, maxZoom: 10.0)] + hasTorch: true ) let capabilities = RemoteCmd.CameraCapabilitiesResp( frontCamera: nil, backCamera: backCamera, currentCamera: .back, - currentLens: .wideAngle, - currentZoom: 1.0, error: nil ) let original = RemoteCmd.ToggleCameraResp(cameraCapabilities: capabilities, error: nil) let decoded: RemoteCmd.ToggleCameraResp = roundTrip(original) XCTAssertNotNil(decoded.cameraCapabilities) XCTAssertEqual(decoded.cameraCapabilities?.currentCamera, .back) - XCTAssertEqual(decoded.cameraCapabilities?.currentLens, .wideAngle) XCTAssertEqual(decoded.cameraCapabilities?.backCamera?.availableLenses.count, 2) XCTAssertNil(decoded.error) } @@ -929,32 +853,6 @@ final class RemoteCmdSerializationTests: XCTestCase { // MARK: - Gap coverage tests - func testCameraCapabilitiesResp_zoomCapabilitiesValues() { - let backCamera = RemoteCmd.CameraInfo( - availableLenses: [.wideAngle, .ultraWide, .telephoto], - hasFlash: true, - hasTorch: true, - zoomCapabilities: [ - .wideAngle: RemoteCmd.ZoomRange(minZoom: 1.0, maxZoom: 10.0), - .ultraWide: RemoteCmd.ZoomRange(minZoom: 0.5, maxZoom: 2.0), - .telephoto: RemoteCmd.ZoomRange(minZoom: 2.0, maxZoom: 15.0) - ] - ) - let original = RemoteCmd.CameraCapabilitiesResp( - frontCamera: nil, backCamera: backCamera, - currentCamera: .back, currentLens: .telephoto, - currentZoom: 5.0, error: nil - ) - let decoded: RemoteCmd.CameraCapabilitiesResp = roundTrip(original) - let caps = decoded.backCamera!.getZoomCapabilities() - XCTAssertEqual(caps[.wideAngle]?.minZoom, 1.0) - XCTAssertEqual(caps[.wideAngle]?.maxZoom, 10.0) - XCTAssertEqual(caps[.ultraWide]?.minZoom, 0.5) - XCTAssertEqual(caps[.ultraWide]?.maxZoom, 2.0) - XCTAssertEqual(caps[.telephoto]?.minZoom, 2.0) - XCTAssertEqual(caps[.telephoto]?.maxZoom, 15.0) - } - func testToggleTorchResp_auto() { let original = RemoteCmd.ToggleTorchResp(torchMode: .auto, error: nil) let decoded: RemoteCmd.ToggleTorchResp = roundTrip(original) @@ -1002,28 +900,27 @@ final class RemoteCmdSerializationTests: XCTestCase { XCTAssertEqual(decoded.camPosition, .front) } - func testToggleCameraResp_nestedZoomCapabilities() { - let backCamera = RemoteCmd.CameraInfo( - availableLenses: [.wideAngle, .telephoto], - hasFlash: true, hasTorch: true, - zoomCapabilities: [ - .wideAngle: RemoteCmd.ZoomRange(minZoom: 1.0, maxZoom: 10.0), - .telephoto: RemoteCmd.ZoomRange(minZoom: 2.0, maxZoom: 20.0) - ] - ) + /// The capabilities envelope carries the control seed intact — the zoom + /// truth a fresh monitor boots from rides inside the toggle response. + func testToggleCameraResp_nestedControlSeed() { let capabilities = RemoteCmd.CameraCapabilitiesResp( - frontCamera: nil, backCamera: backCamera, - currentCamera: .back, currentLens: .wideAngle, - currentZoom: 3.0, error: nil + frontCamera: nil, + backCamera: RemoteCmd.CameraInfo(availableLenses: [.wideAngle, .telephoto], + hasFlash: true, hasTorch: true), + currentCamera: .back, + control: ControlState(seq: 7, activeDeviceID: "back-1", + zoomFactor: 3.0, minZoom: 1.0, maxZoom: 10.0, + zoomStops: [1.0, 2.0], wideAngleZoomFactor: 2.0), + error: nil ) let original = RemoteCmd.ToggleCameraResp(cameraCapabilities: capabilities, error: nil) let decoded: RemoteCmd.ToggleCameraResp = roundTrip(original) - let caps = decoded.cameraCapabilities!.backCamera!.getZoomCapabilities() - XCTAssertEqual(caps[.wideAngle]?.minZoom, 1.0) - XCTAssertEqual(caps[.wideAngle]?.maxZoom, 10.0) - XCTAssertEqual(caps[.telephoto]?.minZoom, 2.0) - XCTAssertEqual(caps[.telephoto]?.maxZoom, 20.0) - XCTAssertEqual(Double(decoded.cameraCapabilities?.currentZoom ?? 0), 3.0, accuracy: 0.001) + let control = decoded.cameraCapabilities?.control + XCTAssertEqual(control?.seq, 7) + XCTAssertEqual(control?.activeDeviceID, "back-1") + XCTAssertEqual(Double(control?.zoomFactor ?? 0), 3.0, accuracy: 0.001) + XCTAssertEqual(Double(control?.maxZoom ?? 0), 10.0, accuracy: 0.001) + XCTAssertEqual(control?.zoomStops, [1.0, 2.0]) } // MARK: - 26. SetVideoQuality @@ -1145,7 +1042,6 @@ final class RemoteCmdSerializationTests: XCTestCase { availableLenses: [.wideAngle], hasFlash: true, hasTorch: true, - zoomCapabilities: [.wideAngle: RemoteCmd.ZoomRange(minZoom: 1.0, maxZoom: 10.0)], supportedResolutions: [.hd1080p, .uhd4k], supportedFrameRates: [.fps24, .fps30, .fps60], resolutionFrameRates: [.uhd4k: [.fps24, .fps30]], @@ -1154,8 +1050,7 @@ final class RemoteCmdSerializationTests: XCTestCase { ) let original = RemoteCmd.CameraCapabilitiesResp( frontCamera: nil, backCamera: backCamera, - currentCamera: .back, currentLens: .wideAngle, - currentZoom: 1.0, error: nil + currentCamera: .back, error: nil ) let decoded: RemoteCmd.CameraCapabilitiesResp = roundTrip(original) let info = decoded.backCamera! @@ -1205,70 +1100,6 @@ final class RemoteCmdSerializationTests: XCTestCase { XCTAssertEqual(decoded.error?.localizedDescription, "not supported") } - // MARK: - CameraInfo with Zoom Stops Round-Trip - - func testCameraInfo_withZoomStops_roundTrip() { - let backCamera = RemoteCmd.CameraInfo( - availableLenses: [.wideAngle, .ultraWide, .telephoto], - hasFlash: true, - hasTorch: true, - zoomCapabilities: [.wideAngle: RemoteCmd.ZoomRange(minZoom: 1.0, maxZoom: 10.0)], - zoomStops: [0.5, 1.0, 2.0, 5.0] - ) - let original = RemoteCmd.CameraCapabilitiesResp( - frontCamera: nil, backCamera: backCamera, - currentCamera: .back, currentLens: .wideAngle, - currentZoom: 1.0, error: nil - ) - let decoded: RemoteCmd.CameraCapabilitiesResp = roundTrip(original) - XCTAssertEqual(decoded.backCamera?.zoomStops, [0.5, 1.0, 2.0, 5.0]) - } - - func testCameraInfo_emptyZoomStops_defaultsToOne() { - let backCamera = RemoteCmd.CameraInfo( - availableLenses: [.wideAngle], - hasFlash: false, - hasTorch: false, - zoomCapabilities: [:] - // zoomStops not provided, defaults to [1.0] - ) - let original = RemoteCmd.CameraCapabilitiesResp( - frontCamera: nil, backCamera: backCamera, - currentCamera: .back, currentLens: .wideAngle, - currentZoom: 1.0, error: nil - ) - let decoded: RemoteCmd.CameraCapabilitiesResp = roundTrip(original) - XCTAssertEqual(decoded.backCamera?.zoomStops, [1.0]) - } - - func testCameraInfo_zoomStopsPreservedWithOtherCapabilities() { - let backCamera = RemoteCmd.CameraInfo( - availableLenses: [.wideAngle, .telephoto], - hasFlash: true, - hasTorch: true, - zoomCapabilities: [ - .wideAngle: RemoteCmd.ZoomRange(minZoom: 1.0, maxZoom: 10.0), - .telephoto: RemoteCmd.ZoomRange(minZoom: 2.0, maxZoom: 20.0) - ], - supportedResolutions: [.hd1080p, .uhd4k], - supportedFrameRates: [.fps30, .fps60], - resolutionFrameRates: [:], - supportsHEIF: true, - supportsHDR: false, - zoomStops: [1.0, 2.0, 5.0] - ) - let original = RemoteCmd.CameraCapabilitiesResp( - frontCamera: nil, backCamera: backCamera, - currentCamera: .back, currentLens: .wideAngle, - currentZoom: 2.0, error: nil - ) - let decoded: RemoteCmd.CameraCapabilitiesResp = roundTrip(original) - let info = decoded.backCamera! - XCTAssertEqual(info.zoomStops, [1.0, 2.0, 5.0]) - XCTAssertEqual(info.supportedResolutions, [.hd1080p, .uhd4k]) - XCTAssertTrue(info.supportsHEIF) - XCTAssertFalse(info.supportsHDR) - } } // MARK: - Unknown actions @@ -1319,8 +1150,7 @@ extension RemoteCmdSerializationTests { func testCapabilitiesCarryPreviewModeSupportAndMode() { let caps = RemoteCmd.CameraCapabilitiesResp( frontCamera: nil, backCamera: nil, - currentCamera: .back, currentLens: .wideAngle, currentZoom: 1.0, - supportsPreviewMode: true, previewMode: .standby, error: nil) + currentCamera: .back, supportsPreviewMode: true, previewMode: .standby, error: nil) let result = roundTrip(caps) XCTAssertTrue(result.supportsPreviewMode) XCTAssertEqual(result.previewMode, .standby) @@ -1330,8 +1160,7 @@ extension RemoteCmdSerializationTests { func testCapabilitiesDefaultPreviewModeIsOnAndUnsupported() { let caps = RemoteCmd.CameraCapabilitiesResp( frontCamera: nil, backCamera: nil, - currentCamera: .back, currentLens: .wideAngle, currentZoom: 1.0, - error: nil) + currentCamera: .back, error: nil) let result = roundTrip(caps) XCTAssertFalse(result.supportsPreviewMode) XCTAssertEqual(result.previewMode, .on) @@ -1426,14 +1255,12 @@ extension RemoteCmdSerializationTests { func testCapabilitiesCarryMulticamSupport() { let caps = RemoteCmd.CameraCapabilitiesResp( frontCamera: nil, backCamera: nil, - currentCamera: .back, currentLens: .wideAngle, currentZoom: 1.0, - supportsMulticam: true, error: nil) + currentCamera: .back, supportsMulticam: true, error: nil) XCTAssertTrue(roundTrip(caps).supportsMulticam) let legacy = RemoteCmd.CameraCapabilitiesResp( frontCamera: nil, backCamera: nil, - currentCamera: .back, currentLens: .wideAngle, currentZoom: 1.0, - error: nil) + currentCamera: .back, error: nil) XCTAssertFalse(roundTrip(legacy).supportsMulticam) } } diff --git a/RemoteCamTests/RigQualityMenuTests.swift b/RemoteCamTests/RigQualityMenuTests.swift index 3195ed9e..0a141c8f 100644 --- a/RemoteCamTests/RigQualityMenuTests.swift +++ b/RemoteCamTests/RigQualityMenuTests.swift @@ -15,7 +15,6 @@ final class RigQualityMenuTests: XCTestCase { heif: Bool = true, hdr: Bool = true) -> RemoteCmd.CameraInfo { RemoteCmd.CameraInfo( availableLenses: [.wideAngle], hasFlash: true, hasTorch: true, - zoomCapabilities: [:], supportedResolutions: Array(matrix.keys), supportedFrameRates: Array(Set(matrix.values.flatMap { $0 })), resolutionFrameRates: matrix, diff --git a/RemoteCamTests/SessionTestSupport.swift b/RemoteCamTests/SessionTestSupport.swift index fae63a2c..1d89fbd5 100644 --- a/RemoteCamTests/SessionTestSupport.swift +++ b/RemoteCamTests/SessionTestSupport.swift @@ -120,14 +120,58 @@ class FakeCameraControlling: CameraControlling, @unchecked Sendable { var exitCameraCalls = 0 var countdownTicks: [Int] = [] var gatherCapabilitiesCalls = 0 - var zoomCalls: [CGFloat] = [] - var focusCalls: [CGPoint] = [] - var exposureCalls: [ExposureIntent] = [] + // Recorded control calls. Locked-backed because the coordinator's actor + // writes them while the test thread reads (a bare array races under TSan). + private let zoomCallsStore = Locked<[CGFloat]>([]) + var zoomCalls: [CGFloat] { zoomCallsStore.value } + private let focusCallsStore = Locked<[CGPoint]>([]) + var focusCalls: [CGPoint] { focusCallsStore.value } + private let exposureCallsStore = Locked<[ExposureIntent]>([]) + var exposureCalls: [ExposureIntent] { exposureCallsStore.value } + private let cinematicCallsStore = Locked<[CinematicIntent]>([]) + var cinematicCalls: [CinematicIntent] { cinematicCallsStore.value } + private let lensSwitchesStore = Locked<[CameraLensType]>([]) + var lensSwitches: [CameraLensType] { lensSwitchesStore.value } + var advertisesManualExposure = true - var cinematicCalls: [CinematicIntent] = [] var advertisesCinematicVideo = true var cinematicEnabled = false - var lensSwitches: [CameraLensType] = [] + + /// The one control-plane truth the fake mutates (v11). `advertises*` + /// mask exposure / Cinematic out of what it hands back, so capability + /// is presence exactly as on the wire. + private let controlStore = Locked(ControlState( + seq: 0, + activeDeviceID: "fake-back", + zoomFactor: 1.0, minZoom: 1.0, maxZoom: 10.0, + zoomStops: [1.0, 2.0], wideAngleZoomFactor: 1.0, + exposure: ExposureState(mode: .auto, durationSeconds: 1.0 / 120, iso: 64, + minDurationSeconds: 1.0 / 10_000, maxDurationSeconds: 1.0, + minISO: 32, maxISO: 3200), + cinematic: CinematicState(enabled: false, simulatedAperture: 2.0, + minSimulatedAperture: 1.4, maxSimulatedAperture: 16, + defaultSimulatedAperture: 2.0, apertureLocked: false, + notEnoughLight: false))) + + /// The snapshot as the remote would receive it: exposure / Cinematic + /// present only when advertised, focus + device identity mirrored from + /// the fake's knobs. + private func maskedControl() -> ControlState { + var st = controlStore.value + st.supportsFocusPoint = advertisesFocusPoint + st.activeDeviceID = advertisesCameraDevices ? activeDeviceID : nil + if !advertisesManualExposure { st.exposure = nil } + if !advertisesCinematicVideo { st.cinematic = nil } + return st + } + + /// Mutate the truth (seq++) and return the masked snapshot — the value + /// every control mutation echoes. + @discardableResult + private func bumpControl(_ body: (inout ControlState) -> Void) -> ControlState { + controlStore.mutate { st in st.seq += 1; body(&st) } + return maskedControl() + } var torchToggles = 0 var chimes: [Int] = [] var torchRestores = 0 @@ -164,50 +208,66 @@ class FakeCameraControlling: CameraControlling, @unchecked Sendable { var appliedProfiles: [StreamProfile] = [] func applyStreamProfile(_ profile: StreamProfile) { appliedProfiles.append(profile) } - // swiftlint:disable:next large_tuple - func setZoom(zoomFactor: CGFloat) async throws -> (CGFloat, CameraLensType, RemoteCmd.ZoomRange) { + func setZoom(zoomFactor: CGFloat) async throws -> ControlState { if let errorToThrow { throw errorToThrow } - zoomCalls.append(zoomFactor) - return (zoomFactor, .wideAngle, RemoteCmd.ZoomRange(minZoom: 1, maxZoom: 10)) + zoomCallsStore.mutate { $0.append(zoomFactor) } + return bumpControl { st in + st.zoomFactor = max(st.minZoom, min(zoomFactor, st.maxZoom)) + } } func focusAtPoint(x: Float, y: Float) async throws { if let errorToThrow { throw errorToThrow } - focusCalls.append(CGPoint(x: CGFloat(x), y: CGFloat(y))) + focusCallsStore.mutate { $0.append(CGPoint(x: CGFloat(x), y: CGFloat(y))) } } - /// Echoes the intent like the engine (fixed phone-like aperture range). - func setCinematic(_ intent: CinematicIntent) async throws -> CinematicState { + /// Echoes the intent like the engine (fixed phone-like aperture range), + /// and — the wire regression — narrows the zoom band while Cinematic is + /// on, restoring it on disable, all inside the one returned snapshot. + func setCinematic(_ intent: CinematicIntent) async throws -> ControlState { if let errorToThrow { throw errorToThrow } - cinematicCalls.append(intent) + cinematicCallsStore.mutate { $0.append(intent) } switch intent { case .off: cinematicEnabled = false case .on: cinematicEnabled = true } + let enabled = cinematicEnabled var aperture: Float = 2.0 if case let .on(requested) = intent, let requested { aperture = requested } - return CinematicState(enabled: cinematicEnabled, simulatedAperture: aperture, - minSimulatedAperture: 1.4, maxSimulatedAperture: 16, - defaultSimulatedAperture: 2.0, apertureLocked: false, notEnoughLight: false) + return bumpControl { st in + st.cinematic = CinematicState(enabled: enabled, simulatedAperture: aperture, + minSimulatedAperture: 1.4, maxSimulatedAperture: 16, + defaultSimulatedAperture: 2.0, apertureLocked: false, + notEnoughLight: false) + st.maxZoom = enabled ? 3.0 : 10.0 + st.zoomFactor = min(st.zoomFactor, st.maxZoom) + } } /// Echoes the intent clamped into a fixed phone-like range, like the engine. - func setExposure(_ intent: ExposureIntent) async throws -> ExposureState { + func setExposure(_ intent: ExposureIntent) async throws -> ControlState { if let errorToThrow { throw errorToThrow } - exposureCalls.append(intent) - switch intent { - case .auto: - return ExposureState(mode: .auto, durationSeconds: 1.0 / 120, iso: 64, - minDurationSeconds: 1.0 / 10_000, maxDurationSeconds: 1.0, minISO: 32, maxISO: 3200) - case let .manual(duration, iso): - return ExposureState(mode: .manual, durationSeconds: duration, iso: iso, - minDurationSeconds: 1.0 / 10_000, maxDurationSeconds: 1.0, minISO: 32, maxISO: 3200) + exposureCallsStore.mutate { $0.append(intent) } + return bumpControl { st in + switch intent { + case .auto: + st.exposure = ExposureState(mode: .auto, durationSeconds: 1.0 / 120, iso: 64, + minDurationSeconds: 1.0 / 10_000, maxDurationSeconds: 1.0, + minISO: 32, maxISO: 3200) + case let .manual(duration, iso): + st.exposure = ExposureState(mode: .manual, durationSeconds: duration, iso: iso, + minDurationSeconds: 1.0 / 10_000, maxDurationSeconds: 1.0, + minISO: 32, maxISO: 3200) + } } } - // swiftlint:disable:next large_tuple - func switchLens(to lensType: CameraLensType) async throws -> (CameraLensType, [CameraLensType], CGFloat, RemoteCmd.ZoomRange) { + func switchLens(to lensType: CameraLensType) async throws -> ControlState { if let errorToThrow { throw errorToThrow } - lensSwitches.append(lensType) - return (lensType, [.wideAngle, lensType], 1.0, RemoteCmd.ZoomRange(minZoom: 1, maxZoom: 10)) + lensSwitchesStore.mutate { $0.append(lensType) } + return bumpControl { st in + st.currentLens = lensType + st.availableLenses = [.wideAngle, lensType] + } } + func controlState() async -> ControlState? { maskedControl() } func toggleFlash() async throws -> AVCaptureDevice.FlashMode { if let errorToThrow { throw errorToThrow } flashMode = flashMode == .off ? .on : .off @@ -259,11 +319,12 @@ class FakeCameraControlling: CameraControlling, @unchecked Sendable { throw NSError(domain: "No camera device available", code: 0, userInfo: nil) } activeDeviceID = device.uniqueID + // v11: live ranges ride ControlState, not the selection result. If + // WP1/WP2 keeps a range field on CameraSelectionResult, add it here. return CameraSelectionResult( device: device, flashMode: device.position == .back ? flashMode : nil, availableLensTypes: [.wideAngle], - zoomRange: RemoteCmd.ZoomRange(minZoom: 1, maxZoom: 10), currentZoom: 1.0) } func setTorchMode(mode: AVCaptureDevice.TorchMode) async throws -> AVCaptureDevice.TorchMode { @@ -329,14 +390,11 @@ class FakeCameraControlling: CameraControlling, @unchecked Sendable { : [] return RemoteCmd.CameraCapabilitiesResp( frontCamera: nil, backCamera: nil, - currentCamera: .back, currentLens: .wideAngle, currentZoom: 1.0, + currentCamera: .back, cameraDevices: entries, - activeDeviceID: advertisesCameraDevices ? activeDeviceID : nil, - supportsFocusPoint: advertisesFocusPoint, supportsPreviewMode: advertisesPreviewMode, previewMode: storedPreviewMode, - supportsManualExposure: advertisesManualExposure, - supportsCinematicVideo: advertisesCinematicVideo, + control: maskedControl(), error: nil) } diff --git a/RemoteCamTests/ZoomScaleTests.swift b/RemoteCamTests/ZoomScaleTests.swift index eca434c8..1d69bc40 100644 --- a/RemoteCamTests/ZoomScaleTests.swift +++ b/RemoteCamTests/ZoomScaleTests.swift @@ -99,7 +99,7 @@ final class ZoomScaleTests: XCTestCase { func testDegenerateRangeIsFlaggedAndNeverDividesByZero() { // maxZoomFactor below the low stop: what the view model holds before the first - // SetZoomResp arrives. Must not produce NaN or trap. + // control snapshot arrives. Must not produce NaN or trap. let collapsed = ZoomScale(stops: [1.0], maxZoomFactor: 1.0, wideAngleZoomFactor: 1.0) XCTAssertTrue(collapsed.isDegenerate) XCTAssertEqual(collapsed.position(forHardware: 1.0), 0.0) diff --git a/RemoteShutter.xcodeproj/project.pbxproj b/RemoteShutter.xcodeproj/project.pbxproj index eab5673a..ae0856f2 100644 --- a/RemoteShutter.xcodeproj/project.pbxproj +++ b/RemoteShutter.xcodeproj/project.pbxproj @@ -77,6 +77,8 @@ 0A11B22C33D44E55F6070002 /* ZoomScale.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0A11B22C33D44E55F6070001 /* ZoomScale.swift */; }; 0A11B22C33D44E55F6070004 /* ZoomPill.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0A11B22C33D44E55F6070003 /* ZoomPill.swift */; }; E0E020700000000000000002 /* RulerPill.swift in Sources */ = {isa = PBXBuildFile; fileRef = E0E020700000000000000001 /* RulerPill.swift */; }; + E0E020710000000000000002 /* ControlState.swift in Sources */ = {isa = PBXBuildFile; fileRef = E0E020710000000000000001 /* ControlState.swift */; }; + E0E020720000000000000002 /* ControlStateTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = E0E020720000000000000001 /* ControlStateTests.swift */; }; 0A11B22C33D44E55F6071004 /* ViewfinderGestureLayer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0A11B22C33D44E55F6071003 /* ViewfinderGestureLayer.swift */; }; 0A11B22C33D44E55F6070006 /* ZoomScaleTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0A11B22C33D44E55F6070005 /* ZoomScaleTests.swift */; }; 1ECFC14E17A9A47D5951E80B /* RemoteCam/WatchSessionManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 148A312CF1B445C71094EF0E /* RemoteCam/WatchSessionManager.swift */; }; @@ -213,7 +215,6 @@ CAFEBABE0177000000000001 /* SessionDebugConsole.swift in Sources */ = {isa = PBXBuildFile; fileRef = CAFEBABE0177000000000002 /* SessionDebugConsole.swift */; }; CAFEBABE0130000000000001 /* CaptureSyncMetadata.swift in Sources */ = {isa = PBXBuildFile; fileRef = CAFEBABE0130000000000002 /* CaptureSyncMetadata.swift */; }; CAFEBABE0132000000000001 /* ClockOffsetEstimator.swift in Sources */ = {isa = PBXBuildFile; fileRef = CAFEBABE0132000000000002 /* ClockOffsetEstimator.swift */; }; - CAFEBABE0170000000000001 /* ZoomScaleSeed.swift in Sources */ = {isa = PBXBuildFile; fileRef = CAFEBABE0170000000000002 /* ZoomScaleSeed.swift */; }; CAFEBABE0172000000000001 /* PeerSessionCore.swift in Sources */ = {isa = PBXBuildFile; fileRef = CAFEBABE0172000000000002 /* PeerSessionCore.swift */; }; CAFEBABE0173000000000001 /* DiscoveredPeers.swift in Sources */ = {isa = PBXBuildFile; fileRef = CAFEBABE0173000000000002 /* DiscoveredPeers.swift */; }; CAFEBABE0171000000000001 /* FocusedCameraControlState.swift in Sources */ = {isa = PBXBuildFile; fileRef = CAFEBABE0171000000000002 /* FocusedCameraControlState.swift */; }; @@ -356,6 +357,8 @@ 0A11B22C33D44E55F6070001 /* ZoomScale.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ZoomScale.swift; sourceTree = ""; }; 0A11B22C33D44E55F6070003 /* ZoomPill.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ZoomPill.swift; sourceTree = ""; }; E0E020700000000000000001 /* RulerPill.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RulerPill.swift; sourceTree = ""; }; + E0E020710000000000000001 /* ControlState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ControlState.swift; sourceTree = ""; }; + E0E020720000000000000001 /* ControlStateTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ControlStateTests.swift; sourceTree = ""; }; 0A11B22C33D44E55F6071003 /* ViewfinderGestureLayer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ViewfinderGestureLayer.swift; sourceTree = ""; }; 0A11B22C33D44E55F6070005 /* ZoomScaleTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = ZoomScaleTests.swift; sourceTree = ""; }; 0ACB2DA94752BB4E9C4CE461 /* CountdownTimer.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = CountdownTimer.swift; sourceTree = ""; }; @@ -483,7 +486,6 @@ CAFEBABE0177000000000002 /* SessionDebugConsole.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SessionDebugConsole.swift; sourceTree = ""; }; CAFEBABE0130000000000002 /* CaptureSyncMetadata.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = CaptureSyncMetadata.swift; sourceTree = ""; }; CAFEBABE0132000000000002 /* ClockOffsetEstimator.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ClockOffsetEstimator.swift; sourceTree = ""; }; - CAFEBABE0170000000000002 /* ZoomScaleSeed.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ZoomScaleSeed.swift; sourceTree = ""; }; CAFEBABE0172000000000002 /* PeerSessionCore.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = PeerSessionCore.swift; sourceTree = ""; }; CAFEBABE0173000000000002 /* DiscoveredPeers.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = DiscoveredPeers.swift; sourceTree = ""; }; CAFEBABE0171000000000002 /* FocusedCameraControlState.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = FocusedCameraControlState.swift; sourceTree = ""; }; @@ -652,6 +654,7 @@ E0E0206D0000000000000001 /* CinematicPolicyTests.swift */, E0E0206E0000000000000001 /* MessageDumpTests.swift */, E0E0206F0000000000000001 /* ProSliderScaleTests.swift */, + E0E020720000000000000001 /* ControlStateTests.swift */, CAFEBABE0121000000000001 /* MonitorChromeTests.swift */, CAFEBABE0131000000000001 /* CaptureSyncMetadataTests.swift */, CAFEBABE0133000000000001 /* ClockOffsetEstimatorTests.swift */, @@ -696,6 +699,7 @@ 0A11B22C33D44E55F6070001 /* ZoomScale.swift */, 0A11B22C33D44E55F6070003 /* ZoomPill.swift */, E0E020700000000000000001 /* RulerPill.swift */, + E0E020710000000000000001 /* ControlState.swift */, 0A11B22C33D44E55F6071003 /* ViewfinderGestureLayer.swift */, 06BB79BB2E3884F00094E085 /* CameraProgressOverlayView.swift */, 06BB79BE2E3884FA0094E085 /* CameraViewModel.swift */, @@ -759,7 +763,6 @@ CAFEBABE0177000000000002 /* SessionDebugConsole.swift */, CAFEBABE0130000000000002 /* CaptureSyncMetadata.swift */, CAFEBABE0132000000000002 /* ClockOffsetEstimator.swift */, - CAFEBABE0170000000000002 /* ZoomScaleSeed.swift */, CAFEBABE0172000000000002 /* PeerSessionCore.swift */, CAFEBABE0173000000000002 /* DiscoveredPeers.swift */, CAFEBABE0171000000000002 /* FocusedCameraControlState.swift */, @@ -1236,6 +1239,7 @@ 0A11B22C33D44E55F6070002 /* ZoomScale.swift in Sources */, 0A11B22C33D44E55F6070004 /* ZoomPill.swift in Sources */, E0E020700000000000000002 /* RulerPill.swift in Sources */, + E0E020710000000000000002 /* ControlState.swift in Sources */, 0A11B22C33D44E55F6071004 /* ViewfinderGestureLayer.swift in Sources */, 068DF59E2E3544AD00A49279 /* MonitorViewController+SwiftUI.swift in Sources */, 068DF59F2E3544AD00A49279 /* MonitorViewModel.swift in Sources */, @@ -1270,7 +1274,6 @@ CAFEBABE0177000000000001 /* SessionDebugConsole.swift in Sources */, CAFEBABE0130000000000001 /* CaptureSyncMetadata.swift in Sources */, CAFEBABE0132000000000001 /* ClockOffsetEstimator.swift in Sources */, - CAFEBABE0170000000000001 /* ZoomScaleSeed.swift in Sources */, CAFEBABE0172000000000001 /* PeerSessionCore.swift in Sources */, CAFEBABE0173000000000001 /* DiscoveredPeers.swift in Sources */, CAFEBABE0171000000000001 /* FocusedCameraControlState.swift in Sources */, @@ -1356,6 +1359,7 @@ E0E0206D0000000000000002 /* CinematicPolicyTests.swift in Sources */, E0E0206E0000000000000002 /* MessageDumpTests.swift in Sources */, E0E0206F0000000000000002 /* ProSliderScaleTests.swift in Sources */, + E0E020720000000000000002 /* ControlStateTests.swift in Sources */, CAFEBABE0121000000000002 /* MonitorChromeTests.swift in Sources */, CAFEBABE0131000000000002 /* CaptureSyncMetadataTests.swift in Sources */, CAFEBABE0133000000000002 /* ClockOffsetEstimatorTests.swift in Sources */, @@ -1521,7 +1525,7 @@ "@executable_path/Frameworks", ); MACOSX_DEPLOYMENT_TARGET = 12.0; - MARKETING_VERSION = 10.0.3; + MARKETING_VERSION = 11.0.0; OTHER_LDFLAGS = "$(inherited)"; PRODUCT_BUNDLE_IDENTIFIER = com.blackfireapps.remotecamera; PRODUCT_NAME = "$(TARGET_NAME)"; @@ -1563,7 +1567,7 @@ "@executable_path/Frameworks", ); MACOSX_DEPLOYMENT_TARGET = 12.0; - MARKETING_VERSION = 10.0.3; + MARKETING_VERSION = 11.0.0; OTHER_LDFLAGS = "$(inherited)"; PRODUCT_BUNDLE_IDENTIFIER = com.blackfireapps.remotecamera; PRODUCT_NAME = "$(TARGET_NAME)"; From e5a8f01365969c1a04363d5162af398c1b93d9e8 Mon Sep 17 00:00:00 2001 From: Dario Lencina Date: Thu, 27 Aug 2026 01:09:40 -0700 Subject: [PATCH 11/14] Slider send throttle: pin the leading/trailing contract ThrottledValueSender was the one untested piece of the slider stack (its core ZoomSendThrottle is pinned; the Timer wrapper was not): the leading value sends immediately, mid-drag values coalesce, and the value the finger released on ALWAYS lands on the trailing edge. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01K5uHRuiiLu5FEBiHZkBWo3 --- RemoteCamTests/ProSliderScaleTests.swift | 46 ++++++++++++++++++++++++ 1 file changed, 46 insertions(+) diff --git a/RemoteCamTests/ProSliderScaleTests.swift b/RemoteCamTests/ProSliderScaleTests.swift index 11e7aa3e..c301a892 100644 --- a/RemoteCamTests/ProSliderScaleTests.swift +++ b/RemoteCamTests/ProSliderScaleTests.swift @@ -84,3 +84,49 @@ final class ProSliderScaleTests: XCTestCase { XCTAssertEqual(ProSliderKind.allCases.map(\.tile), [.shutter, .iso, .aperture]) } } + +/// The slider's send throttle: leading edge for responsiveness, trailing +/// edge so the value the finger released on always reaches the wire — the +/// zoom pill's send pattern (`ZoomSendThrottle`), packaged per slider. +final class ThrottledValueSenderTests: XCTestCase { + + /// Spin the main run loop until `condition` holds (or ~1s passes): the + /// trailing edge rides a main-queue Timer, so fixed sleeps would flake. + private func pumpMainUntil(_ condition: () -> Bool) { + for _ in 0..<100 where !condition() { + RunLoop.current.run(until: Date(timeIntervalSinceNow: 0.02)) + } + } + + func testFirstValueSendsImmediately() { + var sent: [Double] = [] + let sender = ThrottledValueSender(interval: 10) { sent.append($0) } + sender.submit(0.5) + XCTAssertEqual(sent, [0.5], "the leading edge must not wait for a timer") + } + + /// A drag emits a value per frame; only the leading value goes out now, + /// and the LAST value always lands when the trailing timer fires — + /// intermediate positions are coalesced away, never the final one. + func testTrailingEdgeDeliversTheLastValueOnly() { + var sent: [Double] = [] + let sender = ThrottledValueSender(interval: 0.05) { sent.append($0) } + sender.submit(1.0) + sender.submit(2.0) + sender.submit(3.0) + XCTAssertEqual(sent, [1.0], "mid-drag values must be held, not sent") + + pumpMainUntil { sent.count == 2 } + XCTAssertEqual(sent, [1.0, 3.0], "the release value must always land, intermediates never") + } + + func testValuesAfterTheIntervalSendOnTheLeadingEdgeAgain() { + var sent: [Double] = [] + let sender = ThrottledValueSender(interval: 0.05) { sent.append($0) } + sender.submit(1.0) + // Let the interval fully elapse (and any armed trailing timer drain). + RunLoop.current.run(until: Date(timeIntervalSinceNow: 0.08)) + sender.submit(2.0) + XCTAssertEqual(sent, [1.0, 2.0], "a fresh adjustment after a pause is immediate again") + } +} From c66f8335152a895866a07ea0a900cb553ed96786 Mon Sep 17 00:00:00 2001 From: Dario Lencina Date: Thu, 27 Aug 2026 01:20:49 -0700 Subject: [PATCH 12/14] Zoom pill never yields to the pro slider; pin that Cinematic narrowing cannot hide it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Zoom under Cinematic is narrowed, never removed (AVCaptureDevice.h: 'Devices support a limited zoom range when Cinematic Video capture is active'), but the UI contradicted the model: an open pro slider took the zoom pill's slot, which read as 'enabling Cinematic removes zoom'. The pro slider now stacks ABOVE the pill on both remote screens — framing and exposure adjust at the same time. ControlStateTests additionally prove the derivation side: for every narrowed range the engine can emit (guarded max > min), the derived ZoomScale is non-degenerate — a degenerate scale is exactly what hides the pill — including the floor-above-every-stop case, and disabling Cinematic widens straight back (no stored value to un-stick). Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01K5uHRuiiLu5FEBiHZkBWo3 --- RemoteCam/MonitorView.swift | 26 +++++++++------- RemoteCam/MulticamView.swift | 33 +++++++++++--------- RemoteCamTests/ControlStateTests.swift | 42 ++++++++++++++++++++++++++ 3 files changed, 76 insertions(+), 25 deletions(-) diff --git a/RemoteCam/MonitorView.swift b/RemoteCam/MonitorView.swift index d25eccf9..afaf6974 100644 --- a/RemoteCam/MonitorView.swift +++ b/RemoteCam/MonitorView.swift @@ -404,19 +404,23 @@ struct MonitorView: View { return kind } - /// The zoom pill's slot: the pro slider while one is open, else zoom. + /// Zoom and the pro slider are NOT rivals: Apple supports zoom while + /// Cinematic is on (within the narrowed range the snapshot carries), so + /// the zoom pill is always in its slot and an open pro slider stacks + /// above it — framing and exposure stay adjustable at the same time. @ViewBuilder private var zoomOrProSlider: some View { - if let kind = visibleProSlider, let scale = proScale(kind) { - ProSliderPill(scale: scale, - currentValue: proValue(kind), - onChange: { onProSliderChange(kind, $0) }, - onAuto: kind == .aperture ? nil : { - onExposureChange(.auto) - activeProSlider = nil - }, - onClose: { activeProSlider = nil }) - } else { + VStack(spacing: 10) { + if let kind = visibleProSlider, let scale = proScale(kind) { + ProSliderPill(scale: scale, + currentValue: proValue(kind), + onChange: { onProSliderChange(kind, $0) }, + onAuto: kind == .aperture ? nil : { + onExposureChange(.auto) + activeProSlider = nil + }, + onClose: { activeProSlider = nil }) + } ZoomPill(scale: viewModel.zoomScale, currentZoomFactor: viewModel.currentZoomFactor, onZoomChange: onZoomChange) diff --git a/RemoteCam/MulticamView.swift b/RemoteCam/MulticamView.swift index f3506db5..669dc623 100644 --- a/RemoteCam/MulticamView.swift +++ b/RemoteCam/MulticamView.swift @@ -315,23 +315,28 @@ struct MulticamView: View { /// and hides when the focused camera has no usable zoom range (a /// fixed-focal-length camera, or before its first response), exactly as the /// 1:1 monitor does. + /// Zoom and the pro slider coexist (zoom stays legal under Cinematic, + /// just narrowed): an open pro slider stacks ABOVE the zoom pill, same + /// rule as the 1:1 monitor. @ViewBuilder private var focusedZoomPill: some View { if viewModel.displayMode == .focus, let focused = viewModel.focusedLane { - if let kind = viewModel.visibleProSlider, let scale = proScale(kind, focused) { - // The pro slider takes the zoom pill's slot, as on the 1:1 monitor. - ProSliderPill(scale: scale, - currentValue: proValue(kind, focused), - onChange: { onProSliderChange(focused, kind, $0) }, - onAuto: kind == .aperture ? nil : { - onExposureChange(focused, .auto) - viewModel.activeProSlider = nil - }, - onClose: { viewModel.activeProSlider = nil }) - } else if viewModel.showsFocusedZoomPill { - ZoomPill(scale: viewModel.focusedZoomScale, - currentZoomFactor: viewModel.focusedZoomFactor, - onZoomChange: { onZoomChange(focused, $0) }) + VStack(spacing: 10) { + if let kind = viewModel.visibleProSlider, let scale = proScale(kind, focused) { + ProSliderPill(scale: scale, + currentValue: proValue(kind, focused), + onChange: { onProSliderChange(focused, kind, $0) }, + onAuto: kind == .aperture ? nil : { + onExposureChange(focused, .auto) + viewModel.activeProSlider = nil + }, + onClose: { viewModel.activeProSlider = nil }) + } + if viewModel.showsFocusedZoomPill { + ZoomPill(scale: viewModel.focusedZoomScale, + currentZoomFactor: viewModel.focusedZoomFactor, + onZoomChange: { onZoomChange(focused, $0) }) + } } } } diff --git a/RemoteCamTests/ControlStateTests.swift b/RemoteCamTests/ControlStateTests.swift index 8de67ade..263f125c 100644 --- a/RemoteCamTests/ControlStateTests.swift +++ b/RemoteCamTests/ControlStateTests.swift @@ -110,4 +110,46 @@ final class ControlStateTests: XCTestCase { XCTAssertEqual(ControlRefusalReason.sessionRefused.message(detail: ""), "The camera refused that setting", "an empty detail adds no parens") } + + // MARK: - Cinematic never hides the zoom pill + + /// Apple narrows zoom under Cinematic (videoMin/MaxZoomFactorForCinematicVideo) + /// but never removes it — and neither may the derivation. For every + /// plausible narrowed range the engine can emit (its guard ensures + /// max > min within the device range), the derived scale must stay + /// non-degenerate, because a degenerate scale is exactly what hides the + /// pill. This pins the field report "zoom disappears when Cinematic is on". + func testCinematicNarrowedRangesNeverDegenerateTheZoomScale() { + // (stops, wide, cineMin, cineMax) — hardware factors. + let cases: [(stops: [CGFloat], wide: CGFloat, min: CGFloat, max: CGFloat, label: String)] = [ + ([1, 2], 2, 2, 6, "iPhone 14 DualWide: Cinematic pinned to the wide lens"), + ([1, 2], 2, 1, 3, "DualWide: narrowed from both ends"), + ([1, 2, 6], 2, 2, 9, "Triple: ultra-wide and tele stops dropped"), + ([1, 2], 2, 3, 6, "floor above every lens stop: the floor is the one detent"), + ([1], 1, 1, 2, "single-lens: tiny cinematic headroom"), + ] + for c in cases { + let state = ControlState(seq: 1, zoomFactor: c.min, + minZoom: c.min, maxZoom: c.max, + zoomStops: c.stops, wideAngleZoomFactor: c.wide) + let scale = state.zoomScale + XCTAssertFalse(scale.isDegenerate, "\(c.label): a degenerate scale hides the pill") + XCTAssertGreaterThanOrEqual(scale.minZoom, c.min, c.label) + XCTAssertFalse(scale.stops.isEmpty, "\(c.label): the ruler needs at least one detent") + XCTAssertTrue(scale.stops.allSatisfy { $0 >= scale.minZoom && $0 <= scale.maxZoom }, + "\(c.label): every offered detent must be reachable") + } + } + + /// Disabling Cinematic restores the device range: the same derivation + /// widens back — no stored value to un-stick. + func testDisablingCinematicRestoresTheFullScale() { + let narrowed = ControlState(seq: 1, minZoom: 2, maxZoom: 6, + zoomStops: [1, 2], wideAngleZoomFactor: 2) + let restored = ControlState(seq: 2, minZoom: 1, maxZoom: 10, + zoomStops: [1, 2], wideAngleZoomFactor: 2) + XCTAssertEqual(narrowed.zoomScale.stops, [2], "ultra-wide is out of reach under Cinematic") + XCTAssertEqual(ControlState.absorb(narrowed, restored).zoomScale.stops, [1, 2], + "the next snapshot brings the ultra-wide stop back") + } } From af1e873be20ea0d06c29d3c85d49efd2985e0652 Mon Sep 17 00:00:00 2001 From: Dario Lencina Date: Thu, 27 Aug 2026 01:33:34 -0700 Subject: [PATCH 13/14] A closed pro slider never auto-revives; that is what swallowed the zoom pill MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Field repro: no slider open, toggle CINEMATIC, zoom vanishes instantly. Cause: activeProSlider deliberately REMEMBERED the last-opened slider when its tile disappeared, so the aperture slider from an earlier Cinematic session resurrected the moment the tile returned — replacing the zoom pill with no tap. The choice is now CLEARED when its tile vanishes, on both remote screens; combined with the stacking change, no path replaces or hides the zoom pill while the camera reports a usable range. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01K5uHRuiiLu5FEBiHZkBWo3 --- RemoteCam/MonitorView.swift | 12 ++++++++++-- RemoteCam/MulticamViewModel.swift | 15 ++++++++++++--- RemoteCamTests/MulticamViewModelTests.swift | 16 ++++++++++++---- 3 files changed, 34 insertions(+), 9 deletions(-) diff --git a/RemoteCam/MonitorView.swift b/RemoteCam/MonitorView.swift index afaf6974..a5b830dd 100644 --- a/RemoteCam/MonitorView.swift +++ b/RemoteCam/MonitorView.swift @@ -77,6 +77,12 @@ struct MonitorView: View { SessionDebugOverlay() #endif } + // A slider whose tile vanished is closed for good, not parked. + .onChange(of: proTiles) { tiles in + if let kind = activeProSlider, !tiles.contains(kind.tile) { + activeProSlider = nil + } + } } // Catalyst's default style paints a bordered box behind controls that // already draw their own shape. Not .plain — that also drops the @@ -397,8 +403,10 @@ struct MonitorView: View { apertureAdjustable: (viewModel.cinematic?.minSimulatedAperture ?? 0) > 0) } - /// The open slider, as long as its tile is still offered (the camera may - /// have swapped to a device without it, or left video mode). + /// The open slider, as long as its tile is still offered. A vanished tile + /// CLEARS the choice (see the onChange below) rather than parking it — a + /// parked slider auto-revived when the tile returned, displacing the zoom + /// pill with no tap. private var visibleProSlider: ProSliderKind? { guard let kind = activeProSlider, proTiles.contains(kind.tile) else { return nil } return kind diff --git a/RemoteCam/MulticamViewModel.swift b/RemoteCam/MulticamViewModel.swift index 5bc2f52f..3b4f8119 100644 --- a/RemoteCam/MulticamViewModel.swift +++ b/RemoteCam/MulticamViewModel.swift @@ -151,10 +151,19 @@ final class MulticamViewModel: ObservableObject { apertureAdjustable: (focused.cinematic?.minSimulatedAperture ?? 0) > 0) } - /// The open slider, as long as the focused camera still offers its tile - /// (focus moved to another camera, the mode changed, the camera dropped). + /// The open slider, as long as the focused camera still offers its tile. + /// When the tile goes away the choice is CLEARED, never parked: a parked + /// choice resurrected the aperture slider the instant Cinematic re-enabled + /// — a control appearing (and displacing attention) with no tap. A slider + /// is on screen only because the user opened it since its tile appeared. var visibleProSlider: ProSliderKind? { - guard let kind = activeProSlider, focusedProTiles.contains(kind.tile) else { return nil } + guard let kind = activeProSlider else { return nil } + guard focusedProTiles.contains(kind.tile) else { + DispatchQueue.main.async { [weak self] in + if self?.activeProSlider == kind { self?.activeProSlider = nil } + } + return nil + } return kind } diff --git a/RemoteCamTests/MulticamViewModelTests.swift b/RemoteCamTests/MulticamViewModelTests.swift index a0017221..2de7062b 100644 --- a/RemoteCamTests/MulticamViewModelTests.swift +++ b/RemoteCamTests/MulticamViewModelTests.swift @@ -72,9 +72,11 @@ final class MulticamViewModelTests: XCTestCase { } /// An open slider stays only while the focused camera still offers its - /// tile: refocusing onto a camera without manual exposure hides it (the - /// choice is remembered, so focusing back restores it). - func testOpenSliderFollowsTheFocusedCamera() { + /// tile — and once the tile vanishes the choice is CLEARED, never parked. + /// A parked choice resurrected the aperture slider the instant Cinematic + /// re-enabled, displacing the zoom pill with no tap (field report). + /// A slider is on screen only because the user opened it since. + func testClosedSliderNeverAutoRevives() { let vm = MulticamViewModel() vm.apply([info(camA, focused: true, supportsManualExposure: true), info(camB)]) vm.activeProSlider = .shutter @@ -82,9 +84,15 @@ final class MulticamViewModelTests: XCTestCase { vm.apply([info(camA, supportsManualExposure: true), info(camB, focused: true)]) XCTAssertNil(vm.visibleProSlider, "camB has no shutter to slide") + // The vanished tile clears the stored choice (async main-hop). + let cleared = expectation(description: "choice cleared") + DispatchQueue.main.async { cleared.fulfill() } + wait(for: [cleared], timeout: 1) + XCTAssertNil(vm.activeProSlider, "a hidden slider must not stay armed") vm.apply([info(camA, focused: true, supportsManualExposure: true), info(camB)]) - XCTAssertEqual(vm.visibleProSlider, .shutter) + XCTAssertNil(vm.visibleProSlider, + "the tile returning must NOT resurrect the slider — no tap, no slider") } /// The shutter is a broadcast: cameras present is enough — focus is From 9a01ab952803e86d967fc7f9d7df064216114be4 Mon Sep 17 00:00:00 2001 From: Dario Lencina Date: Thu, 27 Aug 2026 01:38:18 -0700 Subject: [PATCH 14/14] Purity pass on the snapshot-to-UI path: no mutating reads, one hop, one rule - ProSliderIntent.reconcile is THE open-slider rule, pure and table-tested; both remote screens apply it from their write paths (director: apply()/ mode change; 1:1: the onChange reconciliation). The director's visibleProSlider getter no longer dispatches a mutation from a read. - MonitorViewModel.applyControlState is synchronous on main (presenter already hops); the redundant second enqueue and its reordering surface are gone. The transform is now pure end to end: two mutable cells per screen (the snapshot, the slider intent), each written in one place, each fed by a pure function. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01K5uHRuiiLu5FEBiHZkBWo3 --- RemoteCam/MonitorView.swift | 17 +++++------ RemoteCam/MonitorViewModel.swift | 5 +++- RemoteCam/MulticamViewModel.swift | 33 +++++++++++---------- RemoteCam/ProSliderPill.swift | 16 ++++++++++ RemoteCamTests/MulticamViewModelTests.swift | 5 +--- RemoteCamTests/ProSliderScaleTests.swift | 20 +++++++++++++ 6 files changed, 66 insertions(+), 30 deletions(-) diff --git a/RemoteCam/MonitorView.swift b/RemoteCam/MonitorView.swift index a5b830dd..cdf785fd 100644 --- a/RemoteCam/MonitorView.swift +++ b/RemoteCam/MonitorView.swift @@ -77,11 +77,11 @@ struct MonitorView: View { SessionDebugOverlay() #endif } - // A slider whose tile vanished is closed for good, not parked. + // A slider whose tile vanished is closed for good, not parked — + // the write-path half of ProSliderIntent.reconcile. .onChange(of: proTiles) { tiles in - if let kind = activeProSlider, !tiles.contains(kind.tile) { - activeProSlider = nil - } + let resolved = ProSliderIntent.reconcile(active: activeProSlider, offeredTiles: tiles) + if resolved != activeProSlider { activeProSlider = resolved } } } // Catalyst's default style paints a bordered box behind controls that @@ -403,13 +403,10 @@ struct MonitorView: View { apertureAdjustable: (viewModel.cinematic?.minSimulatedAperture ?? 0) > 0) } - /// The open slider, as long as its tile is still offered. A vanished tile - /// CLEARS the choice (see the onChange below) rather than parking it — a - /// parked slider auto-revived when the tile returned, displacing the zoom - /// pill with no tap. + /// The open slider — the same pure rule the director uses; the write + /// happens in the `onChange` reconciliation, never in a read. private var visibleProSlider: ProSliderKind? { - guard let kind = activeProSlider, proTiles.contains(kind.tile) else { return nil } - return kind + ProSliderIntent.reconcile(active: activeProSlider, offeredTiles: proTiles) } /// Zoom and the pro slider are NOT rivals: Apple supports zoom while diff --git a/RemoteCam/MonitorViewModel.swift b/RemoteCam/MonitorViewModel.swift index e2ee56fd..57d43afa 100644 --- a/RemoteCam/MonitorViewModel.swift +++ b/RemoteCam/MonitorViewModel.swift @@ -76,8 +76,11 @@ class MonitorViewModel: ObservableObject { /// Fold in the latest snapshot. The stale-drop rule (`absorb`) is applied /// HERE, where the value lives — callers cannot hand this model a state /// older than the one it shows, whatever order deliveries arrive in. + /// Synchronous on purpose: the presenter already hops to main, and a + /// second enqueue would only add a reordering surface to reason about. func applyControlState(_ state: ControlState) { - DispatchQueue.main.async { self.controlState = ControlState.absorb(self.controlState, state) } + dispatchPrecondition(condition: .onQueue(.main)) + controlState = ControlState.absorb(controlState, state) } // MARK: - Zoom and Lens Properties (derived from `controlState`) diff --git a/RemoteCam/MulticamViewModel.swift b/RemoteCam/MulticamViewModel.swift index 3b4f8119..33ea7190 100644 --- a/RemoteCam/MulticamViewModel.swift +++ b/RemoteCam/MulticamViewModel.swift @@ -83,8 +83,11 @@ final class MulticamViewModel: ObservableObject { @Published var isRecording: Bool = false /// When the rig actually started rolling — drives the classic /// `RecordingTimer` in the top bar. Nil unless recording. - /// Photo vs video shutter mode. - @Published var mode: MonitorMode = .photo + /// Photo vs video shutter mode. Changing it can retract pro tiles + /// (Cinematic is video-only), so the slider intent reconciles here. + @Published var mode: MonitorMode = .photo { + didSet { if mode != oldValue { reconcileProSlider() } } + } /// Focus (viewfinder + strip) vs grid (monitor wall) layout. @Published var displayMode: MulticamDisplayMode = .focus /// Cameras discovered but not yet in the rig — the add-camera sheet's list. @@ -151,20 +154,18 @@ final class MulticamViewModel: ObservableObject { apertureAdjustable: (focused.cinematic?.minSimulatedAperture ?? 0) > 0) } - /// The open slider, as long as the focused camera still offers its tile. - /// When the tile goes away the choice is CLEARED, never parked: a parked - /// choice resurrected the aperture slider the instant Cinematic re-enabled - /// — a control appearing (and displacing attention) with no tap. A slider - /// is on screen only because the user opened it since its tile appeared. + /// The open slider — a pure read of `ProSliderIntent.reconcile`. The + /// clearing itself happens at the WRITE sites (`apply`, mode changes), + /// never here: a getter must not mutate. var visibleProSlider: ProSliderKind? { - guard let kind = activeProSlider else { return nil } - guard focusedProTiles.contains(kind.tile) else { - DispatchQueue.main.async { [weak self] in - if self?.activeProSlider == kind { self?.activeProSlider = nil } - } - return nil - } - return kind + ProSliderIntent.reconcile(active: activeProSlider, offeredTiles: focusedProTiles) + } + + /// Write-path reconciliation: a slider whose tile vanished is closed for + /// good. Called wherever the offered tiles can change. + private func reconcileProSlider() { + let resolved = ProSliderIntent.reconcile(active: activeProSlider, offeredTiles: focusedProTiles) + if resolved != activeProSlider { activeProSlider = resolved } } /// The director's mode in the 1:1 monitor's vocabulary, for shared rules. @@ -220,6 +221,8 @@ final class MulticamViewModel: ObservableObject { for gone in existing.values { gone.receiver.invalidate() } lanes = next + // Lane churn can change the focused camera's offered tiles. + reconcileProSlider() return created } diff --git a/RemoteCam/ProSliderPill.swift b/RemoteCam/ProSliderPill.swift index 12dd682d..4e115837 100644 --- a/RemoteCam/ProSliderPill.swift +++ b/RemoteCam/ProSliderPill.swift @@ -90,6 +90,22 @@ struct ProSliderScale: Equatable { } } +// MARK: - Intent + +/// The ONE rule for whether an opened slider stays open: it survives only +/// while its tile is still offered (the camera swapped, left video mode, or +/// Cinematic turned off). Pure — both remote screens apply it from their +/// WRITE paths, so no read ever mutates, and a vanished tile clears the +/// choice instead of parking it (a parked choice once resurrected the +/// aperture slider the instant Cinematic re-enabled, displacing the zoom +/// pill with no tap). +enum ProSliderIntent { + static func reconcile(active: ProSliderKind?, offeredTiles: [MonitorTrayItem]) -> ProSliderKind? { + guard let active, offeredTiles.contains(active.tile) else { return nil } + return active + } +} + // MARK: - Pill struct ProSliderPill: View { diff --git a/RemoteCamTests/MulticamViewModelTests.swift b/RemoteCamTests/MulticamViewModelTests.swift index 2de7062b..67733211 100644 --- a/RemoteCamTests/MulticamViewModelTests.swift +++ b/RemoteCamTests/MulticamViewModelTests.swift @@ -84,10 +84,7 @@ final class MulticamViewModelTests: XCTestCase { vm.apply([info(camA, supportsManualExposure: true), info(camB, focused: true)]) XCTAssertNil(vm.visibleProSlider, "camB has no shutter to slide") - // The vanished tile clears the stored choice (async main-hop). - let cleared = expectation(description: "choice cleared") - DispatchQueue.main.async { cleared.fulfill() } - wait(for: [cleared], timeout: 1) + // The write path cleared the stored choice synchronously. XCTAssertNil(vm.activeProSlider, "a hidden slider must not stay armed") vm.apply([info(camA, focused: true, supportsManualExposure: true), info(camB)]) diff --git a/RemoteCamTests/ProSliderScaleTests.swift b/RemoteCamTests/ProSliderScaleTests.swift index c301a892..68eec5d5 100644 --- a/RemoteCamTests/ProSliderScaleTests.swift +++ b/RemoteCamTests/ProSliderScaleTests.swift @@ -85,6 +85,26 @@ final class ProSliderScaleTests: XCTestCase { } } +/// The one open-slider rule both remote screens apply from their write +/// paths: a slider survives only while its tile is offered; a vanished tile +/// clears the choice — it never parks and never auto-revives. +final class ProSliderIntentTests: XCTestCase { + + func testSurvivesWhileItsTileIsOffered() { + XCTAssertEqual(ProSliderIntent.reconcile(active: .shutter, offeredTiles: [.shutter, .iso]), .shutter) + XCTAssertEqual(ProSliderIntent.reconcile(active: .aperture, + offeredTiles: [.shutter, .iso, .cinematic, .aperture]), .aperture) + } + + func testClearsWhenTheTileVanishes() { + XCTAssertNil(ProSliderIntent.reconcile(active: .aperture, offeredTiles: [.shutter, .iso, .cinematic]), + "Cinematic off retracts the aperture tile — the choice must die with it") + XCTAssertNil(ProSliderIntent.reconcile(active: .shutter, offeredTiles: []), + "a camera without manual exposure offers nothing to slide") + XCTAssertNil(ProSliderIntent.reconcile(active: nil, offeredTiles: [.shutter])) + } +} + /// The slider's send throttle: leading edge for responsiveness, trailing /// edge so the value the finger released on always reaches the wire — the /// zoom pill's send pattern (`ZoomSendThrottle`), packaged per slider.