Skip to content

Multicam PR3: MulticamController + scanner multi-select + focus/strip UI - #201

Closed
darioalessandro wants to merge 1 commit into
multicam-pr2-clocksyncfrom
multicam-pr3-director
Closed

Multicam PR3: MulticamController + scanner multi-select + focus/strip UI#201
darioalessandro wants to merge 1 commit into
multicam-pr2-clocksyncfrom
multicam-pr3-director

Conversation

@darioalessandro

Copy link
Copy Markdown
Member

Stacked on the PR2 clock-sync branch. The director engine and screen, all behind ENABLE_MULTICAM (off) — every single-camera path stays byte-identical.

Engine (unit-tested)

  • MulticamController — a sibling actor of SessionCoordinator, reached only for a multicam director session. Owns the transport as its delegate after the scanner hands it off; holds a CameraLink per camera keyed by MCPeerID. Per-camera capability handshake, per-camera frame routing with a per-source RequestFrame ack (Seam B), focused-peer command addressing, a per-camera clock-sync loop (ClockSyncPing/PongClockOffsetEstimator, ~30s + foreground), keep-browsing + re-invite of a dropped camera (its lane degrades to .reconnecting, the others untouched), and logical camera removal.
  • The camera side is unchanged — a camera can't tell a multicam director from a single monitor.
  • CameraLink — per-camera status / capabilities / clock estimate.

UI

  • MulticamViewController / MulticamView — focus mode: the focused camera fills the viewfinder (reusing the 1:1 LiveFrameView) with a floating strip of the other cameras' live thumbnails; tap to refocus. Each lane has its own FrameDisplayModel + FrameStreamReceiver, so a frame from camera B never re-renders camera A's tile. Per-tile reconnecting scrim. Grid mode is PR6.
  • MulticamViewModel / CameraLane — reconcile controller snapshots while preserving lane instances (and their live streams).

Scanner (flag-gated, additive)

With ENABLE_MULTICAM and the monitor role, the scanner accumulates connected cameras instead of auto-advancing on first connect, and shows "Start (N)". One camera → classic MonitorViewController unchanged; two or more → hand the live transport to a MulticamController and push the director. The SessionCoordinator seam is a single flag-defaulted-false collecting mode; every non-multicam path is untouched.

Tests

MulticamControllerTests (handshake, per-lane frame routing + source-only ack, focused-only commands, disconnect isolation, browser re-invite, clock offset, removal) and MulticamViewModelTests (lane reconcile/focus). Full suite green — 668 tests.

Deviations from the brief

  • CameraLink is a final class, not a struct — it owns a FrameStreamReceiver (a class with a running timer) and the actor mutates it per frame; a struct would force a dict read-modify-write on every frame.
  • The per-lane FrameStreamReceiver lives UI-side (in CameraLane), not in CameraLink. The brief mentioned both; only one can own it. Keeping decode UI-side mirrors the existing 1:1 MonitorViewController (which owns its receiver) and preserves the rendering-isolation pattern. CameraLink keeps status/caps/clock.
  • Camera removal is logical, not a transport disconnect — the QUIC session has no per-peer teardown API, so removal stops the lane (no more acks/handshakes) and drops it from the UI; the peer times out on its side. Noted in-code; a true per-peer disconnect needs a transport addition.

🤖 Generated with Claude Code

The director engine and screen, all behind ENABLE_MULTICAM (off), so every
single-camera path stays byte-identical.

Engine (new, unit-tested):
- MulticamController: a sibling actor of SessionCoordinator, reached only for
  a multicam director session. Owns the transport as its delegate after the
  scanner hands it off; keeps a CameraLink per camera keyed by MCPeerID.
  Per-camera capability handshake, per-camera frame routing with a
  per-source RequestFrame ack (Seam B), focused-peer command addressing,
  a per-camera clock-sync loop (ClockSyncPing/Pong -> ClockOffsetEstimator,
  ~30s + on foreground), keep-browsing + re-invite of a dropped camera
  (its lane degrades to .reconnecting; the others are untouched), and
  logical camera removal. The camera side is unchanged: a camera can't tell
  a multicam director from a single monitor.
- CameraLink: per-camera status/capabilities/clock estimate.

UI (new):
- MulticamViewController hosts MulticamView in focus mode: the focused
  camera fills the viewfinder (reusing the 1:1 LiveFrameView) with a
  floating strip of the other cameras' live thumbnails; tap to refocus.
  Each lane has its own FrameDisplayModel + FrameStreamReceiver, so a frame
  from camera B never re-renders camera A's tile. Per-tile reconnecting
  scrim. Grid mode is a later PR.
- MulticamViewModel/CameraLane reconcile controller snapshots while
  preserving lane instances (and their live streams).

Scanner (flag-gated, additive):
- With ENABLE_MULTICAM and the monitor role, the scanner accumulates
  connected cameras instead of auto-advancing on the first connect, and
  shows "Start (N)". One camera runs the classic MonitorViewController
  unchanged; two or more hand the live transport to a MulticamController and
  push the director. The SessionCoordinator seam is a single
  flag-defaulted-false collecting mode; every non-multicam path is
  untouched.

Tests: MulticamControllerTests (handshake, per-lane frame routing + source-
only ack, focused-only commands, disconnect isolation, browser re-invite,
clock offset, removal) and MulticamViewModelTests (lane reconcile/focus).
Full suite green (668 tests).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@github-actions

Copy link
Copy Markdown
Contributor

📊 Test Coverage Report

656/668 tests passed · ⏭️ 12 skipped

Metric Coverage
App code (excl. generated + vendored Theater) █████████░░░░░░░░░░░ 44.7% 🔴
Whole target █████████░░░░░░░░░░░ 45.9% 🔴

🎥 Capture & session core

File Coverage Lines
CaptureEngine.swift ██░░░░░░░░░░░░░░░░░░ 10.7% 164/1532 🔴
RecordingPipeline.swift ████████████░░░░░░░░ 59.7% 284/476 🟡
FrameStreamingCoordinator.swift ░░░░░░░░░░░░░░░░░░░░ 0.0% 0/124 🔴
FrameStreamer.swift ██████████████░░░░░░ 69.2% 72/104 🟡
RemoteCmdFlatBuffers.swift ███████████████████░ 96.2% 1227/1276 🟢
📁 Full report — 94 files
File Coverage Lines
CountdownTimer.swift ████████████████████ 100.0% 48/48 🟢
JPEGFrameEncoder.swift ████████████████████ 100.0% 12/12 🟢
CameraPreviewMode.swift ████████████████████ 100.0% 13/13 🟢
AppActivityMonitor.swift ████████████████████ 100.0% 31/31 🟢
AppTheme.swift ████████████████████ 100.0% 33/33 🟢
Messages.swift ████████████████████ 100.0% 7/7 🟢
ZoomScale.swift ████████████████████ 100.0% 76/76 🟢
DebugLog.swift ████████████████████ 100.0% 2/2 🟢
MonitorChrome.swift ████████████████████ 100.0% 56/56 🟢
ClockOffsetEstimator.swift ████████████████████ 100.0% 24/24 🟢
MultipeerMessages.swift ████████████████████ 100.0% 12/12 🟢
StreamingConfig.swift ████████████████████ 100.0% 34/34 🟢
Locked.swift ████████████████████ 100.0% 19/19 🟢
CaptureSyncMetadata.swift ████████████████████ 100.0% 32/32 🟢
FrameCreditWindow.swift ████████████████████ 100.0% 9/9 🟢
AppDelegate.swift ████████████████████ 100.0% 17/17 🟢
HEICFrameEncoder.swift ████████████████████ 100.0% 17/17 🟢
PeerCompatibility.swift ████████████████████ 100.0% 42/42 🟢
DeviceScannerViewModel.swift ████████████████████ 99.1% 113/114 🟢
FrameSender.swift ████████████████████ 98.4% 187/190 🟢
MulticamViewModel.swift ████████████████████ 98.0% 50/51 🟢
FocusPointMapping.swift ████████████████████ 97.6% 40/41 🟢
RemoteCmdFlatBuffers.swift ███████████████████░ 96.2% 1227/1276 🟢
RemoteCmds.swift ███████████████████░ 96.0% 311/324 🟢
WatchSharedTypes.swift ███████████████████░ 94.0% 236/251 🟢
WatchPreviewStreamer.swift ███████████████████░ 93.6% 44/47 🟢
VP9FrameEncoder.swift ███████████████████░ 92.7% 153/165 🟢
CameraLink.swift ██████████████████░░ 90.9% 10/11 🟢
WelcomeView.swift ██████████████████░░ 89.3% 492/551 🟢
VideoTransferProgressView.swift █████████████████░░░ 84.0% 295/351 🟢
ScannerLobby.swift █████████████████░░░ 83.3% 5/6 🟢
UIImage+gif.swift █████████████████░░░ 83.2% 129/155 🟢
UIAlertController.swift █████████████████░░░ 83.0% 44/53 🟢
CameraDeviceDescriptor.swift ████████████████░░░░ 81.0% 34/42 🟢
CameraProgressOverlayView.swift ████████████████░░░░ 78.5% 186/237 🟡
MulticamController.swift ███████████████░░░░░ 76.8% 272/354 🟡
MonitorView.swift ███████████████░░░░░ 76.8% 1178/1534 🟡
FlatBufferSchemas_generated.swift (excluded from app-code metric) ███████████████░░░░░ 76.0% 759/999 🟡
RolePickerView.swift ███████████████░░░░░ 75.4% 306/406 🟡
RecordingTimer.swift ███████████████░░░░░ 73.5% 61/83 🟡
CameraRecordingTimerView.swift ███████████████░░░░░ 73.4% 127/173 🟡
WelcomeViewModel.swift ███████████████░░░░░ 73.3% 96/131 🟡
SettingsViewModel.swift ██████████████░░░░░░ 70.9% 90/127 🟡
FrameStreamer.swift ██████████████░░░░░░ 69.2% 72/104 🟡
SessionCoordinator.swift ██████████████░░░░░░ 68.9% 1758/2551 🟡
DeviceScannerView.swift ██████████████░░░░░░ 68.4% 1137/1662 🟡
MonitorPresenter.swift █████████████░░░░░░░ 65.3% 154/236 🟡
WelcomeViewController.swift █████████████░░░░░░░ 64.1% 41/64 🟡
UIAlertPresenter.swift ████████████░░░░░░░░ 60.5% 26/43 🟡
RecordingPipeline.swift ████████████░░░░░░░░ 59.7% 284/476 🟡
MediaProcessors.swift ████████████░░░░░░░░ 58.3% 7/12 🟡
StoreManager.swift ███████████░░░░░░░░░ 53.2% 75/141 🟡
CameraViewModel.swift ██████████░░░░░░░░░░ 49.6% 126/254 🔴
ZoomPill.swift ██████████░░░░░░░░░░ 49.4% 168/340 🔴
UICmds.swift ██████████░░░░░░░░░░ 49.0% 96/196 🔴
UIViewController+SwiftUIHosting.swift ██████████░░░░░░░░░░ 48.4% 15/31 🔴
FrameCodecs.swift ██████████░░░░░░░░░░ 48.1% 25/52 🔴
FrameStreamReceiver.swift █████████░░░░░░░░░░░ 46.3% 238/514 🔴
SceneDelegate.swift █████████░░░░░░░░░░░ 45.8% 11/24 🔴
MonitorViewModel.swift █████████░░░░░░░░░░░ 44.1% 186/422 🔴
PurchaseManaging.swift ████████░░░░░░░░░░░░ 42.0% 21/50 🔴
HEVCFrameEncoder.swift ████████░░░░░░░░░░░░ 40.1% 93/232 🔴
MonitorViewController.swift ███████░░░░░░░░░░░░░ 37.3% 84/225 🔴
EnumExtensions.swift ███████░░░░░░░░░░░░░ 33.9% 40/118 🔴
CameraScreenView.swift ██████░░░░░░░░░░░░░░ 28.5% 148/520 🔴
DeviceScannerViewController.swift █████░░░░░░░░░░░░░░░ 26.2% 113/431 🔴
WatchSessionManager.swift ████░░░░░░░░░░░░░░░░ 20.8% 37/178 🔴
SoundManager.swift ████░░░░░░░░░░░░░░░░ 20.0% 4/20 🔴
MonitorViewController+SwiftUI.swift ███░░░░░░░░░░░░░░░░░ 14.4% 67/465 🔴
CaptureEngine.swift ██░░░░░░░░░░░░░░░░░░ 10.7% 164/1532 🔴
SwiftConstants.swift ██░░░░░░░░░░░░░░░░░░ 10.3% 6/58 🔴
RolePickerController.swift ██░░░░░░░░░░░░░░░░░░ 10.2% 23/226 🔴
PeerLinkStatus.swift █░░░░░░░░░░░░░░░░░░░ 7.1% 10/141 🔴
WatchRemoteCameraController.swift █░░░░░░░░░░░░░░░░░░░ 3.2% 9/279 🔴
CameraPermissionsView.swift ░░░░░░░░░░░░░░░░░░░░ 0.0% 0/640 🔴
CameraHostController.swift ░░░░░░░░░░░░░░░░░░░░ 0.0% 0/197 🔴
RemoteShutterHelpView.swift ░░░░░░░░░░░░░░░░░░░░ 0.0% 0/1373 🔴
CameraPermissionErrorView.swift ░░░░░░░░░░░░░░░░░░░░ 0.0% 0/416 🔴
PhotosAccessDeniedView.swift ░░░░░░░░░░░░░░░░░░░░ 0.0% 0/606 🔴
FrameStreamingCoordinator.swift ░░░░░░░░░░░░░░░░░░░░ 0.0% 0/124 🔴
MulticamView.swift ░░░░░░░░░░░░░░░░░░░░ 0.0% 0/208 🔴
MicrophonePermissionPromptView.swift ░░░░░░░░░░░░░░░░░░░░ 0.0% 0/347 🔴
SettingsView.swift ░░░░░░░░░░░░░░░░░░░░ 0.0% 0/760 🔴
Photos.swift ░░░░░░░░░░░░░░░░░░░░ 0.0% 0/12 🔴
MulticamViewController.swift ░░░░░░░░░░░░░░░░░░░░ 0.0% 0/89 🔴
LocalNetworkPermissionView.swift ░░░░░░░░░░░░░░░░░░░░ 0.0% 0/747 🔴
PermissionManager.swift ░░░░░░░░░░░░░░░░░░░░ 0.0% 0/159 🔴
OrientationUtils.swift ░░░░░░░░░░░░░░░░░░░░ 0.0% 0/18 🔴
PhotosAccessHelper.swift ░░░░░░░░░░░░░░░░░░░░ 0.0% 0/324 🔴
CameraRig.swift ░░░░░░░░░░░░░░░░░░░░ 0.0% 0/574 🔴
StreamLog.swift ░░░░░░░░░░░░░░░░░░░░ 0.0% 0/1 🔴
MultipeerService.swift ░░░░░░░░░░░░░░░░░░░░ 0.0% 0/116 🔴
CameraCountdownTorch.swift ░░░░░░░░░░░░░░░░░░░░ 0.0% 0/61 🔴
Data+MD5.swift ░░░░░░░░░░░░░░░░░░░░ 0.0% 0/4 🔴

Generated from 510303c · workflow run

@darioalessandro

Copy link
Copy Markdown
Member Author

Consolidated into the single feature PR per Dario's request: #203 (branch kept, not deleted).

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant