Pro controls: manual exposure + Cinematic video (#206) - #223
Open
darioalessandro wants to merge 14 commits into
Open
darioalessandro wants to merge 14 commits into
darioalessandro wants to merge 14 commits into
Conversation
Adds ExposurePolicy (pure clamp/recording-cap/unsupported decisions), SetExposure=33 + ExposureState on the wire, supports_manual_exposure capability, one-owner apply in CaptureEngine re-applied on device swap and format change, coordinator gate pinned by loopback tests, and a hardware probe in CaptureIntegrationTests. No UI yet. Design: Docs/pro-controls.md. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Contributor
📊 Test Coverage Report✅ 915/930 tests passed · ⏭️ 15 skipped
🎥 Capture & session core
📁 Full report — 106 files
Generated from |
- CinematicPolicy + SetCinematic=34 + CinematicState on the wire, gated on supports_cinematic_video; engine owns isCinematicVideoCaptureEnabled/ simulatedAperture in one apply function (iOS 26 availability-guarded), Cinematic tap-to-focus routing, mode/recording locks per policy. - Manual-exposure lens hop: entering Manual on a virtual device swaps to activePrimaryConstituent at runtime only when the device refuses .custom. - Monitor UI: PRO tray tile -> ProControlsPanel (Auto/Manual segmented, detented shutter/ISO/aperture dials, echo-driven values, VoiceOver adjustable), camera-side readout chip; behind ENABLE_PRO_CONTROLS. - IAP "10" Pro Controls mirroring tap-to-focus, 15-locale strings. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
darioalessandro
marked this pull request as ready for review
August 24, 2026 06:02
The PRO panel is included for every user; the only gate left is the capability gate (button appears only when the camera offers the feature). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…onstituent activePrimaryConstituent is nil until the virtual device is used in a running session (AVCaptureDevice.h), and the first capabilities exchange fires before the session starts. Every modern iPhone opens on a virtual device, so the camera advertised supports_manual_exposure = false and the PRO tray tile never appeared. The lens choice is now one function (manualExposureLensLocked) used by both the capability and the hop; the tray predicate moved into MonitorTray so it is unit-tested. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01K5uHRuiiLu5FEBiHZkBWo3
…essage A single camera lands on the multicam director (MULTICAM_FOR_SINGLE_CAMERA), whose tray rendered nothing for .proControls — so the PRO tile only ever existed on the classic 1:1 monitor nobody reaches. The director now offers the tile for the FOCUSED camera (like torch and zoom): CameraLink carries that camera's echoed exposure/Cinematic truth, RigTray lists the tile when the focused lane advertised a capability, and MulticamController sends SetExposure/SetCinematic only to a camera that advertised them. The rig's photo/video mode is pushed to cameras (SyncMonitorSettings, incl. late joiners) so Cinematic is not refused as photo-mode. ProControlsPanel takes plain values so both monitors share it. Debug console: TrafficEntry carries a reflective MessageDump of the message; tapping a command row unfolds its fields, so a capabilities message shows supportsManualExposure / exposure ranges on the device. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01K5uHRuiiLu5FEBiHZkBWo3
… zoom and pro The PRO tile → panel → dials path was two taps too deep. Shutter, ISO, Cinematic and Aperture are now first-class tray tiles (with the camera's current value on each); SHUTTER / ISO / APERTURE open a slider in the zoom pill's slot, Cinematic toggles in place like HDR. The slider IS the zoom control: the ruler, relative drag, scroll wheel, pending-value echo and VoiceOver adjustable element are extracted from ZoomPill into RulerPill, and the log-track math from ZoomScale into LogTrack (ZoomScale wraps it; ZoomScaleTests unchanged and green). ZoomPill and ProSliderPill are thin configurations. Slider sends are throttled with the zoom throttle. Both the 1:1 monitor and the multicam director get the same tiles and slider. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01K5uHRuiiLu5FEBiHZkBWo3
…errors the remote shows
AVCaptureDeviceInput.isCinematicVideoCaptureSupported reflects the
session's COMMITTED configuration, so checking it inside the same
begin/commit as the format switch read the old answer and the enable was
skipped — and the code path returned the unchanged state with no error, so
the tile looked like it did nothing. The format now commits on its own,
then the session is asked and the effect enabled in its own begin/commit.
Every refusal (photo mode, recording, no capable format, session refusal)
is a CinematicRefusal carried in SetCinematicResp.error; the director
toasts it, the 1:1 monitor alerts. A 🎬 CINEMATIC PROBE log line reports
the three conditions on the camera phone.
Flip while Manual: the Manual lens hop left the session on a physical lens
that is not in the selectable list, so the flip could not find the current
camera ("Unable to find camera position") and Auto would have restored
the pre-flip camera. The chosen device is now the LOGICAL camera
(logicalDeviceIDLocked) for the flip, the picker's active ID and Auto's
return; a user device change re-bases the hop through one decision
function (manualExposureHopTargetLocked). Hardware test added.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01K5uHRuiiLu5FEBiHZkBWo3
setCinematicVideoTrackingFocus(at:focusMode:) requires exclusive ownership of the device; calling it unlocked is an uncaught NSGenericException that took the camera down on the first tap-to-focus with Cinematic on. The lock now precedes every focus write, and the device-swap focus reset skips focusMode while Cinematic owns focus (writing it then throws too). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01K5uHRuiiLu5FEBiHZkBWo3
ISO slider read 'ISO ISO 400': the pill's readout prefixes the control
name, and the ISO value label also carried 'ISO'. The value label is now
bare ('400'); the camera chip keeps ProStops.isoLabel.
Zoom did nothing with Cinematic on because Cinematic restricts zoom to a
narrower band (videoMin/MaxZoomFactorForCinematicVideo) but the camera
clamped and advertised the device's full range — the monitor's pill asked
for factors Cinematic would not honor. One effectiveZoomBoundsLocked helper
now backs the clamp, the advertised range and the getters; and enabling or
disabling Cinematic republishes the zoom range (SetZoomResp) so the pill
re-scales at once, on the 1:1 monitor and the director alike.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01K5uHRuiiLu5FEBiHZkBWo3
…trol responses Breaking wire change (app major 10 -> 11; the PeerBecameCamera/Monitor handshake bytes are untouched, so 10.x peers complete the version exchange and get the update prompt). Design: Docs/control-plane.md. - ControlState: the camera's complete control truth (logical device, mode, lens, zoom factor + EFFECTIVE range + stops, exposure?, cinematic?, focus), produced by ONE engine function; capability is presence. - ControlStateChanged (35) answers every control mutation (SetZoom, SwitchLens, SetExposure, SetCinematic) and is pushed unsolicited when a constraint moves (device swap, quality change, mode change). Refusals are typed (ControlRefusal) and always rendered. SetZoomResp / SwitchLensResp / SetExposureResp / SetCinematicResp and the ZoomRange tables are deleted; capabilities carry the snapshot as the seed. - Remotes are pure: one stored snapshot per camera (MonitorViewModel .controlState / CameraLink.control), updated only by the absorb seq-fold; every zoom/lens/exposure/cinematic read derives from it, so a stale range is unrepresentable (zoom-under-Cinematic by construction). - Coordinator: one respondWithControlState on the camera, one peerControl + absorbControlState on the monitor; gates are presence reads. Executed as four parallel work packages (coordinator, monitor, director, tests) against a frozen contract; suite: 923 tests, failures = the known machine-local three (local purchase state, keychain TLS, suspended webcam). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01K5uHRuiiLu5FEBiHZkBWo3
ThrottledValueSender was the one untested piece of the slider stack (its core ZoomSendThrottle is pinned; the Timer wrapper was not): the leading value sends immediately, mid-drag values coalesce, and the value the finger released on ALWAYS lands on the trailing edge. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01K5uHRuiiLu5FEBiHZkBWo3
…g cannot hide it Zoom under Cinematic is narrowed, never removed (AVCaptureDevice.h: 'Devices support a limited zoom range when Cinematic Video capture is active'), but the UI contradicted the model: an open pro slider took the zoom pill's slot, which read as 'enabling Cinematic removes zoom'. The pro slider now stacks ABOVE the pill on both remote screens — framing and exposure adjust at the same time. ControlStateTests additionally prove the derivation side: for every narrowed range the engine can emit (guarded max > min), the derived ZoomScale is non-degenerate — a degenerate scale is exactly what hides the pill — including the floor-above-every-stop case, and disabling Cinematic widens straight back (no stored value to un-stick). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01K5uHRuiiLu5FEBiHZkBWo3
…om pill Field repro: no slider open, toggle CINEMATIC, zoom vanishes instantly. Cause: activeProSlider deliberately REMEMBERED the last-opened slider when its tile disappeared, so the aperture slider from an earlier Cinematic session resurrected the moment the tile returned — replacing the zoom pill with no tap. The choice is now CLEARED when its tile vanishes, on both remote screens; combined with the stacking change, no path replaces or hides the zoom pill while the camera reports a usable range. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01K5uHRuiiLu5FEBiHZkBWo3
…ne rule - ProSliderIntent.reconcile is THE open-slider rule, pure and table-tested; both remote screens apply it from their write paths (director: apply()/ mode change; 1:1: the onChange reconciliation). The director's visibleProSlider getter no longer dispatches a mutation from a read. - MonitorViewModel.applyControlState is synchronous on main (presenter already hops); the redundant second enqueue and its reordering surface are gone. The transform is now pure end to end: two mutable cells per screen (the snapshot, the slider intent), each written in one place, each fed by a pure function. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01K5uHRuiiLu5FEBiHZkBWo3
Open
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Closes #206 — a user asked for the controls a photographer expects from a real camera: shutter speed, ISO, and aperture. This PR adds them to the remote, free for everyone. Design notes live in
Docs/pro-controls.md.What you get
Tray tiles, one tap deep
Open the tray (grid glyph, top-right capsule) and the controls are right there, next to timer, aspect and quality — no sub-menu:
1/125,400) and lights up while Manual is on.f/2.8).If the camera can't do any of this (a Mac's webcam, an older phone, an older app version on the other side), the tiles simply aren't there — the remote never offers a control the camera would ignore. This works on the screen you actually land on: a single camera opens the multicam director, and its tray carries the same tiles, driving the focused camera exactly like torch and zoom.
Sliders that work like zoom
Tap SHUTTER, ISO or APERTURE and the tray closes; that control's slider appears where the zoom pill sits — the same control, not a look-alike: a log-spaced ruler over the camera's range, photographic detents you can feel (1/8000 … 1/125 … 1s; ISO in ⅓-stops; f/1.4 … f/16), relative drag so a small correction stays small, scroll wheel on the Mac, VoiceOver-adjustable. The ruler shows the value you're dragging to and settles on what the camera confirms.
Dragging the shutter locks the shutter and keeps ISO where auto had it; dragging ISO does the reverse. So your first drag engages Manual from a correctly exposed frame instead of a black one. AUTO on the pill hands exposure back to the camera and closes it; × just closes it.
Two protections happen quietly:
The camera phone shows a small chip at the top of its screen (
M 1/125 · ISO 400) so whoever is holding it sees it's under manual control.Cinematic video — simulated aperture (iOS 26)
Turning the CINEMATIC tile on applies Apple's Cinematic effect — shallow depth of field with subject tracking — to both the live preview and the recorded clip, and the APERTURE slider (f/1.4 … f/16) sets how shallow. Tap-to-focus on the remote becomes "track this subject" while the effect is on.
The aperture is set before a take: once recording starts the CINEMATIC and APERTURE tiles dim, because Apple rejects changes mid-clip — we don't pretend otherwise. Leaving video mode turns the effect off.
If the camera can't turn Cinematic on, the remote says so — a toast on the director, an alert on the classic monitor — with the reason: "Switch to video mode for Cinematic", "Cinematic can't change while recording", " can't record Cinematic video", or a session refusal naming the camera, format and attached outputs. A tap that does nothing is never the outcome.
Debug console: tap a message to see what's in it
The in-app traffic console (ant icon, debug builds) listed message names only. Tapping a command row now unfolds its fields — nested state indented, arrays with counts, shutter durations also shown as fractions — so you can read straight off the device whether a
CameraCapabilitiesRespcarriedsupportsManualExposure: trueand what exposure range the phone reported. It's reflection-based, so every command, including future ones, is inspectable without writing a describer.Nothing to buy
Earlier drafts gated this behind an in-app purchase; that's gone. No new product, no store changes, no unlock screen.
The control plane (v11)
The POC's per-command responses grew three field bugs from one root cause: camera control state is one coupled system (Cinematic narrows zoom, Manual moves the device, quality moves exposure ranges), but the wire treated it as independent fragments. This PR consolidates it (
Docs/control-plane.md):ControlState— logical device, mode, lens, zoom factor + effective range + stops, exposure, cinematic, focus — produced by exactly one engine function. Capability is presence:exposureabsent means no SHUTTER/ISO tiles and noSetExposureon the wire.ControlStateChangedreplies to every control mutation and is pushed unsolicited whenever a constraint moves. Refusals are typed and always shown.SetZoomResp/SwitchLensResp/SetExposureResp/SetCinematicRespno longer exist.absorb); every control read derives from it. A stale zoom range under Cinematic is now unrepresentable, not patched.How it's built
SetExposureandSetCinematic, each answered with the camera's actual state. Capabilities gainsupports_manual_exposureandsupports_cinematic_video. Old app versions ignore what they don't know.RulerPill, and its log-track mathLogTrack—ZoomScalewraps it (its tests are unchanged and green),ZoomPillandProSliderPillare thin configurations of the same view. Slider sends are throttled with the zoom throttle.ExposurePolicy,CinematicPolicy): range clamping, the recording cap, aperture lock, video-mode-only Cinematic. No AVFoundation types cross that boundary, so every rule has a table test.AVCaptureDeviceInput.isCinematicVideoCaptureSupportedreflects the session's committed configuration; I switched to a Cinematic format and read it inside the samebeginConfiguration/commitConfiguration, so it read the oldfalse, the enable was skipped, and the response carried no error. The format now commits on its own before the session is asked; refusals are typed errors on the wire.activePrimaryConstituent, which Apple documents as nil until the session is running — and capabilities are exchanged before that. The check now reads the constituent lens list, and the same function picks the hop lens, so advertising and behavior can't disagree.MULTICAM_FOR_SINGLE_CAMERA), whose tray rendered nothing for it. The panel now takes plain values, so both screens share it; the director gets its own per-camera routing and capability gate.What I checked
.customexposure support, so the PRO tile stays hidden — the gate works end to end.Still needs a real iPhone
Both devices must be rebuilt from this branch — v11 does not talk to earlier builds (they get the update prompt).
CameraCapabilitiesResprow in the debug console and checksupportsManualExposure/supportsCinematicVideo; on the camera phone the🌗 EXPOSURE PROBElines should show the virtual cameracustom=falseand its lensescustom=true.🎬 CINEMATIC PROBElog line shows the three conditions (cinematicFormats,activeSupports,inputSupports). If it turns on: drag APERTURE, record a clip — the effect should be in the file and both tiles dim while recording.CaptureIntegrationTests.testFlipKeepsWorkingWhileManualExposureHasHoppedis the same check as a test on a phone.)🤖 Generated with Claude Code
https://claude.ai/code/session_01K5uHRuiiLu5FEBiHZkBWo3