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 new file mode 100644 index 00000000..77d8dbc1 --- /dev/null +++ b/Docs/pro-controls.md @@ -0,0 +1,384 @@ +# 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 +> (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. + +## 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 (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 ControlRefusal : byte { Unknown, None, PhotoMode, Recording, Unsupported, SessionRefused } + +// CommandAction +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) +} +``` + +`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. +`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) + +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 + 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)` → + `respondWithControlState { try await ctrl.setExposure(intent) }`; 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 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 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 +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 + +``` +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. +- `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` + 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)** — 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. +- 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: + `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** — per focused camera (above); +broadcasting one setting to N cameras is a follow-up. + +## Monetization + +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 + +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..baa1ecca 100644 --- a/RemoteCam/CameraControlling.swift +++ b/RemoteCam/CameraControlling.swift @@ -41,12 +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 - func switchLens(to lensType: CameraLensType) async throws -> (CameraLensType, [CameraLensType], CGFloat, RemoteCmd.ZoomRange) + /// Auto or manual (shutter + ISO) exposure. The device clamps into its + /// 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 eb4770f5..781e84cc 100644 --- a/RemoteCam/CameraLink.swift +++ b/RemoteCam/CameraLink.swift @@ -91,14 +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 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 @@ -124,12 +122,8 @@ final class CameraLink { canFlipCamera: capabilities.map { $0.frontCamera != nil && $0.backCamera != nil } ?? false, - supportsFocusPoint: capabilities?.supportsFocusPoint ?? false, + 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 be884219..6a07049b 100644 --- a/RemoteCam/CameraRig.swift +++ b/RemoteCam/CameraRig.swift @@ -84,7 +84,19 @@ 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() } + // 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) + } + } } // MARK: - Shell seams @@ -145,6 +157,21 @@ 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 } + // Cinematic is a video-recording effect; mode truth lives here. + 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 @@ -512,10 +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 -> ControlState { + let state = try await engine.setExposure(intent) + cameraViewModel.updateExposureReadout(state.exposure) + return state + } + + func setCinematic(_ intent: CinematicIntent) async throws -> ControlState { + let state = try await engine.setCinematic(intent) + 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 @@ -524,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/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..79ab9b24 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) + ? state.map { "M \(ProStops.shutterLabel($0.durationSeconds)) · \(ProStops.isoLabel($0.iso))" } + : nil + self.recomposeProReadout() + } + } + + func updateCinematicReadout(_ state: CinematicState?) { + DispatchQueue.main.async { [weak self] in + guard let self else { return } + self.cinematicReadoutText = (state?.enabled == true) + ? state.map { "CINEMATIC \(ProStops.apertureLabel($0.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 920adcd8..26222697 100644 --- a/RemoteCam/CaptureEngine.swift +++ b/RemoteCam/CaptureEngine.swift @@ -120,6 +120,26 @@ 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 } + /// 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)` /// on failure. The VC relays this to the session actor exactly as before. @@ -127,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 @@ -264,6 +294,13 @@ 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.manualExposureRestoreDeviceID = nil + _ = self.applyExposureIntentLocked() + self.cinematicIntent = .off + _ = try? self.applyCinematicIntentLocked() if self.captureSession.isRunning { self.captureSession.stopRunning() } @@ -275,12 +312,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 @@ -317,7 +386,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 } @@ -368,6 +437,8 @@ 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 + _ = 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 { @@ -376,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) } @@ -624,7 +696,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) } } } @@ -647,7 +719,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 @@ -923,6 +995,16 @@ 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 + 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)") } guard !videoDevices.isEmpty else { @@ -943,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) @@ -978,26 +1047,46 @@ 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 ) } + /// 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 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 } + return cinematicStateLocked(input: input, device: device) + } + // MARK: - Current Camera Capabilities for Toggle Response func gatherCurrentCameraCapabilities() async -> RemoteCmd.CameraCapabilitiesResp? { await onSessionQueue { self.gatherCurrentCameraCapabilitiesLocked() } @@ -1017,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, @@ -1041,6 +1123,9 @@ final class CaptureEngine: NSObject, AVCapturePhotoCaptureDelegate { // release the director UI ships. supportsMulticam: FeatureFlags.ENABLE_MULTICAM, previewMode: CameraPreviewModeStore().load(), + // The control-plane seed: the same snapshot ControlStateChanged + // pushes, so the first exchange configures the remote completely. + control: controlStateLocked(), error: nil ) @@ -1052,7 +1137,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, @@ -1072,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 @@ -1085,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` @@ -1115,9 +1238,22 @@ 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. + if #available(iOS 26.0, macCatalyst 26.0, *), + videoDeviceInput?.isCinematicVideoCaptureEnabled == true { + device.setCinematicVideoTrackingFocus(at: poi, focusMode: .strong) + debugLog("🎬 CINEMATIC: tracking focus at \(poi)") + return + } + if device.isFocusPointOfInterestSupported { device.focusPointOfInterest = poi if device.isFocusModeSupported(.continuousAutoFocus) { @@ -1126,7 +1262,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 @@ -1146,22 +1284,436 @@ 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 } } - 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 -> ControlState { + try await onSessionQueueThrowing { + self.exposureIntent = intent + self.reconcileExposureDeviceLocked() + guard self.applyExposureIntentLocked() != nil, + let state = self.controlStateLocked() 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) + } + + /// True when Manual exposure is worth offering on this device: it accepts + /// `.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 { + 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 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 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) + 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 -> ControlState { + try await onSessionQueueThrowing { + self.cinematicIntent = intent + guard try self.applyCinematicIntentLocked() != nil, + let state = self.controlStateLocked() 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) + } + } + + /// 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 + /// (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 + _ = try? self.applyCinematicIntentLocked() + } + } + + /// The ONE place that touches `isCinematicVideoCaptureEnabled` and + /// `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() 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 { + 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): + 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): + // 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() + } + + // 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 { + cinematicIntent = .off + 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). + 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) } + 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 + } } - private func setZoomLocked(zoomFactor: CGFloat) throws -> (CGFloat, CameraLensType, RemoteCmd.ZoomRange) { + /// 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 { dispatchPrecondition(condition: .onQueue(sessionQueue)) debugLog("🔍 DEBUG: setZoom called with factor: \(zoomFactor)") @@ -1170,15 +1722,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 @@ -1190,13 +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: device.minAvailableVideoZoomFactor, - maxZoom: device.maxAvailableVideoZoomFactor - ) - - return (clampedZoom, currentLensType, zoomRange) } catch let error as NSError { debugLog("❌ DEBUG: Error setting zoom: \(error.localizedDescription)") throw error @@ -1208,11 +1753,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 @@ -1255,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) @@ -1273,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] { @@ -1472,11 +2023,15 @@ final class CaptureEngine: NSObject, AVCapturePhotoCaptureDelegate { } applyDesiredTorchLocked() // changing activeFormat/preset also resets the torch + _ = applyExposureIntentLocked() // ranges and the frame-rate cap changed with the format + _ = try? applyCinematicIntentLocked() // a format change silently reverts Cinematic; re-assert the intent currentVideoResolution = resolution 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/CinematicPolicy.swift b/RemoteCam/CinematicPolicy.swift new file mode 100644 index 00000000..d16e7226 --- /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 ProStops { + + /// 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/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/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/FeatureFlags.swift b/RemoteCam/FeatureFlags.swift index ca40a4dd..f4cd70f1 100644 --- a/RemoteCam/FeatureFlags.swift +++ b/RemoteCam/FeatureFlags.swift @@ -31,10 +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 + /// Pro controls (issue #206): manual shutter/ISO + Cinematic video from + /// 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. Off until the feature ships (target 9.1.0); while off, - /// cameras advertise `supports_multicam=false` and the scanner keeps its - /// single-camera flow. + /// 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/FlatBufferSchemas.fbs b/RemoteCam/FlatBufferSchemas.fbs index 45e6a51d..09e142af 100644 --- a/RemoteCam/FlatBufferSchemas.fbs +++ b/RemoteCam/FlatBufferSchemas.fbs @@ -66,10 +66,49 @@ 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_*). + // 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 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. +enum ExposureMode : byte { + Unknown = 0, + Auto = 1, + 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 // compositing only — the capture session and the frames streamed to the monitor @@ -221,6 +260,14 @@ 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; + // SetCinematic payload. Aperture 0 = keep the camera's current value. + cinematic_enabled: bool; + simulated_aperture: float; } // MARK: - Command Structure @@ -232,14 +279,62 @@ table CameraCommand { // MARK: - State Structures -table ZoomRange { +// 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; +} + +// 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; +} + +// 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 { @@ -258,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 @@ -305,26 +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; + control: ControlState; } // MARK: - Response Structure @@ -337,13 +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 + // 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 27c41298..d060e111 100644 --- a/RemoteCam/FlatBufferSchemas_generated.swift +++ b/RemoteCam/FlatBufferSchemas_generated.swift @@ -41,12 +41,44 @@ public enum RemoteShutter_CommandAction: Int8, Enum, Verifiable { case requestvideoresend = 30 case camerastatereport = 31 case requestcamerastatereport = 32 + case setexposure = 33 + case setcinematic = 34 + case controlstatechanged = 35 - public static var max: RemoteShutter_CommandAction { return .requestcamerastatereport } + public static var max: RemoteShutter_CommandAction { return .controlstatechanged } 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_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 } @@ -364,6 +396,11 @@ public struct RemoteShutter_CommandParameters: FlatBufferObject, Verifiable { case stateReportSeq = 60 case stateRecordingPhase = 62 case stateRecordingElapsedMs = 64 + 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 } } @@ -404,7 +441,12 @@ 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 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) } @@ -437,6 +479,12 @@ 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 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, @@ -470,7 +518,12 @@ 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, + cinematicEnabled: Bool = false, + simulatedAperture: Float32 = 0.0 ) -> Offset { let __start = RemoteShutter_CommandParameters.startCommandParameters(&fbb) RemoteShutter_CommandParameters.add(sendToRemote: sendToRemote, &fbb) @@ -504,6 +557,11 @@ 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) + RemoteShutter_CommandParameters.add(cinematicEnabled: cinematicEnabled, &fbb) + RemoteShutter_CommandParameters.add(simulatedAperture: simulatedAperture, &fbb) return RemoteShutter_CommandParameters.endCommandParameters(&fbb, start: __start) } @@ -540,6 +598,11 @@ 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) + 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() } } @@ -587,88 +650,267 @@ public struct RemoteShutter_CameraCommand: FlatBufferObject, Verifiable { } } -public struct RemoteShutter_ZoomRange: 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_ZoomRange.id, addPrefix: prefix) } + 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 minZoom = 4 - case maxZoom = 6 + 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 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 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 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, - minZoom: Double = 0.0, - maxZoom: Double = 0.0 + 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_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_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.minZoom.p, fieldName: "minZoom", required: false, type: Double.self) - try _v.visit(field: VTOFFSET.maxZoom.p, fieldName: "maxZoom", required: false, type: Double.self) + 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_ZoomCapability: 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_ZoomCapability.id, addPrefix: prefix) } + 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 lensType = 4 - case zoomRange = 6 + 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 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( + 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, - lensType: RemoteShutter_CameraLensType = .wideangle, - zoomRangeOffset zoomRange: Offset = Offset() + 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_ZoomCapability.startZoomCapability(&fbb) - RemoteShutter_ZoomCapability.add(lensType: lensType, &fbb) - RemoteShutter_ZoomCapability.add(zoomRange: zoomRange, &fbb) - return RemoteShutter_ZoomCapability.endZoomCapability(&fbb, start: __start) + 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.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.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_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_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 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 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 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, + zoomStopsVectorOffset zoomStops: Offset = Offset(), + wideAngleZoomFactor: Double = 0.0, + supportsFocusPoint: Bool = false, + exposureOffset exposure: Offset = Offset(), + cinematicOffset cinematic: Offset = Offset() + ) -> Offset { + 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) + 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() } } @@ -833,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 } } @@ -847,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) } @@ -897,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() } } @@ -1103,10 +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 supportsPreviewMode = 10 + case supportsMulticam = 12 + case control = 14 var v: Int32 { Int32(self.rawValue) } var p: VOffset { self.rawValue } } @@ -1116,41 +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 static func startCameraCapabilities(_ fbb: inout FlatBufferBuilder) -> UOffset { fbb.startTable(with: 7) } + 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(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 + supportsMulticam: Bool = false, + 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(control: control, &fbb) return RemoteShutter_CameraCapabilities.endCameraCapabilities(&fbb, start: __start) } @@ -1159,10 +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.control.p, fieldName: "control", required: false, type: ForwardOffset.self) _v.finish() } } @@ -1186,12 +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 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 } } @@ -1207,15 +1418,14 @@ 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 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, @@ -1225,12 +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(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, @@ -1241,12 +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() + captureIdEchoOffset captureIdEcho: 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) @@ -1256,12 +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(control: control, &fbb) + RemoteShutter_CameraStateResponse.add(controlRefusal: controlRefusal, &fbb) + RemoteShutter_CameraStateResponse.add(controlRefusalDetail: controlRefusalDetail, &fbb) return RemoteShutter_CameraStateResponse.endCameraStateResponse(&fbb, start: __start) } @@ -1274,12 +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.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/MonitorChrome.swift b/RemoteCam/MonitorChrome.swift index 2082702e..1f998a0c 100644 --- a/RemoteCam/MonitorChrome.swift +++ b/RemoteCam/MonitorChrome.swift @@ -73,6 +73,12 @@ enum MonitorTrayItem: Equatable { /// Puts the peer camera's *local* preview to sleep. It keeps capturing and /// keeps streaming here. case cameraStandby + /// 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 } @@ -87,7 +93,8 @@ enum MonitorTray { supportsHDR: Bool, supportsCameraStandby: Bool, resolutionCount: Int, - frameRateCount: Int) -> [MonitorTrayItem] { + frameRateCount: Int, + proTiles: [MonitorTrayItem] = []) -> [MonitorTrayItem] { var items: [MonitorTrayItem] = [] // Shorts runs to a fixed duration, so a self-timer has nothing to delay. @@ -107,12 +114,37 @@ 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) } items.append(.settings) items.append(.help) return items } + + /// 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 + if videoish, supportsCinematicVideo { + tiles.append(.cinematic) + if cinematicOn, apertureAdjustable { tiles.append(.aperture) } + } + return tiles + } } // MARK: - Link health 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 9fe30629..c9721f59 100644 --- a/RemoteCam/MonitorPresenter.swift +++ b/RemoteCam/MonitorPresenter.swift @@ -91,60 +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 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 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, @@ -160,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/MonitorView.swift b/RemoteCam/MonitorView.swift index 3e1e8517..cdf785fd 100644 --- a/RemoteCam/MonitorView.swift +++ b/RemoteCam/MonitorView.swift @@ -36,8 +36,17 @@ 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). + /// 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 + /// The pro slider on the viewfinder, in the zoom pill's slot. + @State private var activeProSlider: ProSliderKind? var body: some View { GeometryReader { geometry in @@ -68,6 +77,12 @@ struct MonitorView: View { SessionDebugOverlay() #endif } + // A slider whose tile vanished is closed for good, not parked — + // the write-path half of ProSliderIntent.reconcile. + .onChange(of: proTiles) { tiles in + let resolved = ProSliderIntent.reconcile(active: activeProSlider, offeredTiles: tiles) + if resolved != activeProSlider { activeProSlider = resolved } + } } // Catalyst's default style paints a bordered box behind controls that // already draw their own shape. Not .plain — that also drops the @@ -229,9 +244,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 } @@ -248,9 +261,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 } @@ -363,7 +374,8 @@ struct MonitorView: View { supportsHDR: viewModel.supportsHDR, supportsCameraStandby: viewModel.supportsCameraStandby, resolutionCount: viewModel.supportedResolutions.count, - frameRateCount: availableFrameRates.count), + frameRateCount: availableFrameRates.count, + proTiles: proTiles), timerValue: Int(viewModel.timerSliderValue), aspectRatio: viewModel.currentAspectRatio, resolution: viewModel.currentVideoResolution, @@ -371,6 +383,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, @@ -379,6 +393,61 @@ struct MonitorView: View { } } + // 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) + } + + /// 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? { + ProSliderIntent.reconcile(active: activeProSlider, offeredTiles: proTiles) + } + + /// 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 { + 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) + } + } + + 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) + } + } + private var availableFrameRates: [VideoFrameRate] { let rates = viewModel.resolutionFrameRates[viewModel.currentVideoResolution] return (rates?.isEmpty == false) ? rates! : viewModel.supportedFrameRates @@ -417,6 +486,16 @@ struct MonitorView: View { // it is worth watching settle. onToggleCameraStandby() + 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() + 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() onSettingsTapped() @@ -676,6 +755,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 @@ -702,8 +784,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, .settings, .help: return nil + case .hdr, .cameraStandby, .cinematic, .settings, .help: return nil } } @@ -712,6 +798,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 } } @@ -722,6 +810,10 @@ struct MonitorTrayPanel: View { case .aspect, .resolution, .frameRate, .format, .hdr: return isQualityEnabled // Not a capture setting: usable mid-recording. case .cameraStandby: 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 } @@ -781,6 +873,10 @@ struct MonitorTrayTile: View { case .format: return "doc" case .hdr: return "camera.filters" case .cameraStandby: return isActive ? "moon.zzz.fill" : "moon.zzz" + 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" } @@ -795,6 +891,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 .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 e01b6be0..c722c7e2 100644 --- a/RemoteCam/MonitorViewController+SwiftUI.swift +++ b/RemoteCam/MonitorViewController+SwiftUI.swift @@ -61,12 +61,35 @@ 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) + }, + 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)") @@ -248,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) @@ -352,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 668d07af..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; @@ -63,8 +65,10 @@ 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 var buttonPrompt: String = "" diff --git a/RemoteCam/MonitorViewModel.swift b/RemoteCam/MonitorViewModel.swift index 2e13643f..57d43afa 100644 --- a/RemoteCam/MonitorViewModel.swift +++ b/RemoteCam/MonitorViewModel.swift @@ -62,21 +62,39 @@ 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. + /// 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) { + dispatchPrecondition(condition: .onQueue(.main)) + controlState = ControlState.absorb(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 +220,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 { @@ -261,6 +262,20 @@ class MonitorViewModel: ObservableObject { /// 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. + 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). + var supportsCinematicVideo: Bool { controlState?.supportsCinematicVideo ?? false } + /// The camera's echoed Cinematic truth. + var cinematic: CinematicState? { controlState?.cinematic } + // MARK: - Video Quality Update Methods func updateVideoQuality(resolution: VideoResolution, frameRate: VideoFrameRate) { DispatchQueue.main.async { diff --git a/RemoteCam/MultiCamChrome.swift b/RemoteCam/MultiCamChrome.swift index 9551530c..8c6de216 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, + proTiles: [MonitorTrayItem] = []) -> [MonitorTrayItem] { var items: [MonitorTrayItem] = [.timer, .aspect] switch mode { @@ -68,9 +69,23 @@ enum RigTray { items.append(contentsOf: [.format, .hdr]) } + // Pro controls drive the FOCUSED camera (like torch and zoom), so the + // 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 } } + +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..30ad688b 100644 --- a/RemoteCam/MulticamController.swift +++ b/RemoteCam/MulticamController.swift @@ -64,18 +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 + /// 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 @@ -221,6 +219,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 +474,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() @@ -586,7 +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) + // 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 @@ -608,6 +623,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 @@ -623,15 +643,26 @@ public actor MulticamController { } if let caps = resp.cameraCapabilities { link.capabilities = caps - seedZoom(link, from: caps) + if let control = caps.control { + link.control = ControlState.absorb(link.control, control) + } } - 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: @@ -857,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)) } @@ -866,6 +897,44 @@ 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) { + // 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]?.control?.cinematic != nil else { return } + sendTo(target, RemoteCmd.SetCinematic(intent: intent)) + } + + /// 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) { @@ -910,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. @@ -955,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. @@ -1835,6 +1893,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 78e8620f..669dc623 100644 --- a/RemoteCam/MulticamView.swift +++ b/RemoteCam/MulticamView.swift @@ -58,6 +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 slider is + /// rendered for it, and the command carries it, like every per-camera + /// 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 @@ -131,6 +139,17 @@ struct MulticamView: View { onSetHDR: onSetHDR, onSetAspectRatio: onSetAspectRatio, onSetStandby: onSetStandby, + proTiles: viewModel.focusedProTiles, + exposure: viewModel.focusedLane?.exposure, + cinematic: viewModel.focusedLane?.cinematic, + onOpenProSlider: { kind in + viewModel.showingRigTray = false + 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. onOpenSettings: { @@ -296,13 +315,45 @@ 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 && 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 { + 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) }) + } + } + } + } + + 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) } } @@ -814,6 +865,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'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). @@ -823,7 +881,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, + proTiles: proTiles), id: \.self) { item in tile(for: item) } @@ -872,6 +931,27 @@ struct RigTrayPanel: View { MonitorTrayTile(item: .help, value: nil, isActive: false, isEnabled: true, action: onOpenHelp) + // 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 23317883..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. @@ -60,6 +64,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 +103,12 @@ 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) }, + 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) @@ -153,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 9755090f..33ea7190 100644 --- a/RemoteCam/MulticamViewModel.swift +++ b/RemoteCam/MulticamViewModel.swift @@ -37,12 +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 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 } @@ -78,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. @@ -90,6 +98,9 @@ final class MulticamViewModel: ObservableObject { @Published var rigSettings = RigSettingsSnapshot() /// Whether the rig settings tray is showing. @Published var showingRigTray: 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 @@ -131,6 +142,40 @@ final class MulticamViewModel: ObservableObject { displayMode == .focus && (focusedLane?.hasTorch ?? false) } + /// 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 — 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? { + 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. + 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 @@ -176,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 new file mode 100644 index 00000000..4e115837 --- /dev/null +++ b/RemoteCam/ProSliderPill.swift @@ -0,0 +1,179 @@ +// +// 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) })) + } + + /// 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 String(Int(value.rounded())) + case .aperture: return ProStops.apertureLabel(Float(value)) + } + } +} + +// 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 { + 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/RemoteCmdFlatBuffers.swift b/RemoteCam/RemoteCmdFlatBuffers.swift index 73caa820..146566c3 100644 --- a/RemoteCam/RemoteCmdFlatBuffers.swift +++ b/RemoteCam/RemoteCmdFlatBuffers.swift @@ -39,14 +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.SetCinematic: 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() @@ -257,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 { @@ -295,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 ) } @@ -321,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 ) } @@ -420,28 +382,24 @@ private func encodeCapabilitiesEnvelope( } devicesVector = fbb.createVector(ofOffsets: deviceOffsets) } - let activeIDOffset = c.activeDeviceID.map { fbb.create(string: $0) } ?? Offset() + 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) + supportsMulticam: c.supportsMulticam, + 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) @@ -602,6 +560,123 @@ 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.ControlStateChanged { + func toFlatBuffer() -> Data { + var fbb = FlatBufferBuilder() + let detailOffset = refusalDetail.map { fbb.create(string: $0) } ?? Offset() + let controlOffset = encodeControlState(state, &fbb) + let resp = RemoteShutter_CameraStateResponse.createCameraStateResponse( + &fbb, + 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() + 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.EndSession { func toFlatBuffer() -> Data { var fbb = FlatBufferBuilder() @@ -632,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) @@ -677,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() @@ -1141,6 +1147,74 @@ 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) +} + +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 { @@ -1361,11 +1435,33 @@ 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 .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)) case .endsession: return EndSession() + + case .controlstatechanged: + // A response-only action arriving as a command is malformed. + return nil } } @@ -1424,35 +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 .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.. 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) + } + } + + /// 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/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: ControlState, refusal: ControlRefusalReason? = nil, + refusalDetail: String? = nil) { + self.state = state + self.refusal = refusal + self.refusalDetail = refusalDetail + 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 @@ -451,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]] { @@ -501,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) @@ -547,64 +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 + /// 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, + 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.control = control self.error = error super.init(sender: nil) } @@ -661,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 @@ -813,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/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/SessionCoordinator.swift b/RemoteCam/SessionCoordinator.swift index 0c7f6628..583af7d5 100644 --- a/RemoteCam/SessionCoordinator.swift +++ b/RemoteCam/SessionCoordinator.swift @@ -227,15 +227,25 @@ 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. @@ -790,8 +800,8 @@ public actor SessionCoordinator { func popToScanning() async { lastCameraStateReportSeq = 0 peerAdvertisedCameraDevices = false - peerSupportsFocusPoint = false peerSupportsPreviewMode = 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 @@ -1226,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 { @@ -1246,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 { @@ -1283,29 +1293,26 @@ 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 // without a focus point simply ignores it. try? await ctrl.focusAtPoint(x: focus.x, y: focus.y) + case let exposure as RemoteCmd.SetExposure: + await respondWithControlState(ctrl) { try await ctrl.setExposure(exposure.intent) } + + case let cinematic as RemoteCmd.SetCinematic: + 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 @@ -1387,10 +1394,49 @@ public actor SessionCoordinator { /// recording-truth derivation on top. private func absorbCapabilities(_ capabilities: RemoteCmd.CameraCapabilitiesResp) { peerAdvertisedCameraDevices = !capabilities.cameraDevices.isEmpty - peerSupportsFocusPoint = capabilities.supportsFocusPoint peerSupportsPreviewMode = capabilities.supportsPreviewMode monitor?.updateCapabilities(capabilities) monitor?.updatePreviewMode(capabilities.previewMode) + // 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 mutate() + await sendOrGoToScanning(RemoteCmd.ControlStateChanged(state: state)) + } catch let refusal as CaptureEngine.CinematicRefusal { + guard let state = await ctrl.controlState() else { return } + await sendOrGoToScanning(RemoteCmd.ControlStateChanged( + state: state, refusal: refusal.reason, refusalDetail: refusal.detail)) + } catch { + guard let state = await ctrl.controlState() else { return } + await sendOrGoToScanning(RemoteCmd.ControlStateChanged( + state: state, refusal: .sessionRefused, refusalDetail: (error as NSError).domain)) + } } /// "The switch didn't stick." The message rides in the NSError domain — @@ -1672,28 +1718,26 @@ 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. 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 respondWithControlState(ctrl) { try await ctrl.setExposure(exposure.intent) } + + case let cinematic as RemoteCmd.SetCinematic: + // 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 @@ -2114,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: @@ -2385,12 +2427,33 @@ 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 } 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 (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 cinematic as UICmd.SetCinematic: + guard peerControl?.cinematic != nil else { + debugLog("SetCinematic dropped: peer did not advertise Cinematic support") + break + } + sendMessage(RemoteCmd.SetCinematic(intent: cinematic.intent)) + + 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 // peer that predates it (it would misread the unknown action). @@ -2400,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) @@ -2549,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 { @@ -2594,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: @@ -2731,12 +2781,31 @@ 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 } 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 (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 cinematic as UICmd.SetCinematic: + guard peerControl?.cinematic != nil else { + debugLog("SetCinematic dropped: peer did not advertise Cinematic support") + break + } + sendMessage(RemoteCmd.SetCinematic(intent: cinematic.intent)) + + case let changed as RemoteCmd.ControlStateChanged: + absorbControlState(changed.state, refusal: changed.refusal, detail: changed.refusalDetail) + case let preview as UICmd.SetCameraPreviewMode: guard peerSupportsPreviewMode else { debugLog("SetCameraPreviewMode dropped: peer did not advertise preview-mode support") @@ -2744,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/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/RemoteCam/StoreManager.swift b/RemoteCam/StoreManager.swift index 7e2f5b58..9b26d203 100644 --- a/RemoteCam/StoreManager.swift +++ b/RemoteCam/StoreManager.swift @@ -94,6 +94,7 @@ final class StoreManager: ObservableObject { hasFullAccess() || UserDefaults.standard.bool(forKey: PurchaseKey.tapToFocus) } + /// 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 diff --git a/RemoteCam/UICmds.swift b/RemoteCam/UICmds.swift index 4edaf284..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 @@ -241,45 +241,44 @@ 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? + /// Monitor screen -> session: set auto/manual exposure on the camera peer. + public class SetExposure: Message, @unchecked Sendable { + public let intent: ExposureIntent - public init(zoomFactor: CGFloat?, currentLens: CameraLensType?, zoomRange: ZoomRange?, error: Error?) { - self.zoomFactor = zoomFactor - self.currentLens = currentLens - self.zoomRange = zoomRange - self.error = error + public init(intent: ExposureIntent) { + self.intent = intent super.init(sender: nil) } } - // MARK: - Lens Switching Commands - public class SwitchLens: Message, @unchecked Sendable { - public let lensType: CameraLensType - - public init(lensType: CameraLensType) { - self.lensType = lensType + /// 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 SwitchLensResp: Message, @unchecked Sendable { - public let lensType: CameraLensType? - public let availableLenses: [CameraLensType]? - public let currentZoom: CGFloat? - 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(lensType: CameraLensType?, availableLenses: [CameraLensType]?, - currentZoom: CGFloat?, zoomRange: ZoomRange?, error: Error?) { + public init(state: ControlState) { + self.state = state + super.init(sender: nil) + } + } + + // MARK: - Lens Switching Commands + public class SwitchLens: Message, @unchecked Sendable { + public let lensType: CameraLensType + + public init(lensType: CameraLensType) { self.lensType = lensType - self.availableLenses = availableLenses - self.currentZoom = currentZoom - self.zoomRange = zoomRange - self.error = error super.init(sender: nil) } } 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..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 @@ -40,12 +56,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 +88,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 +100,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/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 4d3646b0..72c056c9 100644 --- a/RemoteCamTests/CaptureIntegrationTests.swift +++ b/RemoteCamTests/CaptureIntegrationTests.swift @@ -404,4 +404,81 @@ 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)") + } + } + + /// 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.exposure?.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 { + 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.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.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/CinematicPolicyTests.swift b/RemoteCamTests/CinematicPolicyTests.swift new file mode 100644 index 00000000..4460eb2d --- /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 = 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 = ProStops.isoStops(min: 32, max: 3200) + XCTAssertEqual(stops.first, 32) + XCTAssertEqual(stops.last, 3200) + } + + func testApertureStopsEmptyForFixedAperture() { + 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(ProStops.nearestIndex(of: 1.0 / 120, in: stops), 1) + XCTAssertNil(ProStops.nearestIndex(of: 1.0, in: [Double]())) + } + + func testLabels() { + 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/ControlStateTests.swift b/RemoteCamTests/ControlStateTests.swift new file mode 100644 index 00000000..263f125c --- /dev/null +++ b/RemoteCamTests/ControlStateTests.swift @@ -0,0 +1,155 @@ +// +// 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") + } + + // 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") + } +} 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..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) } @@ -705,6 +712,114 @@ 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.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.ControlStateChanged }.last + XCTAssertEqual(autoResp?.state.exposure?.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) + } + + // 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.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.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) + } + + /// 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. @@ -737,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 new file mode 100644 index 00000000..c6da8b54 --- /dev/null +++ b/RemoteCamTests/MessageDumpTests.swift @@ -0,0 +1,56 @@ +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 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)) + let dump = MessageDump.describe(RemoteCmd.ControlStateChanged(state: control)) + + 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) + 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, + 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.ToggleTorchResp(torchMode: nil, error: NSError(domain: "Unsupported", code: 7)) + let dump = MessageDump.describe(resp) + XCTAssertTrue(dump.contains("error: error(Unsupported 7)"), dump) + } + + func testMessageWithoutFields() { + XCTAssertEqual(MessageDump.describe(RemoteCmd.ToggleTorch()), "(no fields)") + } +} diff --git a/RemoteCamTests/MonitorChromeTests.swift b/RemoteCamTests/MonitorChromeTests.swift index c6dd69d8..851cd9c8 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, + proTiles: [MonitorTrayItem] = []) -> [MonitorTrayItem] { MonitorTray.items(for: state, supportsHEIF: supportsHEIF, supportsHDR: supportsHDR, supportsCameraStandby: supportsCameraStandby, resolutionCount: resolutionCount, - frameRateCount: frameRateCount) + frameRateCount: frameRateCount, + proTiles: proTiles) } /// A camera with no optional capabilities gets the irreducible tray. @@ -229,6 +231,59 @@ final class MonitorChromeTests: XCTestCase { XCTAssertEqual(tiles, [.timer, .aspect, .cameraStandby, .settings, .help]) } + // 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 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) }) + } + } + + /// 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 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 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 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(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 /// gives up its nav bar. func testEveryModeOffersSettingsAndHelp() { diff --git a/RemoteCamTests/MonitorPresenterTests.swift b/RemoteCamTests/MonitorPresenterTests.swift index 5c44cf2d..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) } } @@ -93,6 +93,24 @@ class MonitorPresenterTests: XCTestCase { XCTAssertEqual(display.videoRecordingConfigured, 1) } + // MARK: - Exposure echo + + 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.applyControlState(ControlState(seq: 1, exposure: state)) + drain() + XCTAssertTrue(display.viewModel.supportsManualExposure) + XCTAssertEqual(display.viewModel.exposure, state) + + // 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) + } + // MARK: - BecomeMonitorFailed func testBecomeMonitorFailedExitsMonitor() { @@ -118,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 @@ -171,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() { @@ -191,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 a1a81a01..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 } @@ -63,6 +66,19 @@ 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 testProSliderPillRenders() { + let exposure = ExposureState( + mode: .manual, durationSeconds: 1.0 / 125, iso: 400, + minDurationSeconds: 1.0 / 8000, maxDurationSeconds: 0.5, minISO: 32, maxISO: 3200) + let pill = ProSliderPill(scale: .shutter(exposure), + currentValue: exposure.durationSeconds, + onChange: { _ in }, onAuto: {}, onClose: {}) + let image = renderScreen(named: "monitor-pro-slider", pill) + assertRendered(image) + } + func testWaitingForFirstFrame() { // Fresh connection: no frame from the camera yet. let model = MonitorViewModel() diff --git a/RemoteCamTests/MultiCamChromeTests.swift b/RemoteCamTests/MultiCamChromeTests.swift index 62df9c55..becfa5d8 100644 --- a/RemoteCamTests/MultiCamChromeTests.swift +++ b/RemoteCamTests/MultiCamChromeTests.swift @@ -62,6 +62,18 @@ final class MultiCamChromeTests: XCTestCase { XCTAssertFalse(RigTray.items(mode: .video, standbyAvailable: false).contains(.cameraStandby)) } + /// 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() { // 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..e466efea 100644 --- a/RemoteCamTests/MulticamControllerTests.swift +++ b/RemoteCamTests/MulticamControllerTests.swift @@ -148,6 +148,126 @@ 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 { + // v11: capability = presence in the control snapshot the caps carry. + RemoteCmd.CameraCapabilitiesResp( + frontCamera: nil, backCamera: nil, currentCamera: .back, + supportsMulticam: true, + 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) + } + + /// 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?.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.ControlStateChanged(state: ControlState(seq: 10, exposure: applied)), from: camA) + await controller.waitForIdle() + lane = await controller.lanesForTesting().first + 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.ControlStateChanged(state: ControlState(seq: 11, exposure: applied), + refusal: .photoMode), from: camA) + await controller.waitForIdle() + await pumpMainUntil { !display.transientErrors.isEmpty } + 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 + /// 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 { @@ -201,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() @@ -1174,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) } @@ -1763,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) } @@ -1771,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) } @@ -1860,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 { @@ -1892,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 @@ -1929,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 67edc838..67733211 100644 --- a/RemoteCamTests/MulticamViewModelTests.swift +++ b/RemoteCamTests/MulticamViewModelTests.swift @@ -15,21 +15,81 @@ 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, + 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 { - 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, 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 + /// 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)]) + XCTAssertTrue(vm.focusedProTiles.isEmpty, "focused camera offers nothing") + + vm.apply([info(camA), info(camB, focused: true, supportsManualExposure: true)]) + XCTAssertEqual(vm.focusedProTiles, [.shutter, .iso], "focused camera does manual exposure") + + vm.apply([info(camA), info(camB, focused: true, supportsCinematicVideo: true)]) + XCTAssertTrue(vm.focusedProTiles.isEmpty, "Cinematic is not a photo control") + vm.mode = .video + XCTAssertEqual(vm.focusedProTiles, [.cinematic]) + + vm.apply([info(camA), info(camB, status: .reconnecting, focused: true, supportsManualExposure: true)]) + XCTAssertTrue(vm.focusedProTiles.isEmpty, "a dropped camera cannot be driven") + } + + /// An open slider stays only while the focused camera still offers its + /// 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 + XCTAssertEqual(vm.visibleProSlider, .shutter) + + vm.apply([info(camA, supportsManualExposure: true), info(camB, focused: true)]) + XCTAssertNil(vm.visibleProSlider, "camB has no shutter to slide") + // 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)]) + 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 @@ -110,7 +170,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, @@ -118,7 +180,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]), @@ -225,48 +287,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/ProSliderScaleTests.swift b/RemoteCamTests/ProSliderScaleTests.swift new file mode 100644 index 00000000..68eec5d5 --- /dev/null +++ b/RemoteCamTests/ProSliderScaleTests.swift @@ -0,0 +1,152 @@ +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), "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) + 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]) + } +} + +/// 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. +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") + } +} 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 7e96549b..3775f5f5 100644 --- a/RemoteCamTests/RemoteCmdSerializationTests.swift +++ b/RemoteCamTests/RemoteCmdSerializationTests.swift @@ -57,13 +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.ControlStateChanged: return m.toFlatBuffer() + case let m as RemoteCmd.SetCinematic: 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() @@ -351,53 +352,135 @@ 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: - 12. SetZoomResp + // MARK: - 11c. SetExposure - 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) + 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 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") + func testSetExposure_autoRoundTrip() { + let decoded: RemoteCmd.SetExposure = roundTrip(RemoteCmd.SetExposure(intent: .auto)) + XCTAssertEqual(decoded.intent, .auto) + } + + func testCameraCapabilities_manualExposureRoundTrip() { + let original = RemoteCmd.CameraCapabilitiesResp( + frontCamera: nil, backCamera: nil, currentCamera: .back, + control: ControlState(seq: 1, exposure: sampleExposure), error: nil) + let decoded: RemoteCmd.CameraCapabilitiesResp = roundTrip(original) + 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_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, + control: ControlState(seq: 1), error: nil) + let decoded: RemoteCmd.CameraCapabilitiesResp = roundTrip(original) + XCTAssertEqual(decoded.control?.supportsManualExposure, false) + XCTAssertNil(decoded.control?.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 testCameraCapabilities_cinematicRoundTrip() { + let original = RemoteCmd.CameraCapabilitiesResp( + frontCamera: nil, backCamera: nil, currentCamera: .back, + control: ControlState(seq: 1, cinematic: sampleCinematic), error: nil) + let decoded: RemoteCmd.CameraCapabilitiesResp = roundTrip(original) + 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 @@ -406,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) @@ -435,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) } @@ -445,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) @@ -492,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( @@ -507,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") @@ -539,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) @@ -562,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() { @@ -578,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 @@ -603,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() { @@ -796,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) } @@ -835,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) @@ -908,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 @@ -1051,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]], @@ -1060,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! @@ -1111,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 @@ -1225,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) @@ -1236,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) @@ -1332,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 1f64d2be..1d89fbd5 100644 --- a/RemoteCamTests/SessionTestSupport.swift +++ b/RemoteCamTests/SessionTestSupport.swift @@ -120,9 +120,58 @@ class FakeCameraControlling: CameraControlling, @unchecked Sendable { var exitCameraCalls = 0 var countdownTicks: [Int] = [] var gatherCapabilitiesCalls = 0 - var zoomCalls: [CGFloat] = [] - var focusCalls: [CGPoint] = [] - var lensSwitches: [CameraLensType] = [] + // 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 advertisesCinematicVideo = true + var cinematicEnabled = false + + /// 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 @@ -159,22 +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), + /// 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 } + 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 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 -> ControlState { + if let errorToThrow { throw errorToThrow } + 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 @@ -226,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 { @@ -296,12 +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, + 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 eec82d51..ae0856f2 100644 --- a/RemoteShutter.xcodeproj/project.pbxproj +++ b/RemoteShutter.xcodeproj/project.pbxproj @@ -76,6 +76,9 @@ 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 */; }; + 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 */; }; @@ -182,6 +185,10 @@ 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 */; }; + 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 */; }; @@ -201,11 +208,13 @@ 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 */; }; + E0E0206B0000000000000002 /* CinematicPolicy.swift in Sources */ = {isa = PBXBuildFile; fileRef = E0E0206B0000000000000001 /* CinematicPolicy.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 */; }; 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 */; }; @@ -347,6 +356,9 @@ 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 = ""; }; + 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 = ""; }; @@ -449,6 +461,10 @@ 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 = ""; }; + 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 = ""; }; @@ -463,11 +479,13 @@ 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 = ""; }; + E0E0206B0000000000000001 /* CinematicPolicy.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = CinematicPolicy.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 = ""; }; 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 = ""; }; @@ -632,6 +650,11 @@ AABB00032E930002009TESTS /* RemoteCmdSerializationTests.swift */, CAFEBABE0001000000000001 /* CropRectTests.swift */, CAFEBABE00F0000000000001 /* FocusPointMappingTests.swift */, + E0E0206A0000000000000003 /* ExposurePolicyTests.swift */, + E0E0206D0000000000000001 /* CinematicPolicyTests.swift */, + E0E0206E0000000000000001 /* MessageDumpTests.swift */, + E0E0206F0000000000000001 /* ProSliderScaleTests.swift */, + E0E020720000000000000001 /* ControlStateTests.swift */, CAFEBABE0121000000000001 /* MonitorChromeTests.swift */, CAFEBABE0131000000000001 /* CaptureSyncMetadataTests.swift */, CAFEBABE0133000000000001 /* ClockOffsetEstimatorTests.swift */, @@ -675,6 +698,8 @@ 068DF59A2E3544AD00A49279 /* MonitorView.swift */, 0A11B22C33D44E55F6070001 /* ZoomScale.swift */, 0A11B22C33D44E55F6070003 /* ZoomPill.swift */, + E0E020700000000000000001 /* RulerPill.swift */, + E0E020710000000000000001 /* ControlState.swift */, 0A11B22C33D44E55F6071003 /* ViewfinderGestureLayer.swift */, 06BB79BB2E3884F00094E085 /* CameraProgressOverlayView.swift */, 06BB79BE2E3884FA0094E085 /* CameraViewModel.swift */, @@ -731,11 +756,13 @@ 0684A2D81BE6E9D400F0B238 /* RemoteCamSession */, 0684A2D01BE65A9800F0B238 /* OrientationUtils.swift */, FC0CF5A201FE65A9800F0B238 /* FocusPointMapping.swift */, + E0E0206A0000000000000001 /* ExposurePolicy.swift */, + E0E0206B0000000000000001 /* CinematicPolicy.swift */, + E0E0206C0000000000000001 /* ProSliderPill.swift */, CAFEBABE0120000000000002 /* MonitorChrome.swift */, CAFEBABE0177000000000002 /* SessionDebugConsole.swift */, CAFEBABE0130000000000002 /* CaptureSyncMetadata.swift */, CAFEBABE0132000000000002 /* ClockOffsetEstimator.swift */, - CAFEBABE0170000000000002 /* ZoomScaleSeed.swift */, CAFEBABE0172000000000002 /* PeerSessionCore.swift */, CAFEBABE0173000000000002 /* DiscoveredPeers.swift */, CAFEBABE0171000000000002 /* FocusedCameraControlState.swift */, @@ -1211,6 +1238,8 @@ 068DF59D2E3544AD00A49279 /* MonitorView.swift in Sources */, 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 */, @@ -1238,11 +1267,13 @@ 0673275D2E2DF142003E5F94 /* PermissionManager.swift in Sources */, 0684A2D11BE65A9800F0B238 /* OrientationUtils.swift in Sources */, FC0CF5A101FE65A9800F0B238 /* FocusPointMapping.swift in Sources */, + E0E0206A0000000000000002 /* ExposurePolicy.swift in Sources */, + E0E0206B0000000000000002 /* CinematicPolicy.swift in Sources */, + E0E0206C0000000000000002 /* ProSliderPill.swift in Sources */, CAFEBABE0120000000000001 /* MonitorChrome.swift in Sources */, 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 */, @@ -1324,6 +1355,11 @@ AABB00042E930002009TESTS /* RemoteCmdSerializationTests.swift in Sources */, CAFEBABE0001000000000002 /* CropRectTests.swift in Sources */, CAFEBABE00F0000000000002 /* FocusPointMappingTests.swift in Sources */, + E0E0206A0000000000000004 /* ExposurePolicyTests.swift in Sources */, + 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 */, @@ -1489,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)"; @@ -1531,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)";